这个CTF逆向题我已经解出flag了,程序hook了strcmp函数,可是我不太明白是怎样hook的...
创建于:2026年3月25日
使用 GPT-5.4 Thinking 回答 Chat01
创建于:2026年3月25日
使用 GPT-5.4 Thinking 回答 Chat01
这个CTF逆向题我已经解出flag了,程序hook了strcmp函数,可是我不太明白是怎样hook的,__libc_start_main_ptr点进去看似乎是导入函数,帮我讲清楚hook的原理以及在这个函数中的实现,它和tls回调有什么区别
看清以后,这题其实很典型:它不是 hook 了 __libc_start_main,而是借助程序启动阶段的 .init_array 构造函数,把 strcmp 的 GOT 表项改写成了自己的函数。
你看到的 __libc_start_main_ptr “像导入函数”这个判断是对的——它本来就是导入函数对应的 GOT 槽,只是它负责把程序启动流程接到 glibc 上,不是这题真正的 hook 点。
我按“原理 → 这题具体实现 → 和 TLS 回调区别”来讲。
这是 ELF 下的 GOT/PLT hook,而且是在 main 之前 完成的。
核心动作只有两步:
strcmp@GOT 当前的内容保存起来,作为“原始 strcmp 调用路径”strcmp@GOT 改成自定义函数地址所以后面程序里所有 call strcmp@plt,最终都会先跳到它自己的函数。
__libc_start_main_ptr 到底是什么在 ELF 里,__libc_start_main 是 glibc 提供的启动函数。程序入口 _start 并不会直接 call __libc_start_main,而是通常通过 GOT 里的导入指针 去调它。
你这个程序入口大致是这样:
asm5fd: lea rdi, [rip+0x1c3] ; main = 0x7c7 5f6: lea rcx, [rip+0x243] ; init = 0x840 5ef: lea r8, [rip+0x2ba] ; fini = 0x8b0 604: call qword ptr [rip+0x2009d6] ; [0x200fe0] = __libc_start_main@GOT
这里的 [0x200fe0] 就是你反编译里看到的 __libc_start_main_ptr。
它只是:
_start 用来把控制权交给 glibc它不是这题的 hook 对象。
main 前执行的这题关键在 __libc_start_main 的参数里传了一个 init 函数地址:0x840。
也就是:
c__libc_start_main(main, argc, argv, init, fini, rtld_fini, stack_end);
在 glibc 启动过程中,它会先去执行这个 init,而这个 init 会继续调用 .init_array 里的构造函数。
你这个程序的 .init_array 里有两个函数:
0x6e00x795其中真正有用的是 0x795,它就是 安装 hook 的构造函数。
所以完整顺序是:
text_start -> __libc_start_main -> init(0x840) -> 遍历 .init_array -> 调用 0x795 -> 改写 strcmp@GOT -> main(0x7c7)
也就是说,进入 main 之前,strcmp 已经被换掉了。
strcmp@GOT 在哪readelf -r 能看到:
text.rela.plt: 0x201028 R_X86_64_JUMP_SLOT strcmp@GLIBC_2.2.5
所以 strcmp 的 GOT 槽在:
text0x201028
0x795这段最关键:
asm795: call 79e 79e: pop rdx 79f: sub rdx, 0x79e ; 求 PIE 基址 7a6: mov rdi, rdx 7a9: add rdi, 0x201028 ; rdi = &strcmp@GOT 7b0: mov rax, [rdi] ; 取出原来的 GOT 内容 7b3: mov [rdx+0x201090], rax ; 保存到全局 0x201090 7ba: add rdx, 0x6ea ; rdx = 自定义 strcmp 7c1: mov [rdi], rdx ; strcmp@GOT = hook 函数 7c6: ret
可以直接还原成伪代码:
cvoid ctor_install_hook(void) { void **got_strcmp = BASE + 0x201028; orig_strcmp = *got_strcmp; // 存到 BASE + 0x201090 *got_strcmp = BASE + 0x6ea; // 改成自定义函数 }
它保存下来的“原始 strcmp”不一定已经是 libc 里真正的 strcmp 地址。
因为程序默认是 lazy binding,在第一次调用前,strcmp@GOT 里往往还是指向 strcmp@plt+6 那个懒绑定跳板。
你这个文件里,磁盘上的初始值确实是:
textstrcmp@GOT 初值 = 0x5c6
也就是 strcmp@plt 里的第二条指令位置。
所以它保存的其实是:
但这没问题,因为它后面照样可以经由这条原路径进入动态解析,最后到达真正的 libc strcmp。
strcmp 做了什么自定义函数在 0x6ea。
逻辑是:
strcmp伪代码大概是:
cint hooked_strcmp(char *s1, const char *s2) { int n = (strlen(s1) >> 3) + 1; // 8字节分组数,含末尾残块 for (int i = 0; i < n; i++) { ((uint64_t *)s1)[i] -= key[i]; } return orig_strcmp(s1, s2); }
其中 key 在 .data 里,地址大概是 0x201060。
也就是说,程序表面上写的是:
cstrcmp(argv[1], "zer0pts{********CENSORED********}")
但实际执行的是:
ctmp = argv[1]; 对 tmp 每 8 字节减一个固定常量; 再拿变换后的 tmp 去和 "zer0pts{********CENSORED********}" 比较
所以正确输入不是明文目标串,而是“目标串加过一层变换后的值”。
你解出来的那个:
textzer0pts{l3ts_m4k3_4_DETOUR_t0d4y}
正好说明这层变换就是把中间几段做了偏移。
argv[1]因为这里第一个参数传给 strcmp 的是 argv[1],也就是进程启动时放在栈上的参数字符串区域。
这块内存通常是可写的,所以它可以原地改:
c((uint64_t *)argv[1])[i] -= key[i];
这也是这题设计巧妙的地方:
它不是在比较前先复制一份再改,而是直接把你输入的字符串当缓冲区做原地解码。
这个问题很关键。你可以把它们理解成:
init_array 构造函数 + GOT hook但两者不是一回事。
它们都可以做到:
main / OEP 之前执行所以逆向感觉上会很像:
“为什么还没进 main,函数行为就已经变了?”
这是 ELF 启动流程里的构造函数机制:
_start__libc_start_maininit.init_arraymain也就是说,程序自己注册了一个初始化函数,在这里动了 GOT。
它的重点是:
strcmp 的导入跳转TLS callback 是 PE/Windows 体系里更典型的概念:
它的重点是:
TLS callback 是“加载器给你的早期回调机会”,而这题是“程序在正常 ELF 初始化流程里,借构造函数主动改 GOT”。
在 ELF/Linux 里,最接近“入口前自动执行”的不是 TLS callback 这个概念本身,而是:
.preinit_array.init_arrayDT_INIT这几个都属于启动早期执行的初始化机制。
而这题实际用的是:
text.init_array
不是 TLS。
__libc_start_main”因为逆向时会看到两件事同时出现:
_start 里确实通过 __libc_start_main_ptr 间接调用main 前就变了于是很容易脑补成:
“是不是
__libc_start_main被换了?”
但这题里不是。
真正发生的是:
__libc_start_main 还是正常 glibc 的init / .init_array 时,程序自己的构造函数把 strcmp@GOT 改了所以你应该把这两层分开看:
text_start -> __libc_start_main
text__libc_start_main -> init -> .init_array -> 改 strcmp@GOT
这是一个利用 .init_array 在 main 之前安装的 GOT hook:构造函数先保存 strcmp 原始跳转路径,再把 strcmp@GOT 改写为自定义比较函数,自定义函数先原地解码用户输入,再转调原始 strcmp。__libc_start_main_ptr 只是正常的导入/GOT 项,不是 hook 本体。
如果你愿意,我下一条可以直接把这几个地址整理成一份更像 IDA 注释的“函数级伪代码版”,包括 0x795 / 0x6ea / 0x840 / 0x7c7 四个函数逐个翻译。
.text:0000558C556005E0 start proc near
.text:0000558C556005E0 ; __unwind { // 558C55600000
.text:0000558C556005E0 xor ebp, ebp
.text:0000558C556005E2 mov r9, rdx ; rtld_fini
.text:0000558C556005E5 pop rsi ; argc
.text:0000558C556005E6 mov rdx, rsp ; ubp_av
.text:0000558C556005E9 and rsp, 0FFFFFFFFFFFFFFF0h
.text:0000558C556005ED push rax
.text:0000558C556005EE push rsp ; stack_end
.text:0000558C556005EF lea r8, fini ; fini
.text:0000558C556005F6 lea rcx, init ; init
.text:0000558C556005FD lea rdi, main ; main
.text:0000558C55600604 call cs:__libc_start_main_ptr
.text:0000558C5560060A hlt
这个函数如何理解?__libc_start_main_ptr的参数是哪些?作用是依次调用传入的函数地址吗?main,init,fini的参数又通过什么传递?为什么反编译成这样了: _libc_start_main((int (__fastcall )(int, char **, char **))main, v4, &retaddr, (void ()(void))init, fini, a3, &v5);
可以把这段 _start 理解成一句话:
它不是自己去调用 main/init/fini,而是把这些函数指针和启动参数整理好,交给 __libc_start_main,后面真正的启动流程由 glibc 接管。
先看你这段汇编:
asmstart: xor ebp, ebp mov r9, rdx ; rtld_fini pop rsi ; argc mov rdx, rsp ; argv and rsp, -16 push rax push rsp ; stack_end lea r8, fini ; fini lea rcx, init ; init lea rdi, main ; main call cs:__libc_start_main_ptr hlt
__libc_start_main_ptr 的参数到底是什么在 x86-64 SysV ABI 下,前 6 个整型/指针参数走寄存器:
rdirsirdxrcxr8r9第 7 个参数走栈。
所以在 call __libc_start_main_ptr 这一刻,参数对应关系就是:
c__libc_start_main( main, // rdi argc, // rsi argv, // rdx init, // rcx fini, // r8 rtld_fini, // r9 stack_end // [rsp] );
经典原型通常写成:
cint __libc_start_main( int (*main)(int, char **, char **), int argc, char **ubp_av, void (*init)(void), void (*fini)(void), void (*rtld_fini)(void), void *stack_end );
很多资料里 main 写成:
cint (*main)(int, char **, char **)
第三个 char ** 是 envp,即环境变量指针。
虽然你的 main 可能只定义成 int main(int argc, char **argv),但 glibc 内部是按更宽泛的形式处理的。
xor ebp, ebp把 ebp 清零。
这是启动代码常见操作,主要是让栈回溯/调试器看到一个干净的最外层 frame 边界。
mov r9, rdx把进入 _start 时 rdx 里的值保存到 r9,作为 __libc_start_main 的第 6 个参数 rtld_fini。
这个 rdx 不是你程序自己传的,而是动态链接器/运行时装载器给的清理函数指针,程序退出时 glibc 会用它做收尾。
pop rsi程序刚启动时,栈顶布局不是普通函数调用那套“返回地址 + 参数”,而是内核准备好的启动栈:
textrsp -> argc argv[0] argv[1] ... NULL envp[0] ... NULL auxv...
所以这里 pop rsi 的意思是:
crsi = argc; rsp += 8;
于是 rsi 成了 __libc_start_main 的第 2 个参数。
mov rdx, rsp此时 rsp 已经指向 argv[0] 了,所以:
crdx = argv;
这就是第 3 个参数。
and rsp, 0xFFFFFFFFFFFFFFF0把栈 16 字节对齐,满足 x86-64 ABI 的调用要求。
push rax这一步常见于启动代码,主要是为了补齐栈布局/对齐。
这里的 rax 值本身通常不重要,你可以把它看成“占一个槽位”。
push rsp把当前 rsp 压栈,作为第 7 个参数 stack_end。
注意这里压进去的是“当前栈顶地址的值”,不是 rsp 这个寄存器本身的引用。
lea r8, fini第 5 个参数:fini
lea rcx, init第 4 个参数:init
lea rdi, main第 1 个参数:main
call __libc_start_main_ptr真正进入 glibc 启动逻辑。
不是简单的“按顺序 call 一遍”。
更准确地说:
_start 把程序的几个关键入口交给 glibc,由 glibc 按自己的启动/退出流程在合适时机调用。
粗略流程可以理解成:
cint __libc_start_main(main, argc, argv, init, fini, rtld_fini, stack_end) { // 1. 初始化 libc 自己 // 2. 设置 environ、TLS、安全机制、线程库等 // 3. 调用 init(通常会进一步跑 .init_array 里的构造函数) // 4. 调用 main(argc, argv, envp) // 5. main 返回后调用 exit(status) // 6. exit 过程中触发 fini / rtld_fini / atexit / 析构函数 }
所以:
init:通常在 main 之前main:主程序入口fini:不是紧跟在 main 后直接 call,而是退出流程里清理时触发rtld_fini:动态链接器自己的清理函数main/init/fini 的参数是怎么传递的mainmain 的参数来源就是这里传给 __libc_start_main 的:
argcargvenvp 不需要 _start 明确单独传进来,因为 glibc 可以通过启动栈从 argv 后面继续找到它。
调用 main 时,glibc 会按 ABI 再把参数放进寄存器:
cmain(argc, argv, envp);
init这个点要注意:你看到的 init 不一定真的是一个“无参函数”。
反编译器常把它简化成:
cvoid (*init)(void)
但实际上传给 __libc_start_main 的这个“初始化入口”在很多 ELF 里会是 __libc_csu_init 一类函数,它内部会再去调用:
_init.init_array 里的构造函数而这些初始化函数可能会拿到:
argcargvenvp所以从“汇编里这里只传了函数地址”并不意味着“它以后调用时一定没参数”。
finifini 一般是退出阶段的清理入口,通常无参。
你看到的伪代码:
c_libc_start_main( (int (__fastcall *)(int, char **, char **))main, v4, &retaddr, (void (*)(void))init, fini, a3, &v5 );
这是反编译器在“尽力把非标准入口 _start 拟合成 C 函数”的结果。
(int (__fastcall *)(int, char **, char **))main这就是它给 main 造出来的函数指针类型。
这里的 __fastcall 不用太在意。
在 IDA 里,64 位下经常会用 __fastcall 这种标签来表示“寄存器传参的默认约定”,不代表这是 Windows 那套 fastcall。
v4这就是 argc。
对应 pop rsi 取出来的值。
&retaddr这个最容易把人看晕。
因为 _start 不是一个“正常被 call 进来的 C 函数”,反编译器没法给它构造出漂亮的局部变量模型。
它看到当前 rsp 指向一块栈区,就可能把那一格附近虚构成 retaddr 之类的名字。
但这里本质上是:
cargv
也就是:
c(char **)rsp
(void (*)(void))init反编译器把 init 当成无参函数指针,是一种保守简化。
不代表 glibc 后面一定按“无参”方式去调用它内部真正的初始化逻辑。
fini就是第 5 个参数。
a3这通常就是原来进入 _start 时 rdx 里的那个值,也就是:
crtld_fini
反编译器没把它识别出语义,就给了一个形参名。
&v5这是它为 stack_end 伪造出来的局部变量地址表示。
本质上就是压栈进去的那个 rsp 值。
argv 识别成 &retaddr因为 _start 不是普通函数,没有真实返回地址,也没有标准函数原型。
在普通 C 函数里,反编译器习惯认为:
text[rsp] 返回地址 [rsp+8] 第一个栈上传参
但 _start 这里完全不是这回事:
text[rsp] = argc [rsp+8] = argv[0] ...
所以当它把这种“内核准备的启动栈”硬套进“C 函数栈帧模型”时,就会出现:
retaddrv4v5a3这类其实没什么语义的伪变量名。
可以写成这样:
cvoid _start() { int argc; char **argv; void (*rtld_fini)(void); void *stack_end; rtld_fini = (void (*)(void))rdx_in; argc = *(long *)rsp_in; argv = (char **)(rsp_in + 8); rsp_aligned = align_down(rsp_in + 8, 16); stack_end = rsp_aligned - 8; // 近似理解即可 __libc_start_main( main, argc, argv, init, fini, rtld_fini, stack_end ); __builtin_unreachable(); }
这也解释了为什么你那个样本里 hook 能发生在 main 前:
因为 __libc_start_main 在真正调用 main 之前,会先处理 init,而 init 又会进一步跑 .init_array。
所以构造函数完全可以在这个阶段把 strcmp@GOT 改掉。
也就是说:
text_start -> __libc_start_main(...) -> init -> .init_array -> 安装 strcmp hook -> main
所以不是 _start 自己做了 hook,而是它把“初始化入口”交给了 glibc,glibc 在 main 前把那套初始化跑完了。
_start 的职责只做三件事:
argc/argvmain/init/fini/rtld_fini 交给 __libc_start_main__libc_start_main 的职责负责真正的:
main如果你愿意,我可以下一条把你这个二进制里 start -> init -> init_array -> 0x795 hook strcmp 这条链,按“寄存器/参数/控制流”画成一张非常清晰的调用图。