1
0
Files
blog.xinshi.fun/source/_posts/wp/w4terctf-2024-assign.md
2025-04-17 15:28:08 +08:00

26 KiB
Raw Blame History

title, date, updated, categories, cover
title date updated categories cover
W4terCTF 2024 Reverse 出题记录 2024/04/29 22:00:00 2024/04/30 23:30:00
CTF-Writeup
../../wp/w4terctf-2024-assign/image-5.png

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

古老的语言

附件(右键另存为)

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

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

8 支队伍攻克597 pts

判断语言和找工具

一个有图标的72KB的exe。

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

DIE

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

import view

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

ask gpt

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

img submit click

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

上面可以看到是P-Code。VB可以编译为P-Code伪指令或Native Code本机指令两种都要用系统的msvbvm60.dllP-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 3var_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总是变来变去的但是看最后一轮的最后一步操作这一步前后只改变了rl是没有变的所以(((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函数接收用户输入的flagsub_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看成0b00010001x乘17就相当于(x<<4)+x这样就可以反向计算了。虽然出题时并没有想到这一点17这个数是随便选的。

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

第二步 Base64

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

第三步 RC4

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))

彩蛋:杂鱼~❤️