

之前学习了一些关于程序栈和栈溢出/栈保护的知识,了解到 GCC 会默认给函数插入 Canary Checker 进行栈溢出防护。考查 Checker 的性质:
- 它是编译器自动插入的,源代码中不会显式调用。
- 它只在发生栈溢出时会被调用
- 反编译器(以 HexRay Decompiler 为例)只会检查函数签名是否与数据库匹配
于是,一个自然的想法就是:反编译器为了尽可能“还原”人手编写的 C 代码,应该不会事无巨细地显示所有“噪声”函数,而 AI 一般为了效率也倾向于忽略之。因此,如果我们覆写一个自定义的 Canary Checker,是否就能实现某种混淆,瞒天过海呢?
前置知识#
Stack Canary#
由于在栈帧内部栈向下增长,如果栈数据太大,可能会覆盖前一栈帧的内容。为了避免因此造成数据损失或程序异常,C Compiler 一般都会在函数末尾插入 Stack Canary Checker,如果不通过则直接崩溃退出程序。
一个典型的栈金丝雀工作原理是:从神奇的 fs:28h 地址1取一个 8 字节随机数2(A),存入栈底(B),等函数返回时进行比对(A=B?),若不同则进入__stack_chk_fail函数,由它进行后续处理。一个典型的反编译结果形如:
// decompiled by Ghidra
undefined8 dbg.main(void)
{
int64_t iVar1; // 栈上局部变量,存储 canary
undefined8 uVar2; // 返回值
int64_t in_FS_OFFSET;
iVar1 = *(int64_t *)(in_FS_OFFSET + 0x28); // 取随机数
// do something
uVar2 = 0; // 设定返回值为 0
if (iVar1 != *(int64_t *)(in_FS_OFFSET + 0x28)) { // 检查 canary 是否被覆盖
uVar2 = __stack_chk_fail(); // 调用 __stack_chk_fail() 函数
}
return uVar2; // 返回
}c因此,我们首先可以想到:覆写 __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!
12bash可以发现:
__stack_chk_fail被执行了- 栈溢出后
printf正常执行了(因为我们只是覆盖了canary)
使用gdb跟栈:

此时,__stack_chk_fail就是一个可以做正常函数操作的函数了。那么,是否可以让__stack_chk_fail隐式调用自己,实现尾递归?
一个简单的想法是,__stack_chk_fail本身就是一个函数,它的末尾能不能也被插入 Canary Checker 呢?综合上面知识,我们给__stack_chk_fail加入 attribute 就行了。
更复杂的控制流#
有了set_canary这一利器,我们可以自由控制何时发生溢出。
简单的循环逻辑#
int iter = 0;
__attribute__((stack_protect)) int main() {
char stack[1];
scanf("$s", stack);
return 0;
}
__attribute__((stack_protect)) void __stack_chk_fail(void) {
printf("iter: %d\n", iter);
iter++;
if (iter < 5) {
set_canary(get_canary() + 1);
}
}c> ./main
AAAAAAAA
iter: 0
iter: 1
iter: 2
iter: 3
iter: 4bash迭代#
我们用全局变量保存计算状态,就可以实现迭代逻辑。如下为一个可用的斐波那契数列计算程序:
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>
// 迭代轮数
int iter = 0;
// 最终结果
int result = 0;
// 迭代中间变量
int prev = 0;
int now = 1;
// 原始输入保存
char buffer[32];
// 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;
}
// main 函数
__attribute__((stack_protect)) int main() {
char stack[1];
printf("您要计算第几个斐波那契数列 > ");
scanf("%s", buffer);
// 输入要计算的斐波那契数项
sscanf(buffer, "%d", &iter);
// 构造可溢出输入
buffer[1] = buffer[1] == '\0' ? buffer[0] : buffer[1];
buffer[2] = '\0';
// 溢出!
sscanf(buffer, "%s", stack);
return 0;
}
// 栈溢出处理函数
__attribute__((stack_protect)) void __stack_chk_fail(void) {
switch (iter) {
case 1: {
result = prev;
break;
}
case 2: {
result = now;
break;
}
default: {
if (iter > 0) {
prev = prev + now;
now = now + prev;
break;
}
}
}
iter -= 2;
if (iter > 0) {
// 说明迭代还未结束,继续迭代
set_canary(get_canary() + 1);
} else {
printf("结果: %d\n", result);
}
}c> ./main
您要计算第几个斐波那契数列 > 34
结果: 3524578bash递归#
由递归转迭代的知识,我们可以知道,用以下方式模拟递归从理论上是可行的:
__stack_chk_fail(state: in) -> __stack_chk_fail(inner_call) -> __stack_chk_fail(state: out)plaintext经过实现,如下:
#include <stdint.h>
#include <stdio.h>
int data[1024];
int level = 0;
int result = 0;
enum State { incall, handle } hdl = incall;
static inline void bump_canary(void) {
uint64_t c;
__asm__ __volatile__("movq %%fs:0x28, %0\n\tincq %0\n\tmovq %0, %%fs:0x28"
: "=&r"(c));
}
__attribute__((stack_protect)) int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
scanf("%d", data);
char stack[1];
scanf("%s", stack); // 溢出
return 0;
}
__attribute__((stack_protect)) void __stack_chk_fail(void) {
if (level < 0) {
printf("1 + ... + %d = %d\n", data[0], result);
return;
}
if (hdl == incall) {
if (data[level] == 1)
hdl = handle;
else {
data[level + 1] = data[level] - 1;
level++;
}
} else {
result += data[level--];
}
bump_canary(); // 炸掉fs canary
}c> ./main
5
pwn!
1 + ... + 5 = 15bash反汇编行为#
以如下代码为例:
#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 checker 视为正常库函数,不予显示。但函数列表识别出我们的 __stack_chk_fail 不是标准实现,所以没有紫红色背景高亮标注。这是该方案的一个缺陷。可以考虑放入大量函数进行隐藏。