这个CTF逆向题我已经解出flag了,程序hook了strcmp函数,可是我不太明白是怎样hook的...

बनाया गया: 25 मार्च 2026

का उपयोग करके उत्तर दिया गया GPT-5.4 Thinking द्वारा Chat01

प्रश्न

chall

这个CTF逆向题我已经解出flag了,程序hook了strcmp函数,可是我不太明白是怎样hook的,__libc_start_main_ptr点进去看似乎是导入函数,帮我讲清楚hook的原理以及在这个函数中的实现,它和tls回调有什么区别

विचार

Separation between GOT/PLT hook and TLS callback in ELF

I’m thinking about explaining the difference between GOT/PLT hooks and TLS callbacks. In ELF, GOT/PLT hooks modify function pointers after the dynamic lookup on first use, while TLS callbacks run early during process/thread attachment. TLS, unlike in PE/Windows, doesn't have a direct callback concept.

Explaining ELF init mechanisms and GOT overwriting

In this context, the code runs before main but after ELF mapping, via .init_array called through __libc_start_main. Unlike Windows, ELF doesn't use a TLS callback. Instead, it uses constructors and GOT overwriting. The relocation at 0x200fe0 is linked to __libc_start_main, and the decompiler shows it as __libc_start_main_ptr — not suspicious.

Thought for 1m 52s

उत्तर

看清以后,这题其实很典型:它不是 hook 了 __libc_start_main,而是借助程序启动阶段的 .init_array 构造函数,把 strcmp 的 GOT 表项改写成了自己的函数。

你看到的 __libc_start_main_ptr “像导入函数”这个判断是对的——它本来就是导入函数对应的 GOT 槽,只是它负责把程序启动流程接到 glibc 上,不是这题真正的 hook 点。

我按“原理 → 这题具体实现 → 和 TLS 回调区别”来讲。


1. 先说结论:这题是什么 hook

这是 ELF 下的 GOT/PLT hook,而且是在 main 之前 完成的。

核心动作只有两步:

  1. 先把 strcmp@GOT 当前的内容保存起来,作为“原始 strcmp 调用路径”
  2. 再把 strcmp@GOT 改成自定义函数地址

所以后面程序里所有 call strcmp@plt,最终都会先跳到它自己的函数。


2. 你看到的 __libc_start_main_ptr 到底是什么

在 ELF 里,__libc_start_main 是 glibc 提供的启动函数。程序入口 _start 并不会直接 call __libc_start_main,而是通常通过 GOT 里的导入指针 去调它。

你这个程序入口大致是这样:

asm
5fd: 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

它只是:

  • 动态链接导入项
  • GOT 里的一个槽
  • _start 用来把控制权交给 glibc

它不是这题的 hook 对象。


3. 真正的启动链: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 里有两个函数:

  • 0x6e0
  • 0x795

其中真正有用的是 0x795,它就是 安装 hook 的构造函数

所以完整顺序是:

text
_start -> __libc_start_main -> init(0x840) -> 遍历 .init_array -> 调用 0x795 -> 改写 strcmp@GOT -> main(0x7c7)

也就是说,进入 main 之前,strcmp 已经被换掉了。


4. 这题里 hook 的精确实现

4.1 strcmp@GOT 在哪

readelf -r 能看到:

text
.rela.plt: 0x201028 R_X86_64_JUMP_SLOT strcmp@GLIBC_2.2.5

所以 strcmp 的 GOT 槽在:

text
0x201028

4.2 安装 hook 的构造函数:0x795

这段最关键:

asm
795: 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

可以直接还原成伪代码:

c
void 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 那个懒绑定跳板。

你这个文件里,磁盘上的初始值确实是:

text
strcmp@GOT 初值 = 0x5c6

也就是 strcmp@plt 里的第二条指令位置。

所以它保存的其实是:

  • “原始调用路径”
  • 不一定是“已经解析后的 libc 真地址”

但这没问题,因为它后面照样可以经由这条原路径进入动态解析,最后到达真正的 libc strcmp


5. 被 hook 后的 strcmp 做了什么

自定义函数在 0x6ea

逻辑是:

  1. 计算第一个参数字符串长度
  2. 按 8 字节一组处理
  3. 从每组里减去一个常量
  4. 然后调用原始 strcmp

伪代码大概是:

c
int 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

也就是说,程序表面上写的是:

c
strcmp(argv[1], "zer0pts{********CENSORED********}")

但实际执行的是:

c
tmp = argv[1]; 对 tmp 每 8 字节减一个固定常量; 再拿变换后的 tmp 去和 "zer0pts{********CENSORED********}" 比较

所以正确输入不是明文目标串,而是“目标串加过一层变换后的值”。

你解出来的那个:

text
zer0pts{l3ts_m4k3_4_DETOUR_t0d4y}

正好说明这层变换就是把中间几段做了偏移。


6. 为什么它能直接改 argv[1]

因为这里第一个参数传给 strcmp 的是 argv[1],也就是进程启动时放在栈上的参数字符串区域。
这块内存通常是可写的,所以它可以原地改:

c
((uint64_t *)argv[1])[i] -= key[i];

这也是这题设计巧妙的地方:
它不是在比较前先复制一份再改,而是直接把你输入的字符串当缓冲区做原地解码


7. 这和 TLS 回调有什么区别

这个问题很关键。你可以把它们理解成:

  • 这题init_array 构造函数 + GOT hook
  • TLS callback:加载器在入口点前自动调用的一种“早期回调”

但两者不是一回事。

7.1 相同点

它们都可以做到:

  • main / OEP 之前执行
  • 做反调试、解密、修补、安装 hook
  • 让你在入口点看不到真实逻辑

所以逆向感觉上会很像:
“为什么还没进 main,函数行为就已经变了?”


7.2 本质区别

这题的机制

这是 ELF 启动流程里的构造函数机制

  • _start
  • __libc_start_main
  • init
  • .init_array
  • main

也就是说,程序自己注册了一个初始化函数,在这里动了 GOT。

它的重点是:

  • 执行时机:程序初始化阶段
  • 功能方式:改 GOT 表项
  • 作用对象:strcmp 的导入跳转

TLS callback 的机制

TLS callback 是 PE/Windows 体系里更典型的概念:

  • 由 loader 根据 TLS Directory 自动调用
  • 发生在 OEP 之前
  • 不仅进程启动时会调,线程创建/退出时也可能调

它的重点是:

  • 执行者是 loader
  • 触发原因是 TLS 机制
  • 可以在入口点前多次触发(尤其是线程相关)

7.3 用一句话区分

TLS callback 是“加载器给你的早期回调机会”,而这题是“程序在正常 ELF 初始化流程里,借构造函数主动改 GOT”。


8. 再精确一点:如果你硬要类比,ELF 里更接近 TLS callback 的是谁

在 ELF/Linux 里,最接近“入口前自动执行”的不是 TLS callback 这个概念本身,而是:

  • .preinit_array
  • .init_array
  • DT_INIT

这几个都属于启动早期执行的初始化机制。

而这题实际用的是:

text
.init_array

不是 TLS。


9. 为什么很多人会误判成“hook 了 __libc_start_main

因为逆向时会看到两件事同时出现:

  1. _start 里确实通过 __libc_start_main_ptr 间接调用
  2. 程序行为又明显在 main 前就变了

于是很容易脑补成:

“是不是 __libc_start_main 被换了?”

但这题里不是。

真正发生的是:

  • __libc_start_main 还是正常 glibc 的
  • 只是它在执行 init / .init_array 时,程序自己的构造函数把 strcmp@GOT 改了

所以你应该把这两层分开看:

第一层:正常启动

text
_start -> __libc_start_main

第二层:程序借启动时机安装 hook

text
__libc_start_main -> init -> .init_array -> 改 strcmp@GOT

10. 你可以把这题总结成这句话

这是一个利用 .init_arraymain 之前安装的 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);

विचार

我直接按寄存器和 ABI 把这段启动代码“展开”,把每一条指令对应到 __libc_start_main 的参数位置。这样你就能看出:它不是“依次 call main/init/fini”,而是把这些函数指针交给 glibc,由 glibc 在合适时机去调用。

Thought for 1m 10s

उत्तर

可以把这段 _start 理解成一句话:

它不是自己去调用 main/init/fini,而是把这些函数指针和启动参数整理好,交给 __libc_start_main,后面真正的启动流程由 glibc 接管。

先看你这段汇编:

asm
start: 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

1. __libc_start_main_ptr 的参数到底是什么

在 x86-64 SysV ABI 下,前 6 个整型/指针参数走寄存器:

  • rdi
  • rsi
  • rdx
  • rcx
  • r8
  • r9

第 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] );

经典原型通常写成:

c
int __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 写成:

c
int (*main)(int, char **, char **)

第三个 char **envp,即环境变量指针。
虽然你的 main 可能只定义成 int main(int argc, char **argv),但 glibc 内部是按更宽泛的形式处理的。


2. 每条指令在干什么

xor ebp, ebp

ebp 清零。
这是启动代码常见操作,主要是让栈回溯/调试器看到一个干净的最外层 frame 边界。


mov r9, rdx

把进入 _startrdx 里的值保存到 r9,作为 __libc_start_main 的第 6 个参数 rtld_fini

这个 rdx 不是你程序自己传的,而是动态链接器/运行时装载器给的清理函数指针,程序退出时 glibc 会用它做收尾。


pop rsi

程序刚启动时,栈顶布局不是普通函数调用那套“返回地址 + 参数”,而是内核准备好的启动栈:

text
rsp -> argc argv[0] argv[1] ... NULL envp[0] ... NULL auxv...

所以这里 pop rsi 的意思是:

c
rsi = argc; rsp += 8;

于是 rsi 成了 __libc_start_main 的第 2 个参数。


mov rdx, rsp

此时 rsp 已经指向 argv[0] 了,所以:

c
rdx = 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 启动逻辑。


3. 它是不是“依次调用传入的函数地址”

不是简单的“按顺序 call 一遍”。

更准确地说:

_start 把程序的几个关键入口交给 glibc,由 glibc 按自己的启动/退出流程在合适时机调用

粗略流程可以理解成:

c
int __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:动态链接器自己的清理函数

4. main/init/fini 的参数是怎么传递的

main

main 的参数来源就是这里传给 __libc_start_main 的:

  • argc
  • argv

envp 不需要 _start 明确单独传进来,因为 glibc 可以通过启动栈从 argv 后面继续找到它。

调用 main 时,glibc 会按 ABI 再把参数放进寄存器:

c
main(argc, argv, envp);

init

这个点要注意:你看到的 init 不一定真的是一个“无参函数”。

反编译器常把它简化成:

c
void (*init)(void)

但实际上传给 __libc_start_main 的这个“初始化入口”在很多 ELF 里会是 __libc_csu_init 一类函数,它内部会再去调用:

  • _init
  • .init_array 里的构造函数

而这些初始化函数可能会拿到:

  • argc
  • argv
  • envp

所以从“汇编里这里只传了函数地址”并不意味着“它以后调用时一定没参数”。


fini

fini 一般是退出阶段的清理入口,通常无参。


5. 为什么反编译成这样

你看到的伪代码:

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 之类的名字。

但这里本质上是:

c
argv

也就是:

c
(char **)rsp

(void (*)(void))init

反编译器把 init 当成无参函数指针,是一种保守简化。
不代表 glibc 后面一定按“无参”方式去调用它内部真正的初始化逻辑。


fini

就是第 5 个参数。


a3

这通常就是原来进入 _startrdx 里的那个值,也就是:

c
rtld_fini

反编译器没把它识别出语义,就给了一个形参名。


&v5

这是它为 stack_end 伪造出来的局部变量地址表示。

本质上就是压栈进去的那个 rsp 值。


6. 为什么反编译器会把 argv 识别成 &retaddr

因为 _start 不是普通函数,没有真实返回地址,也没有标准函数原型

在普通 C 函数里,反编译器习惯认为:

text
[rsp] 返回地址 [rsp+8] 第一个栈上传参

_start 这里完全不是这回事:

text
[rsp] = argc [rsp+8] = argv[0] ...

所以当它把这种“内核准备的启动栈”硬套进“C 函数栈帧模型”时,就会出现:

  • retaddr
  • v4
  • v5
  • a3

这类其实没什么语义的伪变量名。


7. 这段代码最接近的手写 C 伪代码

可以写成这样:

c
void _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(); }

8. 和你前一个问题连起来看

这也解释了为什么你那个样本里 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 前把那套初始化跑完了。


9. 你可以直接这样记

_start 的职责

只做三件事:

  1. 从启动栈里取出 argc/argv
  2. 整理调用约定和栈对齐
  3. main/init/fini/rtld_fini 交给 __libc_start_main

__libc_start_main 的职责

负责真正的:

  • libc 初始化
  • 构造函数执行
  • 调用 main
  • 退出清理

如果你愿意,我可以下一条把你这个二进制里 start -> init -> init_array -> 0x795 hook strcmp 这条链,按“寄存器/参数/控制流”画成一张非常清晰的调用图。

इस प्रश्नोत्तर को साझा करें