为什么翻这个

D:\...\Pangolin\repos\1-2023ctf 和 2-2023ctf2 是 2023 年给校内 CTF 出的两套逆向题,八道,原目录加起来 1.5 GB。这 1.5 GB 里绝大部分是 VS 的构建缓存(.vs/、ipch、pdb、Browse.VC.db),清掉之后只剩 48 个文件 8.6 MB,而其中源码本身只有 35 KB / 12 个文件。这次把缓存剔了,只留源码、当时真正发出去的 exe、两个分发包和第二套的官方题解,放进 r1-2-2023ctf-reverse。

重读的理由不是怀旧。平时写的是"怎么把一道题解出来",这次想看的是相反的方向:源码里留着一大堆在编译产物里根本看不见的东西——被注释掉的分支、故意反着写的常量、没接上的初始化、写死的三行答案。 这些东西在题目 exe 里全都消失了,但它们恰好是"这道题为什么难"的答案。

反过来也就是做题侧能用的东西:一个难度如果是被设计出来的,它会留下人为的痕迹,而痕迹是可认的。

下面按痕迹的种类过一遍。


一、难度是靠删自己的代码控制的

第二套的 Large&Small 是四道里分值第二高的(450)。它的加密就一层 XOR 加一层 base64,本身没什么。有意思的是源码里有一段被注释掉的代码,旁边还留了话:

	/*
	// 增加此段代码会使得题目过于简单...
	s1 = Base64Util::base64_decode(s);
	if (!s1.empty())
	{
		s = s1;
	}
	*/

这段的功能是:如果选手输入的内容本来就是一个 base64 串,程序会先替他解码。留着它,选手就可以直接拿题目给的密文 dFg3H2kEF0FQdmc7IRlvQSd2e0AhdWdVVwEQQD9wZDsjDGBOJnEXSipJAA== 喂回程序,一步拿到明文——题目会自己解自己。所以删掉了。

题解里对应的那句话是反问句:

可惜,题目不会主动识别base64加密过的密文(注:你猜为什么不会主动识别?)

同一段源码里还有一处被注释掉的调试输出,那是出题人自己算答案用的工具:

	/*
	for (int i = 0; i < 4; i++)
	{
		printf("0x%x ", *((char*)&key + i));
	}
	return 0;
	*/

做题侧的判据:如果一道题的程序"恰好"缺了一个本该顺手提供的便利分支——明明有解码函数却没被调用、明明有输入通道却只走一条——那大概率不是疏忽,是拿掉的。而它被拿掉的位置,通常就是题眼所在的方向。这个判据的价值在于它是反向的:你不需要知道出题人在想什么,你只需要知道代码的"不自然"本身就是信号,正常写的程序不会留一个定义了却没被引用的 Base64Util::base64_decode。


二、端序可以就是考点本身

还是这道 Large&Small。它的 key 是一个 int:

	int key = 0x78563412; // 0x12345678,非常有迷惑性~

加密循环按内存字节序取 key:

	flag.push_back((char)(s[i] ^ *(((char*)&key) + i % 4)));

所以实际生效的字节序列是 12 34 56 78,不是任何人第一眼会去试的 78 56 34 12。i % 4 保证了只可能循环用到这 4 个字节,0x78563412 写成 0x12345678 才是它本来的样子——字面量被反着写,让"读源码的人"和"读内存的程序"看到两个不同的 key。

配套的迷惑还有两处。程序运行时打印的那句提示:

	std::cout << "有时候,解题需要的不仅是技术,还有细心\n";

以及题解里我自己算完之后的复盘。当时写了个 Python 直接按 0x78,0x56,0x34,0x12 异或,跑出来是乱码:

lst = [0x78,0x56,0x34,0x12]
baseText = b"dFg3H2kEF0FQdmc7IRlvQSd2e0AhdWdVVwEQQD9wZDsjDGBOJnEXSipJAA=="
byteList = base64.b64decode(baseText)
for i in range(len(byteList)):
    print(chr(ord(byteList.decode()[i])^lst[3-(i%4)]),end="")

注意 lst[3-(i%4)] —— 这个把列表倒过来取的下标,就是端序那一步在 Python 里的形状。“列表反转之后 tX7 回来了"是当时认出题目的瞬间,题解里那句"熟悉的tX7回来了!这是为什么?“写的就是它。

做题侧的判据:按字节顺序取出的 key 算不出合理明文时,别急着怀疑还有第二层加密,先把 key 的 4 字节整体反转再试一次。这个动作的成本是一次改动,收益在 x86 逆向里高得不成比例——因为 C 源码里的十六进制字面量是"数值”,而内存里躺的是"字节序列”,这两者对大端直觉的人来说永远差一个反转。


三、源码 + 正常编译 ≠ 题目

第二套分值最高的一道(500)叫 bettX0R,它的源码第二行不是文件注释,是警告:

// 警告!此源码编译完成后需要手动加入call init_array!编译完成并不代表题目完成。

载荷是两层 XOR 套起来的,而中间那层的原文直接以注释形式留在文件里:

    // 原始
    // char secret[] = "flag{AA0C9286-4CC7-679C-1BB5-12944961DF88}\0\0";
    // 一次 (xor 129)
    //unsigned int secretInt[] = { 0xe6e0ede7,0xb1c0c0fa,... };
    // 二次 (xor 145)
unsigned int secretInt[] = { 0x77717c76,0x2051516b,... };

真正被编译进二进制的是"二次"那个数组。main 做的事是把输入逐字节 xor 129,再和内存里的数组比;而把数组从"二次"推回"一次"的那一步 xor 145 不在 main 里,在一个独立的函数里:

void __stdcall init_array() {
    if (isInit) return;
    isInit = true;
    for (int i = 0; i < 42; i++)
        *((char*)secretInt + i) = *((char*)secretInt + i) ^ 145;
}

这个函数没有任何地方调用它。 链接器不会自己调,所以正常编译出来的 exe 是"错的"——数组还停在二次 XOR 上,把 flag{AA0C9286-...} 投进去反而过不去。要出题,得手工在二进制里补一个 call init_array,这就是那句警告的意思。

main 开头还有一段被注释掉的循环,它把文件里这份数组就地 xor 145 再打印 11 个 int——也就是把上面那行"一次"复现出来。出题人核对链条用的工具留在注释里,选手是看不见的。

三层关系我逐字节验了一遍:"二次"数组 ^145 == "一次"数组,"一次"数组 == 明文 flag ^129,两条都成立。顺便撞到一个本来会看漏的东西——

flag{AA0C9286-4CC7-679C-1BB5-12944961DF88} 正好 42 个字符,而 init_array 和 main 里两个循环写的都是 i < 42。数组实际有 44 字节(11 个 int),最后那两个字节从头到尾没有任何人读。 所以注释里那句 \0\0 到底对应 00 00 还是 91 91,对判定毫无影响——我算出来的是 91 91,跟注释不符,但那是个死字节,不是 bug。

做题侧的判据:在一个二进制里看到一段没人调用的初始化/解密函数,先别当成死代码。它有两种可能:要么真是废的,要么它的调用是被手工接上去的(或者反过来,被手工摘掉的)。区分方法很便宜——看它写的东西后面有没有人读:init_array 改的是 secretInt,而 main 正好拿 secretInt 来比对,那这段"没人调"的代码就是判定的前提,它一定在某个时刻被执行过。

同一条"有没有人读"反过来也用来界定范围:判定实际覆盖的长度由循环上界决定,不由数组声明决定。 想知道哪些字节真的参与比较,去找那个 i < 42,别去数数组。

这条之所以值得单列,是因为它在 IDA 的 F5 输出里长得特别像垃圾:一个孤零零的、交叉引用为空的全局函数。


四、随机化把"唯一解"做成"多解"

第一套的 willYouSolveMe 是这八道里设计得最满的一道。题面对它的描述是:

willYouSolveMe-流程梳理题,需要输入正确的flag,程序会核验flag是否正确(tip:程序包含随机数,上一次成功的flag这一次不一定成功,flag有多个,均可通过。)

“随机数导致 flag 有多个"这句话在源码里是能精确还原的,所以我把它整个复刻了一遍。

状态机

程序的入口不是"读入-校验”,是两个互相调用的函数:

int main() {
    int a = getRandomInt(0, 3);
    char message[100] = "hello world";
    server(message, sizeof(message), a);
}

server 和 client 靠 message 字符串的内容分派,每次转移改 code:

  • server("hello world") → 置 "copy that",client(code + 3)
  • client("copy that") → 置 "code try",server(code / 2)(整除)
  • server("code try"):code 为奇数 → 置 "code retry",client(code + 3);为偶数 → 置 "code accept",client(code)
  • client("code retry") → 置 "code try",server(code + 2)
  • client("code accept") → verify(message, code, flag)

把四个起点逐个走一遍:

随机起点 a转移进 verify 的 code
0+3=3 → /2=1(奇)→ +3=4 → +2=6(偶)6
1+3=4 → /2=2(偶)2
2+3=5 → /2=2(偶)2
3+3=6 → /2=3(奇)→ +3=6 → +2=8(偶)8

校验

verify 里真正比对 flag 体的循环是这样:

    for (size_t i = 5; i < strlen(flag)-1; i++)
    {
        if (flag[i] != UUID[i + 5 - code]) { cout << "WRONG FLAG!" << endl; return; }
    }
    cout << "CONGRATULATIONS!!!" << endl;

UUID 是一个 36 字符的串抄了两遍(72 字符),所以取片下标可以在 0~71 里滑:

char UUID[] = "307490C8-041F-C1E7-ED80-70E8B7C12FD2307490C8-041F-C1E7-ED80-70E8B7C12FD2";

还有一个容易漏的步骤:code 不是上面表里的值。前向分支走完 flag{ 那五个字符加末位 } 的检查之后,它会改掉 message 再递归一次,把 code 减 1:

    strcpy(message, "Why do I have to write such terrible code?");
    verify(message, code - 1, flag);
    return;

递归那一层里两处 if (strcmp(message, "Why do I have to write such terrible code?")) 都因为相等而为假,于是直接落到取片循环——实际生效的偏移是 code - 1,也就是 5 / 1 / 1 / 7。

按这个算出来三条:

a=0  -> flag{0C8-041F-C1E7-ED80-70E8B7C12FD230749}
a=1/2-> flag{041F-C1E7-ED80-70E8B7C12FD2307490C8-}
a=3  -> flag{490C8-041F-C1E7-ED80-70E8B7C12FD2307}

和源码里注释掉的那三行答案对一下:

//flag{0C8-041F-C1E7-ED80-70E8B7C12FD230749}
//flag{041F-C1E7-ED80-70E8B7C12FD2307490C8-}
//flag{490C8-041F-C1E7-ED80-70E8B7C12FD2307}

逐字符相等,一条不多一条不少,而且和 a 的对应关系正好是 1→1、{1,2}→1、3→1。也就是说"4 个随机起点、3 个合法 flag"不是感觉,是能算出来的。

实跑

拿仓库里那个当年发出去的 willYouSolveMeX32.exe,每条 flag 重复投,看成功率是不是落在预测值上:

投的 flag预测实测
flag{041F-...490C8-}(a=1 或 2)50%34/60 = 56.7%
flag{0C8-...30749}(a=0)25%16/60 = 26.7%
flag{490C8-...307}(a=3)25%8/60 = 13.3%
乱写的一串0%0/60

第三条当场看着是不对的——13.3% 离 25% 差了两倍标准差。加大到 200 次重测:58/200 = 29.0%,回到预测值附近。所以那条是 n=60 的小样本噪声,不是模型错。

这个弯路本身也是一条判据,而且是给做题人自己的:当你算出的模型和实测差 2σ 的时候,先怀疑样本量,再怀疑模型。 我这里能这么判是因为模型是从源码逐行推出来的、并且和注释里的三个答案对上了;如果模型本身是猜的,那这个顺序就得反过来。

做题侧的判据:如果校验逻辑里出现"从一个大常量数组里按下标取片段",而下标里含一个运行时才定的量,那么这道题的合法答案就不止一个,而且答案空间的大小等于那个量的取值数。这时候正确的动作不是去猜"哪个才是真 flag",是把那个量的所有取值枚举出来,每个解一个——三个都交,总能中一个。题面那句"上一次成功的flag这一次不一定成功"说的就是这个。

顺手一处:哪些"可疑"是真不影响判定

这道题里有两个一眼看去像 bug 的地方,但它们对判定的影响完全不同,值得单独说。

一个是 FLAG 数组的顺序:

int FLAG[] = { 108,97,102,103,123,125 }; // lafg{}

108='l'、97='a'、102='f'、103='g'、123='{'、125='}'。按顺序读是 lafg{},注释也很诚实地写了 // lafg{}。但校验里用的全是下标,而且下标是打乱的:

if (FLAG[2] == flag[0])            // flag[0] = 'f'
  if (flag[2] == FLAG[1])          // flag[2] = 'a'
    if (flag[strlen(flag)-1] == FLAG[5])   // 末位 = '}'
      if (flag[1] == FLAG[0])      // flag[1] = 'l'
        if (flag[3] == FLAG[3])    // flag[3] = 'g'
          if (*(flag + 4) == *(FLAG + 4))  // flag[4] = '{'

逐条解下来正好是 flag{。数组顺序是噪音,下标映射才是规则。 谁要是照着 lafg 去理解这道题就绕远了。

另一个是赋值写成比较:

    if (flag[0] == FLAG[1]) {        // 输入以 'a' 开头
        if (flag[1] = FLAG[0]) {     // 这是赋值,把 flag[1] 改成 'l',条件恒真
            code++;
        }
        cout << happy << endl;       // "Congratulations with the wrong answer XDD!"
        return;
    }

它在第一个分支里,而这个分支的条件是"输入以 a 开头"——真正的 flag 以 f 开头,进不来。所以这个 = 只对走错路的输入生效,而且生效完就 return 了。它是给错答案准备的嘲讽,不是门。

还有一个更典型的:server 和 client 的第二个形参 length,从 main 开始一路 sizeof(message) 传下去,而 message 是数组形参、sizeof 在这里退化成指针大小——但 length 这个参数在两个函数体里一次都没被读过,所以它退化不退化,对题目零影响。

这三处合起来是同一件事:在逆向里看到"可疑",第一步不是分析它有什么后果,是先确认它到底能不能改变判定。 不能改变判定的可疑点,花再多时间读也是纯亏。判据同样朴素——看它写出去的值后面有没有人读。


五、提示可以藏在程序自己的输出里

第一套的签到题 ezre 一共 1,022 字节:

char str[] = "flag{F3242006-CAC0-AFA1-634B-2C2AC5F5D72E}";
char good[] = "Congratulations!";
int main()
{
    std::cout << "Hello World!\n";
    printf("%d,%d\n",str,good);
    std::cout << "GET YOUR FLAGGGGGG!";
}

printf("%d,%d\n", str, good) 把两个字符串的地址打了出来。题面 题目说明.txt 里对它的描述只有一句「ezre-字符串隐藏(逆向入门题,记得下好ida和x64dbg和peid)」,一个字没提这个输出。

意图不难猜:入门题如果连"找到数据在内存里的位置"这一步都要选手自己悟,卡点就太多了,所以程序直接把地址告诉你,把这道题压缩成"拿着地址去 dump"。

做题侧的判据:入门题的输出里出现"看起来没用的数字",先把它按地址解释一遍。反过来说,出题人给提示最常见的落点不是题面,是程序自己的 stdout、资源段和节名。


六、真 bug 可以当题面卖点写

第一套的 mysterious 是迷宫题。题面对它的 tip 是:

mysterious-迷宫题(tip:但是注意没有边界检查,内存溢出什么的我可不负责~)

源码里对应的是这样:

char flag[100];
int i = 1*5+0;
bool input() {
    ...
    case 1: i = i + 5; strcat(flag, "1");
            if (flag[strlen(flag) - 2] == '2') return false;   // 禁止回头
    ...
    if (*(((int*)map) + i) == 0) return false;
    if (i == 24) return false;
}

两个"没有检查"是真的:strcat 往 flag[100] 里每步追加一个字符,全程没有任何长度判断;i 两个方向都没有边界检查,((int*)map) + i 会直接读出 map 这个 int[5][5] 之外。

做题侧的判据:题面里那句"我可不负责"不是玩笑,是出题人在明说自己知道哪里没设防。这类自曝在 CTF 里可信度相当高,因为出题人写 tip 的时候心里想的是"我要让选手知道这题的边界在哪"。看到这种句子,值得花十分钟确认它到底能走多远——哪怕最后发现正常解法根本不经过它。

(这些全局量在编译产物里具体怎么排布、越界读会读到什么,源码是看不出来的,得 dump 出来对着内存算。这次没做。)


七、分发包里存着出题时间线

这一条跟逆向无关,是清仓库的时候顺出来的,但我觉得是可迁移性最高的一条。

两个分发包 2023ctf-re.7z 和 2023ctf2.zip 是当时真正发给选手的东西。把它们解出来跟工作目录对账:

  • 包里的 6 个 exe 与目录里散放的同名 exe 逐字节全等(md5 逐个核过)。所以散放的那份和包里的是一份东西,仓库里两边都留着,用哪个都行。

  • 2023ctf-re.7z 里只有 ezreX32.exe、mysteriousX32.exe、willYouSolveMeX32.exe 和 题目说明.txt——MissionImpossible 从来没进包。而 题目说明.txt 恰好也只写了三道题。两边互相印证。

  • 2023ctf2.zip 里那份 答案-flags.txt 是 176 字节的三行版,没有分值:

    whyUnopMe.exe:	flag{a9d661fd2e753a8c70198e4e868927b3}
    Large&Small.exe:	flag{0A9BB1C3-995B-83A1-E5F8-D2C18664EA28}
    move.exe:	flag{6C98D291-C2D7-BC54-BBB9-B8D39066A6EF}
    

    而工作目录里那份是 246 字节、四行、带分值:

    whyUnopMe.exe:	flag{a9d661fd2e753a8c70198e4e868927b3}	200
    move.exe:	flag{6C98D291-C2D7-BC54-BBB9-B8D39066A6EF}	300
    Large&Small.exe:	flag{0A9BB1C3-995B-83A1-E5F8-D2C18664EA28}	450
    bettX0R.exe	flag{AA0C9286-4CC7-679C-1BB5-12944961DF88}	500
    

    顺序也变了(按分值重排)。bettX0R.exe 的 mtime 是 2023-10-16,而 zip 是 10-14。

结论很直接:bettX0R 是在封包之后两天补进去的,分值表是和它一起定的。 这件事在任何一份单一副本里都读不出来,只有"包 vs 工作目录"这一对摆在一起才显形。

判据:分发包是快照,工作副本是现网。拿两者做 diff,读出来的不是"哪里不一致",是时间线——哪些东西是后补的、哪些决定是哪一天做的。这个做法不限于题目,任何带 .zip/.7z/dist/ 归档的目录都值得撞一次。


八、这次没解决的和不想硬编的

留几个诚实的缺口,免得读起来像一切都算清楚了。

MissionImpossible 没有答案。 它整个就是一句 MD5 定值比对:

    string md5s = md5.toString();
    if (md5s == "d0da297db9ca8e9d1a487069c98059eb")

明文 flag 在源码、注释、答案表里都没出现过,这次也不打算去撞它——撞中了就等于替这道题补一个它本来就故意留白的说明。

whyUnopMe 的源码丢了。 目录里只剩 .vs 的构建缓存,从 ipch 的文件名能反推出当初确实有 WHYUNOPME 和 FILEUTIL 两个编译单元,但 .cpp 不在。题解里对它的描述是一句「签到题,nop 掉提示函数即可过关」,所以这道题我只能发 exe,复现不了。

MD5Util.cpp/h 和 Base64Util.cpp/h 不是我写的。 这两对是当时从网上找的通用实现,文件里没有出处注释、没有版权头,我现在也没法指认来源,所以在仓库 README 里明确写了"不要当成我的代码看"。题目逻辑只在同目录那几个 *.cpp 里。

Reverse_wp.doc 的扩展名是骗人的。 它叫 .docx 但实际是 WPS 存的 OLE 复合文档(头 d0cf 11e0),Word 按 .docx 打开会直接报"文件格式与文件扩展名不匹配"。仓库里我把它改名成 .doc 了,原始文件名在 README 里留着。


收尾

把这八道题重读一遍,得到的不是"当年我出得挺好",是一个更实用的东西:难度几乎从来不是靠"加复杂代码"造出来的,而是靠删、靠挪、靠反着写、靠不接上。 删掉一个便利分支(Large&Small)、把 key 的字节序反过来写(同上)、把解密挪进没人调的函数、让出题人自己去补那声调用(bettX0R)、把校验偏移做成运行时才定的(willYouSolveMe)、把数组顺序写成误导(同上)。

做题的时候如果只能记住一条,我会记这条:代码里的"不自然"比代码里的"复杂"更值得追。 复杂可能只是业务逻辑,不自然基本一定是有人做的决定。

题目、源码、当年发出去的包和题解都在 r1-2-2023ctf-reverse,八道题的 *.cpp 全在里面,包括那些注释。