第一次出题,虽然还好没有非预期,但是与预想的结果并不完全符合,并不完美。果然出好题比做题难。

# 古老的语言

附件 (右键另存为)

Difficulty: Normal
听说这个程序兼容从 Windows 95 到 Windows 11……
盲猜你身边就有人用过这个编程语言 🤔
Note: 附件不含恶意代码,可放心下载运行。

💡 反编译器😭我还能完全相信你吗😭你这个 you 嘴 hua 舌的家伙😭
💡 “Procedure analyzer and optimizer” 是什么功能?关掉会怎么样呢?

8 支队伍攻克,597 pts

# 判断语言和找工具

一个有图标的 72KB 的 exe。

判断语言、框架、构建工具等,可以使用 DIE(Detect It Easy)。判断是 Visual Basic 6.0 程序。

DIE

如果用 IDA 打开,看到的几乎全是 “数据”,导入表有几个来自 MSVBVM60 库的函数,搜一下也可以知道是 VB 程序。

import view

也有选手询问大语言模型,也是可以的:

ask gpt

Python 有 uncompyle6 和 pycdc,.NET 平台有 dnspy 和 dotPeek,Java 有 JADX 和 JEB。结合题目名称 “古老的语言”,可以尝试寻找专门逆向 VB 的软件。上网搜索 “VB 逆向”,可以找到 VB Decompiler 软件。

img submit click

右侧可以看到函数名。(出题人:原本第一版函数是 Private 的,编译没有保留函数名,出题人自己看到都不想逆,所以故意把函数设为 Public,就有函数名了。)

上面可以看到是 P-Code。(VB 可以编译为 P-Code(伪指令)或 Native Code(本机指令),两种都要用系统的 msvbvm60.dll,P-Code 类似于一些逆向题中的 VM。这里编译为伪指令也只是为了反编译好看一点。)

# 逻辑分析

先看 img_submit_Click 过程,容易找到用 Me.txt_input.Text 判断长度的代码(这时就能知道 VB 中 <> 是不等于的意思;一开始可能以为 &H30 是 30,运行程序测试一下会发现长度应该是 48,所以 &H30 是 0x30 的意思),以及把 var_A4 传入 Fxxxtel 函数判断 flag 是否正确。那么中间几行应该就是把输入的 flag 从 Me.txt_input.Text 移到 var_A4 的逻辑了,实际上 var_A4 是由 12 个 Long 组成的数组(VB 的 Long 是 32 位有符号数),并且出题人写的逻辑是大端序。其实中间这个过程也可以先不管,先解出 Fxxxtel,看看会得到什么就行。

看看 Fxxxtel 函数(其实是 Feistel 分组密码结构的意思啦(虽然题目魔改了),写成这样只是抖个机灵,不需要能反应过来,直接逆就完事了):

fxxxtel

下面的连续 if 明显是与密文比对,上面的两层 for 就是加密过程了,很简短吧。加密过程和 TEA 很相似,不同的是每个分组分为 3 部分而不是 2 部分、异或改成加法、加法改成异或。

loc_40F089 有一个很迷惑的 var_B0 = AddLong(0, -1640531527) ,正常人不会这样写代码。这里实际上是 var_B0 = AddLong(var_B0, -1640531527) ,只是 var_B0 初始化为 0,被反编译器错误地优化了。这并不是出题时故意的,只是碰巧,恰好也体现出反编译器并不总是可靠的。如果关闭优化,就正确了:

optimizer

外层循环是把 flag 切片赋值给 var_B4、var_B8、var_BC,注意 VB 中 For 循环是闭区间,这里 For var_AC = 0 To 9 Step 3 ,var_AC 等于 9 时是满足条件的。内层循环是 var_B4、var_B8、var_BC 互相使用并赋新值,重复 0x20 轮。加法和移位使用函数实现,看着不直观,改写成下面的伪代码(用 l、m、r 代替 var_B4、var_B8、var_BC):

l = l ^ (((m << 4) ^ -559038737) + (m ^ var_B0) + ((m >> 5) ^ -1161901314))
m = m ^ (((r << 4) ^ -559038737) + (r ^ var_B0) + ((r >> 5) ^ -1161901314))
r = r ^ (((l << 4) ^ -559038737) + (l ^ var_B0) + ((l >> 5) ^ -1161901314))

# 写解密算法

TEA 是 CTF 逆向中很经典、很常见的加密算法。乍一看 l、m、r 总是变来变去的,但是看最后一轮的最后一步操作,这一步前后只改变了 r,l 是没有变的,所以 (((l << 4) ^ -559038737) + (l ^ var_B0) + ((l >> 5) ^ -1161901314)) 这一串是可知的。把最后的 r 异或这一串,就能得到最后一步之前的 r,同理也能得到最后一轮之前的 l、m、r,也就能得到开始的 l、m、r。写解密程序:

#include <stdio.h>
#define u32 unsigned int
u32 enc[] = {
    0x7D11E3C2, 0x5C6DB083, 0x6A7C56D9, 0x7DFBA9E5, 0x6DA04F4B, 0x18B18EE3,
    0x2624549B, 0x6C98C6D8, 0x75A2E883, -131717161, 0x6E303277, -258141690
};
int main()
{
    for (int i = 0; i < 12; i += 3)
    {
        u32 sum = 0;
        for (int j = 0; j < 32; j++)
            sum += -1640531527;
        u32 l = enc[i], m = enc[i + 1], r = enc[i + 2];
        for (int j = 0; j < 32; j++)
        {
            r ^= ((l << 4) ^ -559038737) + (l ^ sum) + ((l >> 5) ^ -1161901314);
            m ^= ((r << 4) ^ -559038737) + (r ^ sum) + ((r >> 5) ^ -1161901314);
            l ^= ((m << 4) ^ -559038737) + (m ^ sum) + ((m >> 5) ^ -1161901314);
            sum -= -1640531527;
        }
        enc[i] = l, enc[i + 1] = m, enc[i + 2] = r;
    }
    printf("%s\n", enc);
    // et4WFTCrmi7{0T_eeSu_DOM__nR3gNAle6au1l_sr_3k}tSU
    char* flag = (char*)enc;
    for (int i = 0; i < 48; i += 4)
    {
        printf("%c%c%c%c", flag[i+3], flag[i+2], flag[i+1], flag[i]);
    }
    // W4terCTF{7ime_T0_uSe_MOD3Rn_lANgua6es_l1k3_rUSt}
    return 0;
}

注意要用 unsigned int,注意 sum 的初始值。最后发现字节序不对,转一下就好。

也有选手将反编译代码复制出来,作为 VBScript 代码,工作量会比用 C 或 Python 重写小很多。

as-vbs

check pass

# 出题人的话

之所以出一道 VB 的题目,最开始是因为中山大学互联网与开源技术协会全员大会上,有几个人都说以前用 VB 写过程序,恰好出题人高中时也用 VB 写过程序。虽然出题人从未在比赛中见过 VB 的题目(可能只是因为见得少),但是 VB 反编译也不是特别难看,放在新手赛挺合适,所以就出了这道题。

出题时才发现 VB 整数溢出会报异常,而不是舍弃溢出位,而且 VB 也没有逻辑移位运算符,所以参考 https://www.syue.com/Software/NET/ASPNET/5425.html 用函数实现。参考链接中逻辑右移是有缺陷的,在题目中做了修改。由于考虑不周,我直接用了参考链接中的 Rotate(循环移位)这个函数名,但实际上做的是 Shift(移位),对选手造成了误导,非常抱歉。因为不想选手花时间检查这些算术运算的逻辑,所以专门定义了字符串,用来说明函数没有魔改。但还是有不少选手在逆这几个函数,也许我应该写 “此函数在实现 C 语言中的逻辑移位,做题时不须关注” 的,非常抱歉。

决定出这道题的时候,考点是 “对特定语言或框架的处理” 和 “TEA 类似算法的解密”。至于 VB Decompiler 工具的反编译结果不准确,这是出题出到一半才发现的。于是就想着通过这道题,顺便让选手知道 “反编译并不总是可靠的”,毕竟现实中的软件也不会刻意规避这种情况。出题人认为应该有选手注意到 var_B0 = AddLong(0, -1640531527) 很奇怪,然后来私聊询问的。但事实是,错误的反编译结果、比较复杂的 TEA 类似算法、不提供动态调试的工具这三点结合在一起,导致实际的难度比原来出题人想要的难度高了不少。

正如 flag 所说,是时候用更现代的编程语言了。这应该是 LilRan 最后一次用 VB 了,下次出题时,LilRan 的题目将开始锈化。

彩蛋:UI、加密函数名称、应用程序标题、输入 “激活码”。

# BruteforceMe

附件 (右键另存为)

Difficulty: Easy
和你爆了 😡

20 支队伍攻克,217 pts

基础题,出题目的是让选手熟悉 IDA 的使用,并尝试除了写反向算法外的其他解题方法(是因为这个原因,才进行了很多处的算法魔改)。本题灵感来源是 CISCN 2023 华南赛区分区赛题目 “签个到”,在此基础上修改了算法、修改了 “flag 错误” 的输出信息。(所以做出这道题的选手,可以算是做出国赛分区赛的题目了。)

这是一个 64 位 ELF 文件,是在 64 位 Linux 系统上运行的。去除了符号表,但是在程序分析上没有为难选手,main 函数、flag 长度、加密过程、密文位置都很好找。

# 逻辑分析

第一步

main 函数接收用户输入的 flag,sub_1209 函数把 flag 输入原地逐字节异或 0x87 再乘 17,把输入从 ASCII 32-127 分散到 1-255。然后在 main 函数中判断 flag 长度等于 43。

因为没有任何一个可打印字符异或 0x87 再乘 17 会变成 0,所以明文 flag 长度就等于 43。注意这个步骤中,flag 的每个字节互不影响。

char *__fastcall sub_1209(char *a1)
{
    int i; // [rsp+1Ch] [rbp-14h]
    for (i = 0; i < strlen(a1); ++i)
    {
        a1[i] ^= 0x87u;
        a1[i] *= 17;
    }
    return a1;
}

对于写反向算法的解法 3:

有选手认为乘 17 这个步骤是不可逆的,其实不是。

y = (x * 17) % 256
y * 241 % 256 = (x * 17 * 241) % 256
y * 241 % 256 = (x % 256) * (17 * 241 % 256) % 256
y * 241 % 256 = x * 1 % 256
y * 241 % 256 = x

这里的 241 就是 17 的模 256 逆元,可以通过扩展欧几里得原理求得,或者 Python 中直接 pow (17, -1, 256)。

有选手用了一种巧妙的做法,把 17 看成 0b00010001,x 乘 17 就相当于 (x<<4)+x,这样就可以反向计算了。虽然出题时并没有想到这一点,17 这个数是随便选的。

当然也可以稍微爆破一下,看 0-255 之间哪个 x 乘 17 会得到对应的 y。

第二步

sub_1290 传入上一步产物和长度 43,在新的内存区域进行 base64 操作,可以明显看到 base64 的表。在 IDA 中把 a1 类型设为 unsigned char*(对 a1 按右键,选择 Set lvar type... 输入 unsigned char*a1)会好看一点。

注意这个步骤中,原文的连续 3 个字节编码后变成 4 个字节,原文合适位置的连续 3 个字节与另外 3 个字节互不影响,编码后合适位置的连续 4 个字节与另外 4 个字节互不影响。

_BYTE *__fastcall sub_1290(unsigned __int8 *a1, int a2)
{
    int v4;    // [rsp+14h] [rbp-1Ch]
    int i;     // [rsp+18h] [rbp-18h]
    _BYTE *v6; // [rsp+28h] [rbp-8h]
    v6 = malloc(4 * ((a2 + 2) / 3) + 1);
    if (!v6)
        return 0LL;
    v4 = 0;
    for (i = 0; a2 % 3 ? v4 < a2 - 3 : v4 < a2; i += 4)
    {
        v6[i + 3] = ~aAbcdefghijklmn[a1[v4] >> 2];
        v6[i + 2] = ~aAbcdefghijklmn[(16 * a1[v4]) & 0x30 | (a1[v4 + 1] >> 4)];
        v6[i + 1] = ~aAbcdefghijklmn[(4 * a1[v4 + 1]) & 0x3C | (a1[v4 + 2] >> 6)];
        v6[i] = ~aAbcdefghijklmn[a1[v4 + 2] & 0x3F];
        v4 += 3;
    }
    if (a2 % 3 == 2)
    {
        v6[i + 3] = ~aAbcdefghijklmn[a1[v4] >> 2];
        v6[i + 2] = ~aAbcdefghijklmn[(16 * a1[v4]) & 0x30 | (a1[v4 + 1] >> 4)];
        v6[i + 1] = ~aAbcdefghijklmn[(4 * a1[v4 + 1]) & 0x3C];
        v6[i] = 126;
    }
    else if (a2 % 3 == 1)
    {
        v6[i + 3] = ~aAbcdefghijklmn[a1[v4] >> 2];
        v6[i + 2] = ~aAbcdefghijklmn[(16 * a1[v4]) & 0x30];
        v6[i + 1] = '~';
        v6[i] = 126;
    }
    v6[4 * ((a2 + 2) / 3)] = 0;
    return v6;
}

对于写反向算法的解法 3:

这里进行了魔改,四个字符顺序颠倒,且每个字符按位取反,然后用~而不是 = 来补齐 4 的倍数个字符。比如正常情况下是 Abc= ,这里就变成了 ~ ~c ~b ~A

第三步

sub_1650 传入上一步产物、长度 60、密钥 "W4terCTF {ZaYu}",在新的内存区域进行 RC4 操作。RC4 是一种流密码,第一个循环用密钥来初始化 S 盒,第二个循环用 S 盒来加密原文。这里进行了魔改,最后一步异或时还异或了 (length-i),即便这样也不改变 RC4 本身就是自己的反函数的性质,即 RC4(RC4(m))==m 。注意这个步骤中,每个字节互不影响。

_BYTE *__fastcall sub_1650(__int64 a1, int a2, const char *a3)
{
    int v3;               // r12d
    __int64 v4;           // kr00_8
    __int64 v5;           // kr08_8
    char v8;              // [rsp+2Bh] [rbp-135h]
    char v9;              // [rsp+2Bh] [rbp-135h]
    int i;                // [rsp+2Ch] [rbp-134h]
    int j;                // [rsp+2Ch] [rbp-134h]
    int k;                // [rsp+2Ch] [rbp-134h]
    int v13;              // [rsp+30h] [rbp-130h]
    int v14;              // [rsp+30h] [rbp-130h]
    int v15;              // [rsp+34h] [rbp-12Ch]
    _BYTE *v16;           // [rsp+38h] [rbp-128h]
    char v17[264];        // [rsp+40h] [rbp-120h]
    unsigned __int64 v18; // [rsp+148h] [rbp-18h]
    v13 = 0;
    v15 = 0;
    v16 = malloc(a2 + 1);
    for (i = 0; i <= 255; ++i)
        v17[i] = i;
    for (j = 0; j <= 255; ++j)
    {
        v3 = (unsigned __int8)v17[j] + v13;
        v4 = v3 + a3[j % strlen(a3)];
        v13 = (unsigned __int8)(HIBYTE(v4) + v4) - HIBYTE(HIDWORD(v4));
        // 看到有选手对上面一行反编译代码有疑问,可以查看 defs.h 中的宏定义,HIBYTE 指的是最高有效字节(如 0x1122334455667788 的 0x11)。v4 是寄存器里的 64 位整数,但它只是两个 0~255 的数加起来而已,根本不能使 64 位的最高字节不为 0,所以 HIBYTE (v4) 和 HIBYTE (HIDWORD (v4)) 都应该是 0,所以 v13 = v4 % 256。反编译器通常不会做这种推理,而是忠实于原始汇编指令。
        v8 = v17[j];
        v17[j] = v17[v13];
        v17[v13] = v8;
    }
    v14 = 0;
    for (k = 0; k < a2; ++k)
    {
        v15 = (v15 + 1) % 256;
        v5 = (unsigned __int8)v17[v15] + v14;
        v14 = (unsigned __int8)(HIBYTE(v5) + v17[v15] + v14) - HIBYTE(HIDWORD(v5));
        v9 = v17[v15];
        v17[v15] = v17[v14];
        v17[v14] = v9;
        v16[k] = (a2 - k) ^ v17[(unsigned __int8)(v17[v15] + v17[v14])] ^ *(_BYTE *)(k + a1);
    }
    v16[k] = 0;
    return v16;
}

最后将结果与 byte_4020 逐字节对比,输出一致的字节数。

第二步和第三步的魔改可能不太容易注意到,但是应该要看出来用了 base64 和 RC4。flag 明文每 3 个字符为 1 组,对应最后密文的 4 个字节,每次只穷举 3 个字符,就必然能对应上密文的 4 个字节,每组只有 (127-32)**3 种情况,在可接受的时间内可以完成穷举,这是可以分组爆破的原因。如果像 DES 那样,每组有 8 个字节也就是 64 位,爆破需要数天时间,是不可接受的。

在判断出基本算法后,本题预设 3 种解法。

# 解法 1:复制出反编译代码,分段穷举输入的所有可能

最省事且快的做法,既不需要大量创建线程,也不需要发现所有魔改的地方。缺点是如果遗漏了某个角落里的部分加密过程(比如有些题目在程序加载时会修改密文),就得不到正确的结果。所以必须随便设计一组输入,比对 爆破脚本 与 动态调试原文件 产生的对应的密文是否一致。同时,本题三个加密函数都是无状态(函数式)的,只影响参数,不对全局变量造成影响,否则要考虑的更多。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "defs.h" // 这是 IDA 安装路径里的文件,定义了 IDA 里看到的 HIBYTE 之类的东西
char *aAbcdefghijklmn = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
char *__fastcall sub_1209(char *a1)
{
    // 自行复制
}
_BYTE *__fastcall sub_1290(unsigned __int8 *a1, int a2)
{
    // 自行复制
}
_BYTE *__fastcall sub_1650(__int64 a1, int a2, const char *a3)
{
    // 自行复制
}
int main()
{
    // TF { 留给爆破,用来验证爆破程序是否正确
    char flag[] = "W4terC????????????????????????????????????}";
    char tmp[] = "W4terC????????????????????????????????????}";
    unsigned char res[] = {
        0xF9, 0xB6, 0x61, 0xF4, 0x74, 0x6C, 0xE1, 0x0D, 0xE5, 0xEC,
        0x65, 0x1E, 0x0F, 0x35, 0x59, 0x37, 0x0E, 0x23, 0xE3, 0x55,
        0x2C, 0xE4, 0x24, 0x72, 0x15, 0xC8, 0x5D, 0xAA, 0x53, 0x34,
        0xB9, 0x98, 0x0B, 0x1F, 0x04, 0x71, 0xF9, 0x97, 0xBB, 0x0E,
        0x39, 0x0A, 0x33, 0xE2, 0x66, 0x98, 0x88, 0x72, 0x52, 0x42,
        0xAF, 0x90, 0xA7, 0xE9, 0x5B, 0xEF, 0x71, 0x6D, 0xBB, 0x35
    };
    for (char *i = flag + 6; i < flag + 42; i += 3)
    {
        for (i[0] = 32; i[0] < 127; i[0]++) // 通过 i 来修改 flag 数组
        {
            for (i[1] = 32; i[1] < 127; i[1]++)
            {
                for (i[2] = 32; i[2] < 127; i[2]++)
                {
                    strcpy(tmp, flag);
                    sub_1209(tmp);
                    unsigned char *tmp1 = sub_1290(tmp, 43);
                    unsigned char *tmp2 = sub_1650(tmp1, 60, "W4terCTF{ZaYu}");
                    if (!memcmp(tmp2, res, (i - flag + 3) * 4 / 3))
                    {
                        printf("%s\n", flag);
                        free(tmp1);
                        free(tmp2);
                        goto br; // 退出循环,锁定这组结果
                    }
                    free(tmp1);
                    free(tmp2);
                }
            }
        }
    br:
    ;
    }
    return 0;
}

在我的电脑上需要爆破 48 秒(可能是频繁 malloc 开销大)。输出:

W4terCTF{?????????????????????????????????}
W4terCTF{UnR??????????????????????????????}
W4terCTF{UnRELa???????????????????????????}
W4terCTF{UnRELat3D????????????????????????}
W4terCTF{UnRELat3D_BY?????????????????????}
W4terCTF{UnRELat3D_BYte5??????????????????}
W4terCTF{UnRELat3D_BYte5_cA???????????????}
W4terCTF{UnRELat3D_BYte5_cAn_6????????????}
W4terCTF{UnRELat3D_BYte5_cAn_63_e?????????}
W4terCTF{UnRELat3D_BYte5_cAn_63_eNuM??????}
W4terCTF{UnRELat3D_BYte5_cAn_63_eNuMeRA???}
W4terCTF{UnRELat3D_BYte5_cAn_63_eNuMeRA7ED}

# 解法 2:创建进程,分段穷举输入的所有可能

这种解法是多次将程序运行起来,向程序提供输入,统计输出结果。出题时为了这种解法可行,专门在输入错误 flag 后输出了匹配的个数。由于每次都要创建新的进程,开销很大,爆破需要的时间更长。

必须向程序输入 43 个字符。由于 base64 不是对单个字节操作,如果对 43 个字符逐个爆破,只跑一轮是无法完全匹配的。就算第一次尝试时把这 43 个字符设为全 'A' 或者别的什么,也可能碰巧匹配上一部分;由于 Base64 编码结果的每个字符只由原来的 6 位得到,“最终匹配数量加一” 并不意味着 “输入字符多对了一个”。

以下列举几种真的能运行打印出正确 flag 而不是最后靠猜的做法:

A. 三个字符三个字符地尝试

保险(但低效)的做法是三个字符三个字符地尝试,尝试所有可能(而不中途提前结束),选择匹配数量最多的一次尝试(匹配数量最多的必然只有一次)。但是这样会导致爆破时间非常长,而每次尝试单个字符的时间相对短很多。

出题人尝试用 Python(很慢)pwntools(很慢),在出题人的电脑上,每 3 个字符都需要 3 小时才能跑完,这是不可接受的。有选手用 Python subprocess,跑完整个 flag 用了 1.5 小时。有选手用 Rust,跑完整个 flag 只用了十来分钟。这里抄写选手 WP

use std::{
    io::Write,
    process::{Command, Stdio},
};
fn main() {
    // Init to 7 to make only 2 out of 60 match
    let mut flag = [7; 43 + 1];
    flag[43] = b'\n';
    flag[42] = b'}';
    let prefix = b"W4terCTF{";
    flag[..prefix.len()].copy_from_slice(prefix);
    let mut last_matched = (prefix.len() / 3 * 4 + 4) as u8;
    let mut command = Command::new("./BruteforceMe");
    command.stdin(Stdio::piped()).stdout(Stdio::piped());
    'loop_i: for i in (prefix.len()..42).step_by(3) {
        println!("{}", flag[..i].escape_ascii());
        for x in 0x21..0x7f {
            println!("progress: {x}");
            flag[i] = x;
            for y in 0x21..0x7f {
                flag[i + 1] = y;
                for z in 0x21..0x7f {
                    flag[i + 2] = z;
                    let mut child = command.spawn().unwrap();
                    let mut stdin = child.stdin.take().unwrap();
                    stdin.write_all(&flag).unwrap();
                    let output = child.wait_with_output().unwrap().stdout;
                    let a = output[17];
                    let b = output[18];
                    let success = a == b'C';
                    if success {
                        break 'loop_i;
                    }
                    let matched = if b == b' ' {
                        a - b'0'
                    } else {
                        (a - b'0') * 10 + (b - b'0')
                    };
                    if matched == last_matched + 4 {
                        last_matched = matched;
                        continue 'loop_i;
                    }
                }
            }
        }
    }
    println!("{}", flag.escape_ascii());
}
# 在出题人的电脑上每 3 个字符都需要 3 小时才能跑完,这是不可接受的,这段代码看看就好了
from itertools import product
from pwn import *
from string import printable
from tqdm import tqdm
context.log_level = 'ERROR'
flag = list(b'W4terC' + b'\xff' * 36 + b'}')
alphabet = printable[:-6]
dic = list(product(alphabet, repeat=3))
max_match_count = 0
for i in range(6, 42, 3):
    tmp_flag = ''
    for j in tqdm(dic):
        j = ''.join(j)
        flag[i:i+3] = list(j.encode())
        s = process('./BruteforceMe')
        s.sendline(bytes(flag))
        match_count = int(s.recvall().decode().split()[3])
        s.close()
        if match_count > max_match_count:
            max_match_count = match_count
            tmp_flag = j
    flag[i:i+3] = list(tmp_flag.encode())
    print(bytes(flag))

B. 尝试单个字符,跑完一轮后打乱字母表

看到选手很巧妙地每次跑完 43 个字符后,把爆破的字母表 (A-Za-z0-9_) 随机打乱。这样可以让每个未完成的三字节分组的第一个字节发生变化。第二次抄写选手 WP,贴一份简化后的脚本,两分钟内能跑完:

import subprocess
import random
def run_and_get_result() -> int:
    global flag   # 貌似会比传参快一些
    command = ["./BruteforceMe"]
    process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    process.stdin.write(''.join(flag))
    process.stdin.flush()
    output_data, _ = process.communicate()
    if 'Congratulations' in output_data:
        print(''.join(flag))
        exit()
    return int(output_data.split()[3])
if __name__ == "__main__":
    result = 0
    flag = list("W4terCTF")
    alphabet = list("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_")
    while result < 60:
        random.shuffle(alphabet)   # 这是精髓
        for t in range(43):
            for v in alphabet:
                tmp = flag[t]
                flag[t] = v
                new_result = run_and_get_result()
                if new_result >= result:
                    print(''.join(flag))
                    result = new_result
                else:
                    flag[t] = tmp
        print(f"best: {result}")
        print(''.join(flag))

C. 总是查找出问题的是哪个字符

这里第三次抄写选手 WP

third-method

D. 根据 base64 的特点进行剪枝

以标准 base64 为例,假如某三个字符编码后得到的是 W*** ,这个 W 已经对上了。那么第一个字符一定是 010110?? ,即 X Y Z [ 这四个字符中的一个。这样就只需要尝试 X** Y** Z** [** ,大大减少了可能的空间。第二个字符以此类推。这里第四次抄写选手 WP,贴个脚本:

fourth-method

# 解法 3:写反向算法

这是最传统的做法,这种做法就和题目名称无关了,需要看出所有魔改的地方才行。详见上文逻辑分析。

import base64
def decode_flag(enc: bytes) -> bytes:
    tmp = list(my_rc4_encode(enc, b'W4terCTF{ZaYu}'))  # RC4 的 encode 和 decode 是一样的
    for i in range(0, len(tmp), 4):
        # 负数就是按位取反加一,按位取反就是负数减一,再加回 256 回到 0~255 之间
        # 或者说,反码加原码会等于 11111111 即 255
        tmp[i], tmp[i+3] = 255-tmp[i+3], 255-tmp[i]
        tmp[i+1], tmp[i+2] = 255-tmp[i+2], 255-tmp[i+1]
    tmp = bytes(tmp).replace(b'\x81', b'=')
    tmp = list(base64.b64decode(tmp))
    for i in range(len(tmp)):
        # 这里的 pow (17, -1, 256) 见上文逻辑分析第一步
        tmp[i] = tmp[i] * pow(17, -1, 256) % 256
        tmp[i] ^= 0x87
    return bytes(tmp)
def my_rc4_encode(raw: bytes, key: bytes) -> bytes:
    s = list(range(256))
    j = 0
    out = []
    for i in range(256):
        j = (j + s[i] + key[i % len(key)]) % 256
        s[i], s[j] = s[j], s[i]
    j = k = 0
    for i in range(len(raw)):
        k = (k + 1) % 256
        j = (j + s[k]) % 256
        s[k], s[j] = s[j], s[k]
        out.append(raw[i] ^ s[(s[k] + s[j]) % 256] ^ (len(raw)-i))
    return bytes(out)
if __name__ == '__main__':
    enc = bytes([
        0xF9, 0xB6, 0x61, 0xF4, 0x74, 0x6C, 0xE1, 0x0D, 0xE5, 0xEC,
        0x65, 0x1E, 0x0F, 0x35, 0x59, 0x37, 0x0E, 0x23, 0xE3, 0x55,
        0x2C, 0xE4, 0x24, 0x72, 0x15, 0xC8, 0x5D, 0xAA, 0x53, 0x34,
        0xB9, 0x98, 0x0B, 0x1F, 0x04, 0x71, 0xF9, 0x97, 0xBB, 0x0E,
        0x39, 0x0A, 0x33, 0xE2, 0x66, 0x98, 0x88, 0x72, 0x52, 0x42,
        0xAF, 0x90, 0xA7, 0xE9, 0x5B, 0xEF, 0x71, 0x6D, 0xBB, 0x35
    ])
    print(decode_flag(enc))

彩蛋:杂鱼~❤️