ho_0k
打开IDA,看到主入口里面就是一个平平无奇的输入与预期字符串比较,大概率是判断flag
int __fastcall main(int argc, const char **argv, const char **envp)
{
char Str1[64]; // [rsp+20h] [rbp-40h] BYREF
_main(argc, argv, envp);
puts("Input your flag:");
scanf("%64s", Str1);
if ( !strcmp(Str1, "VIDAR{ho_0k&&h0_ok&&h0_0k&&ho_ok}") ) //很奇怪 这里使用了几个变量替换
puts("yes!");
else
puts("no!");
return 0;
}
然后看到ho_0k 大概率就是程序的核心部分
int ho_0k()
{
int result; // eax
HANDLE CurrentProcess; // rax
HANDLE v2; // rax
SIZE_T NumberOfBytesRead; // [rsp+38h] [rbp-38h] BYREF
_BYTE v4[5]; // [rsp+46h] [rbp-2Ah] BYREF
int Buffer; // [rsp+4Bh] [rbp-25h] BYREF
char v6; // [rsp+4Fh] [rbp-21h]
struct _PEB *v7; // [rsp+50h] [rbp-20h]
int v8; // [rsp+5Ch] [rbp-14h]
struct _PEB *v9; // [rsp+60h] [rbp-10h]
LPCVOID lpBaseAddress; // [rsp+68h] [rbp-8h]
result = (int)refptr_strcmp; //这是链接器生成的对 strcmp 的函数引用(全局函数指针)。
lpBaseAddress = refptr_strcmp; //它实际指向真正的 strcmp() 实现地址。
Buffer = 0;
v6 = 0;
memset(v4, 0, sizeof(v4));
NumberOfBytesRead = 0LL;
if ( refptr_strcmp )
{
v8 = 96;
v7 = NtCurrentPeb(); //读取当前Peb
v9 = v7;
CurrentProcess = GetCurrentProcess(); //获取当前线程
ReadProcessMemory(CurrentProcess, lpBaseAddress, &Buffer, 5uLL, &NumberOfBytesRead);
//从 refptr_strcmp(即 strcmp 的函数指针)读取前 5 字节
v4[0] = -23 - v9->BeingDebugged; //修改一个字节 反调试
*(_DWORD *)&v4[1] = (unsigned int)strcmp - 5 - (_DWORD)lpBaseAddress; // 计算偏移
v2 = GetCurrentProcess();
return WriteProcessMemory(v2, (LPVOID)lpBaseAddress, v4, 5uLL, &NumberOfBytesRead); //自修改
}
return result;
}
ho_0k解读
-
refptr_strcmp
这是链接器生成的对strcmp 的函数引用(全局函数指针)。
它实际指向真正的strcmp()实现地址。 -
ReadProcessMemory 与 WriteProcessMemory
这意味着程序在运行时读取并修改自己内存中的代码(自修改代码)。 -
修改内容:
v4[0] = -23 - v9->BeingDebugged; *(_DWORD*)&v4[1] = (unsigned int)strcmp - 5 - (_DWORD)lpBaseAddress;一般来说,
v4[0] 很可能是写入一个JMP 指令(0xE9)的补码形式。
-23 十进制是0xE9 的补码(256 – 23 = 233 = 0xE9)。
因此如果没有调试(BeingDebugged==0),v4[0] = 0xE9;
如果在调试,v4[0] = 0xE8 (call) 或 0xE9 – 1 之类——反调试关键点。
所以:这段代码实质是:当程序没被调试时,把 strcmp 前 5 字节改写为一个 jmp 指令,跳转到真正的 strcmp 实现处。
如果在调试器下,BeingDebugged = 1,会导致写入错误的字节码而破坏 strcmp,使程序失效。
这段 strcmp 伪代码正是你那道题里真正的“隐藏核心逻辑”——
原来程序不是直接调用标准 strcmp,而是自己在运行时修补 IAT (导入表)把系统的 strcmp 替换成了它自写的版本,也就是这个。
下面给你详细拆解一下,每一步都是什么作用。
🧩 结构概览
int64 __fastcall strcmp(const char *a1)
{
_BYTE v2[48];
...填充了 40 个字节常量...
if (strlen(a1) != 40)
return 1LL;
v4 = 96;
v3 = NtCurrentPeb();
p_NtGlobalFlag = &v3->NtGlobalFlag;
for (i = 0; i < strlen(a1); ++i)
{
a1[i] = *p_NtGlobalFlag + 17 * a1[i] + 35;
if (a1[i] != v2[i])
return 1LL;
}
return 0LL;
}
🧠 逐行说明
1️⃣ v2 是目标字节序列
这 40 字节(负数只是 unsigned char 变换)就是最终希望你输入的每一位在计算完后的结果。
也就是说,合法的输入字符串 a1 经过某个数学变换后应该刚好等于 v2[]。
2️⃣ PEB / NtGlobalFlag 与反调试
v3 = NtCurrentPeb();
p_NtGlobalFlag = &v3->NtGlobalFlag;
-
NtCurrentPeb():取当前进程 PEB。 -
NtGlobalFlag距离 BeingDebugged 更后一点;Windows 会在调试器存在时让这个标志的部分位(0x70, 0x2, 0x10)拉高。
题目的意图:相同的输入,如果正在被调试器附加,它读取到的 NtGlobalFlag 不同,后续计算结果全部不同。
这就是反调试机制里的另一个检测点。
3️⃣ 循环逻辑
a1[i] = *p_NtGlobalFlag + 17 * a1[i] + 35;
每个字符都会被 “加密” 成一个新数值:
new_char = 17 * old_char + NtGlobalFlag + 35
然后与 v2[i] 比较。
如果所有 40 位都相等返回 0(正确),否则返回 1(错误)。
🔍 程序的真实校验顺序
-
程序启动时执行
ho_0k()。- 它把 IAT 里的
strcmp地址替换成此处自定义版本。 - 如果检测到 BeingDebugged = 1(由 PEB 设置),还会破坏那 5 字节,使校验一定失败。
- 它把 IAT 里的
-
后面调用 “strcmp” 的地方实际上调用了这段函数。
-
该函数再通过
NtGlobalFlag检查当前是否被调试;若是,计算不同。
🔧 你可以怎么验证(在比赛沙盒中)
以下都只在 CTF 沙盒环境里进行,用来理解过程,不是实机利用。
-
脱离调试器运行程序(让
NtGlobalFlag=0),运行输入你自己算出的结果字符串,看它是否输出yes!。 -
静态推算逆变换
数学等式:v2[i] = 17 * plain[i] + NtGlobalFlag + 35当
NtGlobalFlag=0时:plain[i] = (v2[i] - 35) * 17^-1 mod 256其中 17^-1 是 17 在 256 下的乘法逆元 (=241)。
所以:plain[i] = ((v2[i] - 35) * 241) mod 256用这公式对 40 个 v2 字节解回去就能得到正确 flag。
(你可以用 Python/C 去算,在比赛环境中自己编码即可。)
-
带调试器运行相当于
NtGlobalFlag 不为 0;此时每个结果不同,自然比较失败,输出no!。
✅ 小结
| 项目 | 说明 |
|---|---|
| 函数来源 | 程序自定义的伪 strcmp |
| 功能 | 校验 flag 是否正确 |
| 检查机制 | 每个字符经线性变换 (17x+35+NtGlobalFlag) 与固定表比对 |
| 反调试点 | 依赖 NtGlobalFlag 值变化,调试器附加时必错 |
| 逆向要点 | 关闭调试、或模拟 NtGlobalFlag=0,再对表 v2 求逆变换恢复原 flag |
所以一句话总结:
这段 “strcmp” 是程序在反调试保护下实现的自定义校验函数,用来检测输入的 flag。
很好!你找到了被修改后的 strcmp 函数。这是一个加密比较逻辑,让我们分析一下:
代码分析
关键逻辑:
- 检查输入长度是否为 40
- 使用
NtGlobalFlag作为密钥的一部分 - 对每个字符进行变换:
a1[i] = *p_NtGlobalFlag + 17 * a1[i] + 35 - 与硬编码的
v2数组比较
解题步骤
第一步:获取 NtGlobalFlag 值
在调试器中:
- 在修改后的
strcmp函数中设断点 - 运行程序,输入任意40字符的字符串
- 当断点命中时,查看
p_NtGlobalFlag指向的值
或者直接计算:
// 在正常情况下,NtGlobalFlag 的值通常是:
// - 非调试状态:0x0
// - 调试状态:0x70
第二步:逆向加密算法
加密公式:
encrypted[i] = NtGlobalFlag + 17 * input[i] + 35
解密公式:
input[i] = (encrypted[i] - NtGlobalFlag - 35) * inverse(17) mod 256
第三步:编写解密脚本
# 加密数据
v2 = [
-39, -4, -89, 116, -107, 78, -106, -126, 113, -6,
-75, -105, -41, -24, 79, -108, -41, 100, -126, 113,
-58, 114, -4, 113, 47, 28, 113, -40, -21, 83,
-126, 62, 114, 96, -108, -58, -41, -40, -75, 112
]
# 转换为无符号字节
encrypted = [b & 0xFF for b in v2]
# 假设 NtGlobalFlag 值(需要调试确认)
nt_global_flag = 0x0 # 或者 0x70,需要实际调试确定
# 计算模逆元(17在模256下的逆元)
# 17 * 逆元 ≡ 1 mod 256
def mod_inverse(a, m=256):
for i in range(m):
if (a * i) % m == 1:
return i
return None
inverse_17 = mod_inverse(17)
# 解密函数
def decrypt(encrypted, nt_global_flag):
result = []
for enc_byte in encrypted:
# input[i] = (encrypted[i] - nt_global_flag - 35) * inverse(17) mod 256
decrypted = ((enc_byte - nt_global_flag - 35) * inverse_17) % 256
result.append(decrypted)
return bytes(result)
# 尝试两种可能的 NtGlobalFlag 值
for flag_val in [0x00, 0x70]:
try:
decrypted = decrypt(encrypted, flag_val)
print(f"NtGlobalFlag=0x{flag_val:02x}: {decrypted}")
# 检查是否可打印
if all(32 <= b < 127 for b in decrypted):
print(f"可读字符串: {decrypted.decode('ascii')}")
except:
pass
第四步:在调试器中确认 NtGlobalFlag
在 x64dbg 中:
-
在修改后的
strcmp中设断点 -
运行并输入40个字符(如 "A"*40)
-
当断点命中时:
- 查看
p_NtGlobalFlag指向的值 - 或者在寄存器/内存窗口中找这个值
- 查看
第五步:获取真实 Flag
运行解密脚本后,你会得到真正的 flag。
快速验证:先尝试 NtGlobalFlag = 0,如果结果是乱码,再试 NtGlobalFlag = 0x70。
你现在可以在调试器中查看 p_NtGlobalFlag 的实际值吗?这样我们就能得到确切的 flag 了。
VIDAR{Congr4tulat1ons_InLineH0ok_master}