在周末和战队队友们一起运维了一次校赛,虽然大多数时候我只负责OB,不过最后还是小小的帮了点忙。在这期间,有一个pwn题的非预期做法引起了我的注意,并且在最终发现其本质上是一个docker引发的问题。这里记录一下整个探索过程
一次比赛出题引发的docker学习
起因
因为本次比赛面向的是学校内的学生,并且除了考察能力外,还要兼具宣传CTF和激发学生兴趣的目的,所以我和几个负责出题的战队学弟说,这次题目最好还是要兼具趣味性和技术性,少一点太技巧性的内容。其中在pwn这边负责出题的学弟就想到了一个点子:专门考察漏洞点,只要能触发漏洞点,就会将flag交出。

我当即就觉得想法很好,因为这几年CTF的变化给人感觉有点过于为了竞赛而竞赛。加上竞赛本身被赋予的各种含义,感觉有点不在专注于【探索新技术】这个点。
扯远了,这个题目在各种修改后,其代码大致如下(删掉了一些和本文无关的代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
| class PopMart { public: string name; PopMart(string name = "") : name(name) {} bool satisfied() { cout << "input a number:" << endl; int target = rand() % 100; int num; cin >> num; if (num == target) { cout << "You got a SSR!" << endl; return true; } else { cout << "You got a R!" << endl; return false; } } };
vector<PopMart*> buy_list;
vector<PopMart*>::iterator buyGoods(vector<PopMart*>& popmart_list) { cout << "Which one do you want to buy?" << endl; string name; cin >> name; cout << "we will try to new popmart" << endl; vector<PopMart*>::iterator find_SSR = vector<PopMart*>::iterator(); for (auto it = popmart_list.begin(); it != popmart_list.end(); ) { PopMart* popmart = *it; if (popmart->name == name) { cout << "create popmart" << endl; PopMart* newpopmart = new PopMart(bandMembers[name][rand() % 5]); cout << "create finish" << endl; buy_list.push_back(newpopmart); if (popmart->satisfied()) { find_SSR = std::prev(buy_list.end()); return find_SSR; } } ++it; } return find_SSR; }
void init() { cout << "Welcome to PopMart Shop!" << endl; cout << "try your best to get the bang SSR you like" << endl; buy_list.reserve(32); }
int main() { try { int id; init(); cout << "plz input your user account:" << endl; cin >> id; srand(id); vector<PopMart*> popmart_list; vector<PopMart*>::iterator wanted = vector<PopMart*>::iterator(); cout << "We have 9 goods for you:" << endl;
for (int i = 0; i < 9; i++) { PopMart* popmart = new PopMart(bandNames[i]); popmart_list.push_back(popmart); } vector<PopMart*>::iterator find_SSR = vector<PopMart*>::iterator(); while (true) { banner(); int choice; cin >> choice; switch (choice) { case 1: { find_SSR = buyGoods(popmart_list); break; } case 2: { cout << "Purchased goods:" << endl; for (auto it = buy_list.begin(); it != buy_list.end(); it++) { cout << (*it)->name << endl; } break; } case 3: { if (chance) { wanted = find_SSR; chance--; } else { cout << "You have no chance!" << endl; } break; } case 4: { if ((wanted) == vector<PopMart*>::iterator()) continue; cout << "plz input your new name:" << endl; string name; cin >> name; (*wanted)->name = name; break; } case 5: { cout << "Exiting..." << endl; cout << "Haha! U can't exit!" << endl; break; } default: { cout << "Invalid choice!" << endl; break; } } } } catch (const exception& e) { cout << "Error: " << e.what() << endl; return 1; } }
|
上述代码中潜藏着一个C++迭代器错误使用
的例子。为了能够让学生们能够更加轻易的识别漏洞,我们将题目调整成一种虽然看起来是正常使用,但是其实也有一点刻意的形式,希望选手在读题的时候察觉到这种可以异常,从而思考漏洞的成因。
漏洞成因
这里简单介绍一下漏洞成因和设计思路。题目中给出的是一个邦多力抽卡商店,其中购买的对象会用全局对象 buy_list
存储
1
| vector<PopMart*> buy_list;
|
每次我们购买对应的popmart的时候,我们都会尝试进行一次ssr判定,这个过程其实是要求选手去进行猜测的,本质上是一个随机数预测。当猜中后,程序会将当前buy_list的迭代器返回给选手,否则返回一个无效迭代器
此处就是第一个让人觉得刻意的地方。因为正常情况下思考,此时应当返回对象指针,而并非迭代器。此处异常举动是希望引发选手注意到该题可能和迭代器错误有关
之后除了普通的展示逻辑外,还有常见pwn题的修改功能,在本题中的含义为能对抽到的SSR的名字进行自定义。不过题目此时修改的功能依赖一个全局wanted 迭代器对象,而非指针:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| case 3: { if (chance) { wanted = find_SSR; chance--; } else { cout << "You have no chance!" << endl; } break; } case 4: { if ((wanted) == vector<PopMart*>::iterator()) continue; cout << "plz input your new name:" << endl; string name; cin >> name; (*wanted)->name = name; break; }
|
此处是第二个刻意的地方,强调此时修改的是一个迭代器对象,而非指针,说明漏洞点本质上发生在vector而非Popmart对象中
如果结合上述关于vector和迭代器两个元素,查找漏洞相关的资料(抑或是自己思考直接速通),应该是能够找到相关资料的,关于 C++ 迭代器失效特性的研究以及编程千问》第十六问:迭代器失效你了解吗?,均讨论了这种vector对象迭代器失效的研究,这里简单概括一下题目中的情况:
假定我们的 vector 中有7个元素:
1 2 3 4
| +-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | | +-----+-----+-----+-----+-----+-----+-----+-----+
|
此时,我们需要插入一个元素 7。那么在插入元素的时候,实际上vector会发生一次【重分配】,可以简单的理解为进行了realloc
的调用, 会分配原来两倍的大小,并且将原先的内存释放掉
1 2 3 4 5 6 7 8 9 10 11 12 13
| // 已经被释放 +-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | | +-----+-----+-----+-----+-----+-----+-----+-----+
// 重新申请的内存,保证能放得下元素7 +-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +-----+-----+-----+-----+-----+-----+-----+-----+ | | | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+
|
然而,假设我们在释放前,存在一个指针(也就是迭代器)指向了原先的数组,例如
1 2 3 4 5 6 7
| +---- iter | v +-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | | +-----+-----+-----+-----+-----+-----+-----+-----+
|
那么当我们插入元素的时候,iter并不会发生改变,那么此时内存布局就变为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| // 依旧指向原先的元素 +---- iter | v +-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | | +-----+-----+-----+-----+-----+-----+-----+-----+
// 重新申请的内存,保证能放得下元素7 +-----+-----+-----+-----+-----+-----+-----+-----+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +-----+-----+-----+-----+-----+-----+-----+-----+ | | | | | | | | | +-----+-----+-----+-----+-----+-----+-----+-----+
|
那此时如果对iter指向的对象进行操作,就会发生UAF,导致漏洞出现
题目希望做题人利用购买来触发vector的内存重分配,并且利用SSR机制来让迭代器遗留在重新申请内存前的位置,从而构造一个UAF场景。最终利用题目中提供的修改机制,访问迭代器对象,最终实现UAF,触发ASAN。
意料之外 - vector导致的崩溃?
为了能够检测asan,出题人准备了如下的检测逻辑,称为runner.cpp
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| int main() { cout <<"Welcome to Runner!\n"; pid_t pid = fork(); if (pid < 0) { cerr << ANSI_COLOR_RED << "Fork error." << ANSI_COLOR_RESET << endl; return 1; } close(fd);
if (pid == 0) { execl("./attachment", "attachment", (char*)NULL); perror("exec error"); exit(1); } int status = 0; waitpid(pid, &status, 0);
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { cout << ANSI_COLOR_GREEN "./attachment exited normally." ANSI_COLOR_RESET << endl; } else { ifstream flagFile("flag"); if (!flagFile) { cerr << ANSI_COLOR_RED << "Cannot open flag.txt"<< ANSI_COLOR_RESET << endl; return 1; } stringstream buffer; buffer << flagFile.rdbuf(); cout << "now status is " << WEXITSTATUS(status) << endl; cout << ANSI_COLOR_RED << "You escaped the shop!" << ANSI_COLOR_RESET << endl; cout << ANSI_COLOR_YELLOW << "ASan detected an error! Here is your flag:" << ANSI_COLOR_RESET << endl; cout << ANSI_COLOR_GREEN << buffer.str() << ANSI_COLOR_RESET; } return 0; }
|
其中attachment
就是题目。题目本身给了源码,可以发现出题人把退出逻辑去掉了,就是希望做题人能够通过触发asan来实现程序退出,就能通过此办法获取flag。
其实仔细看,这个判题逻辑中并未直接判断是否存在asan出错信息,为之后的问题留下伏笔
然而在比赛期间,发生了一些意料之外的事情:

这个题目我也参与了验题,理论上大部分的问题应该排除,但是这里上传大量的数据会导致程序挂掉这点我是没想到的。在vector底层实现里面,有类似如下的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| __int64 __fastcall std::vector<int>::_S_max_size(__int64 a1) { __int64 v2; __int64 v3[2];
v3[1] = __readfsqword(0x28u); v2 = 0x1FFFFFFFFFFFFFFFLL; v3[0] = std::allocator_traits<std::allocator<int>>::max_size(a1); return *(_QWORD *)std::min<unsigned long>(&v2, v3); }
__int64 __fastcall std::vector<int>::_M_check_len(__int64 a1, unsigned __int64 a2, const char *a3) {
v10 = a1; v9 = a2; v13 = __readfsqword(0x28u); v3 = std::vector<int>::max_size(a1); if ( v3 - std::vector<int>::size(a1) < a2 ) std::__throw_length_error(a3); v4 = std::vector<int>::size(v10); v11 = std::vector<int>::size(v10); v12 = v4 + *(_QWORD *)std::max<unsigned long>(&v11, &v9); v5 = std::vector<int>::size(v10); if ( v12 >= v5 && (v6 = std::vector<int>::max_size(v10), v12 <= v6) ) return v12; else return std::vector<int>::max_size(v10); }
|
可以看到,当我们企图构建巨大的vector的时候,会因为超出数据逻辑而导致崩溃:
1 2
| if ( v3 - std::vector<int>::size(a1) < a2 ) std::__throw_length_error(a3);
|
理论上来说,确实是一个可能的因素。然而我注意到,队友提到的是上传文件导致的崩溃,这个题目并非是上传的题,也就是这里存在表述错误,那么如何理解上传呢?多次沟通后得知,这里其实使用了
的方式将一个16g的巨大文件写入了网络流。但是这样就引发了我的一个好奇的点:这种pwn题为了进入这个逻辑,往往是需要多次交互的,难道这个做题人故意生成了一个巨大的文件,里面包含了多次交互逻辑,从而将这个程序撑爆了吗?如果它有这种想法的话,何不直接用脚本写一个循环,类似
1 2
| for i in range(1000000): buy_obj('a')
|
这样的逻辑简单得多?所以我这里猜测,可能做题人并非是有意撑坏buy_list
这个vector,而是企图撑坏别的对象,那个对象可能会让人联想到文件,所以才会采用文件的方式进行通信。
进一步确认 - bad alloc?
怀着这个疑问,在比赛结束后我也找做题的同学沟通了一下这个事情。他提到说看到原题目中的try catch
就联想到了可以使用std::string
中可能发生的bad alloc
来触发漏洞。这一点倒是符合我的猜想,毕竟这个题目中,在购买逻辑里就有输入名字的要求,也许就是利用了这一点吗?
于是我尝试自己复现,为了可能复现内存耗尽的情况,这里使用了参数约束了容器的内存大小:
1
| sudo docker run -p 9999:9999 -m 7M --privileged --security-opt seccomp=unconfined -it pwn /bin/bash
|
然而在实际测试中,却发生了奇怪的事情:我发现程序在崩溃的时候,并没有发生期待的asan
报错,而是直接打印了flag:

这里让我感觉非常疑惑。根据我们原来的猜测,程序通过bad alloc
,然后抛出异常。然而这个过程中理论上需要打印出错信息,这个过程中没有任何错误信息就很奇怪。为什么不能打印错误呢?我产生了以下几种猜想
- 程序本身因为特殊原因,导致无法打印错误流
- 程序本身就没有产生错误信息
其中,第一种原因可以细化成几种原因
- 程序的输入输出流因为读入过量数据损坏了,也许这就是程序崩溃的原因
- 程序有错误流输出,但是因为错误流被劫持了,导致输出看不到
为了一个个定位原因,首先修改了runner.cpp
的逻辑,加上了错误流打印:
1 2 3 4 5 6 7 8 9 10 11 12
| int fd = open("/tmp/log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644); if (fd == -1) { perror("open log file failed"); return 1; }
if (dup2(fd, STDERR_FILENO) == -1) { perror("dup2 failed"); close(fd); return 1; }
|
然而实际运行至触发的时候,虽然会产生该日志文件,但是文件内容却是空的。这就非常奇怪了,无论哪种报错,程序至少应该要楼下错误信息,这样怎么定位呢。
docker 与 OOM
之后尝试过多种方法,包括但不限于
- 怀疑是
runner.cpp
将输入流劫持,所以直接使用attachment直接与输入输出流对接。然而这样做会直接导致asan被触发,无法定位错误
- 怀疑xinted会导致asan输出流被劫持,所以替换asan为非asan版本,但是也没能看到其他的输出
- 拖入gdb进行调试,然而gdb会直接报错提示
Killed
其中gdb这个错误让我感到异或,我一度以为是gdb版本问题导致的错误,不过这也成了后面一个操作的提示
为了能够完全复现问题,这边试着模仿最初的做题人的思路,使用文件作为输入。这里构建了一个能够完成简单交互的例子,并且之后写入大量数据的文件:
1 2 3 4
| with open("test.bin", 'wb') as fd: fd.write(b"1\n") fd.write(b"1\n") fd.write(b"A"*0x1000000 + "\n")
|
之后再首先的doker环境中,直接使用
的方式尝试复现问题,发现其同样报错提示
这个奇怪的现象和gdb非常相似,联想到我们的docker内存大小仅有7M,不禁联想到:这是否是因为内存空间不足导致程序被强制干掉了?使用echo $?
检查错误码后会发现,程序错误码为137,也就是收到了SIGKILL
信号。咨询gpt后可知,当linux下程序占用过多内存的时候,就会触发OOM Killer
,强制终止进程,保证系统正常工作。
这个原因听起来非常合理,因为实际上我们的检测逻辑并非检测是否存在asan报错,而是检查程序的退出码,确认其是否为正常退出,这就意味着,除了程序自身的错误导致的退出外,来自外部信号导致程序退出也会被视为是触发了漏洞,这一点正好对应了无论怎么检查程序都没有找到出错日志这个原因上。
进一步咨询gpt,可以知道为了检查程序是否会因为OOM发生被kill的情况,可以使用指令
1
| dmesg | grep -i "killed process"
|
检查日志,结果确实找到了如下的内容:
1
| [13157.135036] runner invoked oom-killer: gfp_mask=0x1100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0 [13157.138313] oom_kill_process.cold+0xb/0x10 [13157.146478] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name [13157.148489] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=b8d0b3d7f8a1c3cd8415c3dc27474181ab6a69d4de70d569eeac11cf99f90dc8,mems_allowed=0,oom_memcg=/docker/b8d0b3d7f8a1c3cd8415c3dc27474181ab6a69d4de70d569eeac11cf99f90dc8,task_memcg=/docker/b8d0b3d7f8a1c3cd8415c3dc27474181ab6a69d4de70d569eeac11cf99f90dc8,task=runner,pid=10972,uid=0 [13157.150468] Memory cgroup out of memory: Killed process 10972 (runner) total-vm:21474910272kB, anon-rss:4476kB, file-rss:1480kB, shmem-rss:0kB, UID:0 pgtables:896kB oom_score_adj:0
|
可以发现,确实是docker动的手,强制将目标程序给干掉了。
小科普:OOM Killer 和 cgroup
日志中出现了几个比较特殊的词语,我们在这里简单的介绍一下这些词的含义:
参考这篇文章可知,Linux运行的时候,使用malloc
这类函数分配内存的时候,允许分配超出实际可用的物理内存的大小(over-commit memory
),因为malloc本质上分配的是虚拟内存地址,只有当真的需要写入数据的时候,Linux才会真的去获取物理内存。这种【预支内存】的方式如果真的导致了进程空间不足,就会强制kill一个正在运行中的程序,从而保证物理内存在足够使用的范围内。这个机制就被称为 OOM Killer
而Cgroup是一种Linux内核提供的机制,用来对单个或者多个程序的内存,cpu等资源进行管理。而docker这种容器正是使用了这个机制对容器进行的管理。当我们docker指定的内存空间(在我们题目中是7M)被耗尽的时候,docker就会尝试触发OOM Killer机制,将cgroup中(也就是当前容器中)的一些进程kill掉,从而保证内存消耗不会超出我们的指定需求
总结
绕了一大圈定位出问题,发现似乎并非程序本身的问题,而是因为docker的防护机制导致的程序退出。不过此次探索这也揭露了一些有趣的事情,比如为什么很多出题人都会限制可读入数据的大小(使用cin.get(size)
而不是直接让程序读入数据),亦或者在检测asan出错的时候,会严格限制输出内容需要包含asan的字符串,而不是直接检查程序的错误码。这些设计可能更多的是为了保证整体程序运行能够符合程序逻辑,从而减少非预期的出现。