Cris.Q

Back

摆弄一下 Stack Canary CheckerBlur image

之前学习了一些关于程序栈和栈溢出/栈保护的知识,了解到 GCC 会默认给函数插入 Canary Checker 进行栈溢出防护。考查 Checker 的性质:

  1. 它是编译器自动插入的,源代码中不会显式调用
  2. 它只在发生栈溢出时会被调用
  3. 反编译器(以 HexRay Decompiler 为例)只会检查函数签名是否与数据库匹配

于是,一个自然的想法就是:反编译器为了尽可能“还原”人手编写的 C 代码,应该不会事无巨细地显示所有“噪声”函数,而 AI 一般为了效率也倾向于忽略之。因此,如果我们覆写一个自定义的 Canary Checker,是否就能实现某种混淆,瞒天过海呢?

前置知识#

Stack Canary#

由于在栈帧内部栈向下增长,如果栈数据太大,可能会覆盖前一栈帧的内容。为了避免因此造成数据损失或程序异常,C Compiler 一般都会在函数末尾插入 Stack Canary Checker,如果不通过则直接崩溃退出程序。

一个典型的栈金丝雀工作原理是:从神奇的 fs:28h 地址1取一个 8 字节随机数2A),存入栈底(B),等函数返回时进行比对(A=B?),若不同则进入__stack_chk_fail函数,由它进行后续处理。一个典型的反编译结果形如:

因此,我们首先可以想到:覆写 __stack_chk_fail,让它在报错退出之前“额外做一些工作”。

强符号与弱符号#

__stack_chk_fail是一个弱符号,这是让覆写成为可能的关键。

强符号有如下特点3

  • 由明确定义的变量或函数产生。
  • 同一个程序中不能有多个强符号定义同名符号,否则链接器报错(重定义错误)。
  • 定义全局变量或函数。
  • 确保唯一性,避免冲突。
  • 提供明确的符号定义,链接器优先选择强符号

而弱符号则:

  • 通过特殊方式声明(如 __attribute__((weak))),表示它是备选符号。
  • 如果有强符号与弱符号同名,链接器选择强符号;如果只有弱符号存在,链接器选择弱符号。
  • 多个弱符号同名时,链接器只选择其中一个。
  • 提供默认实现,允许用户通过强符号覆盖。
  • 支持动态链接中的符号解析。
  • 提供灵活性,支持符号覆盖机制。(malloc/free 都是弱符号,因此tcmalloc才能实现自己的malloc、free函数进行覆盖)

换言之,glibc中提供的__stack_chk_fail是一个备选方案,我们可以用自己声明的强符号覆盖它。

GCC 何时插入 Canary Checker#

详细信息见GCC文档,比较复杂。

综合各种编译参数,为了让stack_chk_fail的自由度更高,我选用了-fstack-protector-explicit,这样每个显式声明__attribute__((stack_protect))的函数都会被保护。

获取和修改 FS 段的 canary 原件#

GCC 提供了 __seg_fs 符号让我们访问 CPU 的 FS 段,但为了清晰,我还是选择了内联汇编。定义如下两个 helper 函数:

static inline uint64_t get_canary() {
    uint64_t canary;
    __asm__ __volatile__("movq %%fs:0x28, %0" : "=r"(canary));
    return canary;
}

static inline uint64_t set_canary(uint64_t canary) {
    __asm__ __volatile__("movq %0, %%fs:0x28" : : "r"(canary));
    return canary;
}
c

开始实验#

最小可用 demo#

我们写一个最简的示例:

#include <stdio.h>
#include <unistd.h>
int main() {
    char stack[1];
    scanf("%s", stack);
    printf("%s", stack);
    return 0;
}

void __stack_chk_fail(void) {
    static const char msg[] = "Hello, World!\n";
    write(2, msg, sizeof(msg) - 1);
}
c
> ./main
12
Hello, World!
12
bash

可以发现:

  • __stack_chk_fail被执行了
  • 栈溢出后printf正常执行了(因为我们只是覆盖了canary)

使用gdb跟栈: gdb 跟栈,可见 __stack_chk_fail 已被替换

此时,__stack_chk_fail就是一个可以做正常函数操作的函数了。那么,是否可以让__stack_chk_fail隐式调用自己,实现尾递归?

一个简单的想法是,__stack_chk_fail本身就是一个函数,它的末尾能不能也被插入 Canary Checker 呢?综合上面知识,我们给__stack_chk_fail加入 attribute 就行了。

更复杂的控制流#

有了set_canary这一利器,我们可以自由控制何时发生溢出。

简单的循环逻辑#

> ./main
AAAAAAAA
iter: 0
iter: 1
iter: 2
iter: 3
iter: 4
bash

迭代#

我们用全局变量保存计算状态,就可以实现迭代逻辑。如下为一个可用的斐波那契数列计算程序:

> ./main
您要计算第几个斐波那契数列 > 34
结果: 3524578
bash

递归#

由递归转迭代的知识,我们可以知道,用以下方式模拟递归从理论上是可行的:

__stack_chk_fail(state: in) -> __stack_chk_fail(inner_call) -> __stack_chk_fail(state: out)
plaintext

经过实现,如下:

> ./main
5
pwn!
1 + ... + 5 = 15
bash

反汇编行为#

以如下代码为例:

#include <stdio.h>
#include <unistd.h>
int main() {
    char stack[1];
    scanf("%s", stack);
    printf("%s", stack);
    return 0;
}

void __stack_chk_fail(void) {
    static const char msg[] = "Hello, World!\n";
    write(2, msg, sizeof(msg) - 1);
}
c

IDA 将自定义 __stack_chk_fail 识别为普通函数

可以看出,IDA 的反汇编视图将我们的 stack checker 视为正常库函数,不予显示。但函数列表识别出我们的 __stack_chk_fail 不是标准实现,所以没有紫红色背景高亮标注。这是该方案的一个缺陷。可以考虑放入大量函数进行隐藏。

Footnotes#

  1. 关于 FS 和 GS 段,可以参考Linux内核文档这篇博客

  2. 根据这篇博客的记录,32 位系统中取地址的内存区是 gs:14h,取出的是 4 字节数。

  3. 强符号和弱符号的特点取自这篇博客

摆弄一下 Stack Canary Checker
https://crisq.top/blog/stack_canary_checker
Author Cris.Q
Published at 2026年9月3日
Comment seems to stuck. Try to refresh?✨