W4terCTF2026 —— wp分享

W4terCTF_2026_wp By A-Liang's_Payload

只放了我自己做的题目的wp。仅供学习和交流之用。

MISC

红芯铸魂・码践初心

做过类似题目,一眼社会主义核心价值观编码。bugku上有现成工具可直接用,解码得W4terCTF{Br41nW4sh1n9_5uck5}。

SignIn

注意到协会网站下面有个终端,输入tree再输入cat flag得到W4terCTF{Welc0me_t0_W4terCTF2026!!!}。

Constructed Dataline

vscode打开文件,发现大量Dataline(32202,32204,0.80,1.26,0.00,0.00);格式的文本,观察数据规律并基于题目名称“Constructed Dataline”猜测这是一个坐标序列,怀疑前四个参数是x1,x2,y1,y2,用python绘图库将其画出来即可。脚本如下:

import re
import matplotlib.pyplot as plt

Y_SCALE = 100
lines = []
with open("flag", "r", encoding="utf-8") as f:
    for line in f:
        m = re.search(r'Dataline\(([^)]*)\);', line.strip())
        if m:
            nums = [float(x.strip()) for x in m.group(1).split(",")]
            if len(nums) >= 4:
                lines.append((nums[0], nums[1], nums[2], nums[3]))

plt.figure(figsize=(25, 8), dpi=200)
for x1, x2, y1, y2 in lines:
    plt.plot([x1, x2], [y1 * Y_SCALE, y2 * Y_SCALE], color='black', linewidth=0.8)

plt.tight_layout()
plt.savefig("result.png")

得到的图片放到word中翻转并拉伸即可得到W4terCTF{Ev3ryb0dy_plz_V_Hachiwa0_50_bcUz_He_i5_so0o0o0o0o_hUn9rY_on_KFCCrazyThurs}。

REVERSE

Funsokoban

简单运行了下程序,大致猜测是一个迷宫游戏的变种(后来发现是推箱子)。这种题的思路永远是先想方设法把地图搞出来。

用IDA打开,从start函数开始分析。start函数的伪代码的总体抽象层级较低,调用了许多不多见的内核函数。上网搜索可知,sys_rt_sigaction是一个用于自定义处理段错误函数的回调函数,其第一个参数11是信号编号,表示段错误,而后面的参数则用于传递v24这个结构体——其中承载着自定义错误处理函数sub_133700E0。出于好奇,先进入sub_133700E0函数查看,发现了一行奇怪的代码"v2[0] = 0x6C69616620756F79LL;",IDA又错把字符串当十六进制读了。修复后发现就是"you fail"。这似乎是只要跳转到这里就会触发段错误,进而触发失败播报。此处阅读已经十分吃力。再后面的伪代码就越来越读不下去了,整个程序充满了大量标签跳转,可读性极差。

考虑到我们对整个游戏的玩法都完全不了解,而且程序核心逻辑的伪代码有多处致命的错误,例如IDA对处理用户输入部分的反编译让我一度以为用户只能输入w和s两种字符,无奈之下只能手啃汇编。阅读loc_442008地址开始的代码,才知道用户实际上可以输入W,S,A,D,F五种类型的字符,显然前面四个代表着在二维平面地图下的移动方向。而F会直接跳转到LABEL_412——在这里惊喜的发现了success和to many steps两个字符串(IDA又没读出来。。。),仔细分析发现是最终通过前的检查部分,其中的if对两个地址是否为1的判断应该是一个很重要的逻辑。这段代码还提示我们最终通关的操作序列不能超过63个字符,应该尽可能取最短路径。

LABEL_412:
        if ( dword_402000 == 1 && dword_433000 == 1 )
        {
          if ( i <= 63 )
          {
            v24[4] = '\nsseccus';
            v20 = sys_write(1u, (const char *)&v24[4], 8u);
            v21 = sys_exit(0);
            __halt();
          }
          strcpy((char *)&v24[3], "too many steps\n");
          v22 = sys_write(1u, (const char *)&v24[3], 0xFu);
          v23 = sys_exit(1);
          __halt();
        }

还是回头,继续分析开始的w/s/a/d解析和跳转部分的代码。程序第一次开始做w/s/a/d解析时的地址就是在我们提到的442000处以后,也就是seg16里面(注意到这个elf程序是没有代码段和数据段的,只有17个seg段,显然是由作者精心设计)。汇编代码写的很清楚,用jz+cmp来实现解析加跳转,按w会跳到loc_442049,按a会跳到loc_4420B5。。。我们只能硬着头皮一个一个点击跟进下去。发现440249处的代码实际上又跳转到了432000处,4420B5处的代码实际上又跳转到了441000处。于是又仔细这样逐个跟踪不同seg段到不同seg段的跳转逻辑(非常耗时),发现了一个鼓舞人心的事实:整个二维平面图的格子编号被隐式的编码在了seg段地址的第四高位(x)和第五高位(y)处,例如442000按w会到432000,442000按a会到441000。到这里整个程序的架构就瞬间明了了,这意味着我们完全可以基于17个seg段建模出整个地图,而且地图的规模并不大,呈现清晰的五乘五结构,其中的八个格子是未定义状态。

.seg16:0000000000442025                 cmp     al, 77h ; 'w'
.seg16:0000000000442027                 jz      short loc_442049
.seg16:0000000000442029                 cmp     al, 73h ; 's'
.seg16:000000000044202B                 jz      short loc_44207D
.seg16:000000000044202D                 cmp     al, 61h ; 'a'
.seg16:000000000044202F                 jz      loc_4420B5
.seg16:0000000000442035                 cmp     al, 64h ; 'd'
.seg16:0000000000442037                 jz      loc_4420ED
.seg16:000000000044203D                 cmp     al, 66h ; 'f'
.seg16:000000000044203F                 jz      loc_13370120

但是地图的部分元数据信息还需要我们去获取。基于前面推理出的重要成果,我们目前知道是玩家出生点是在(2,4)的。前面贴出的LABEL_412给了我们启发:在所有4xx000处存放的双字节数据,也就是所有seg段起始处的变量,存放的值只能是0或1,且极有可能代表着箱子是否在这一格子上这一条件。基于该前提,最后通关也必须让指定的两个格子上都放好箱子,这虽然有些奇怪但我们姑且接受(事实上这是正确的)。扫一眼所有的seg段,发现初始时仅有402000和431000两处的dword值为1,也就是说箱子开始就放在了(1,3)和(2,0)上面,而我们的目的正是要将箱子推到LABEL_412所提及的(2,0)和(3,3)上。这里不禁要提出一个疑问,那就是(2,0)处的这个箱子是完全可以不用移动的,即便要移动也会浪费额外的步骤。换而言之,可以把(2,0)处的箱子视为一个禁区,尽量不要让另外一个箱子影响到此箱子,同时也不应去推它。该箱子反倒是大大拉高了我们规划路线的难度,考虑到地图的狭窄性。

此时我们对本题的认识已经十分深刻,下一步是要仔细分析所有seg段汇编共有的模板化代码(高度重合),也就是推这个动作的判定逻辑。其实在明确4xx000处变量的含义之后,整套汇编代码的逻辑阅读起来就很容易了,完全可以只读汇编来分析,且程序几乎只用到了mov、cmp、jz/jnz三种命令。仍然以seg16部分的逻辑为例做分析,当移动的目标格有箱子时,先检查“再后面一格”,若那里也有箱子或者是墙壁(不可到的seg段),将跳转到我们最开头的sub_133700E0函数并触发fail播报。若能正常推动,则原位置的seg段开头的变量清零,而目标seg段的变量置为1。这再一次验证了我们前面分析的成果。

.seg16:0000000000442049 loc_442049:                             ; CODE XREF: start-12F2DFD9↑j
.seg16:0000000000442049                 mov     eax, ds:432000h
.seg16:0000000000442050                 cmp     eax, 1
.seg16:0000000000442053                 jnz     short loc_442075
.seg16:0000000000442055                 cmp     dword ptr ds:422000h, 1
.seg16:000000000044205D                 jz      short loc_442008
.seg16:000000000044205F                 mov     dword ptr ds:432000h, 0
.seg16:000000000044206A                 mov     dword ptr ds:422000h, 1
.seg16:0000000000442075
.seg16:0000000000442075 loc_442075:                             ; CODE XREF: start-12F2DFAD↑j
.seg16:0000000000442075                 inc     r12
.seg16:0000000000442078                 jmp     near ptr 432008h
.seg16:000000000044207D ; ------------------------------------------------------------------------
.seg16:000000000044207D loc_44207D:                             ; CODE XREF: start-12F2DFD5↑j
.seg16:000000000044207D                 mov     eax, ds:452000h
.seg16:0000000000442084                 cmp     eax, 1
.seg16:0000000000442087                 jnz     short loc_4420AD
.seg16:0000000000442089                 cmp     dword ptr ds:462000h, 1
.seg16:0000000000442091                 jz      loc_442008
.seg16:0000000000442097                 mov     dword ptr ds:452000h, 0
.seg16:00000000004420A2                 mov     dword ptr ds:462000h, 1
.seg16:00000000004420AD
.seg16:00000000004420AD loc_4420AD:                             ; CODE XREF: start-12F2DF79↑j
.seg16:00000000004420AD                 inc     r12
.seg16:00000000004420B0                 jmp     near ptr 452008h
.seg16:00000000004420B5 ; ------------------------------------------------------------------------
.seg16:00000000004420B5 loc_4420B5:                             ; CODE XREF: start-12F2DFD1↑j
.seg16:00000000004420B5                 mov     eax, ds:dword_441000
.seg16:00000000004420BC                 cmp     eax, 1
.seg16:00000000004420BF                 jnz     short loc_4420E5
.seg16:00000000004420C1                 cmp     dword ptr ds:440000h, 1
.seg16:00000000004420C9                 jz      loc_442008
.seg16:00000000004420CF                 mov     ds:dword_441000, 0
.seg16:00000000004420DA                 mov     dword ptr ds:440000h, 1
.seg16:00000000004420E5
.seg16:00000000004420E5 loc_4420E5:                             ; CODE XREF: start-12F2DF41↑j
.seg16:00000000004420E5                 inc     r12
.seg16:00000000004420E8                 jmp     loc_441008
.seg16:00000000004420ED ; ------------------------------------------------------------------------
.seg16:00000000004420ED loc_4420ED:                             ; CODE XREF: start-12F2DFC9↑j
.seg16:00000000004420ED                 mov     eax, ds:dword_443000
.seg16:00000000004420F4                 cmp     eax, 1
.seg16:00000000004420F7                 jnz     short loc_44211D
.seg16:00000000004420F9                 cmp     dword ptr ds:444000h, 1
.seg16:0000000000442101                 jz      loc_442008
.seg16:0000000000442107                 mov     ds:dword_443000, 0
.seg16:0000000000442112                 mov     dword ptr ds:444000h, 1
.seg16:000000000044211D
.seg16:000000000044211D loc_44211D:                             ; CODE XREF: start-12F2DF09↑j
.seg16:000000000044211D                 inc     r12
.seg16:0000000000442120                 jmp     loc_443008

所有seg段都是基于这段逻辑。接着就是逐一阅读所有的17个seg段,汇总所有的跳转逻辑,画出整个地图的全貌(W为墙,.为通路,P为玩家,B为箱子,T为目标):

y\x 0 1 2 3 4
0   . . T . .
1   . . . . .
2   W . W . W
3   W B . T W
4   W . P . W

豁然开朗。这样的推箱子问题就是一道初中级别的逻辑题,由于(2,0)处的箱子位于顶部边缘且两侧路径受限,最佳策略是保持其不动,绕过障碍物将(1,3)的箱子推向右侧的目标点(3,3)。需要注意时刻调整人的位置(不然会卡住),在纸上手动推演得到操作序列如下:dwwwaawdsdsssaawwssddwwwawaasdsssddwwwdwaadssssaawwwdwdsswaaawd。最后再加一个f就能得到flag。

很有意思的题目。

开始电力运输

第一次看到给网页的逆向题,起初误以为是web题。。。

游戏的前两关是送分的,只需按照右侧面板的规则,放中继器连线即可,可以毫无难度的得到“W4terCTF{C0NnEC7_One_8Y_ON3_W17hin_b0m_7Hat_lS_easY_T0_pEr10RM_”。来到第三关,开始傻傻的逐一连线,连到一半后发现电厂不足,事实上发电厂的供电量本身完全不够覆盖一百个节点。显然作为逆向题,本题的考点在于前端给出的可供下载的两个js文件和wasm文件。开始当成web题做,本想从控制台入手用js修改游戏逻辑如增加一座发电厂之类的,但是多次尝试无果,加上自身js水平不够便放弃此路。

看来本题必须要分析wasm,要从js与wasm的黏合层入手,上网查阅资料得知,wasm文件必然会有对应的js文件写好wasm函数与js函数的一一映射关系。于是在game_logic.js文件在找到了js与wasm的映射/导出表,该表亦解释了为什么IDA中解析wasm文件的函数列表看着那么奇怪,函数名大多都只有一个字母。再看另外一份js文件。game_wrapper.js这份前端主逻辑的文件的注释十分详尽,在verifyLevelCompleteEx这个函数中可以看到其调用了__verifyLevelCompleteExh函数,而该函数顾名思义正是我们想要绕过的通关检查函数,它恰巧被映射到了wasm文件的w函数里。随后开始重点分析这份wasm文件,在此之前要做些工作,先用官网下载的wasm-decompile.exe工具将wasm文件翻译成可读的伪c代码,得到game_logic.c文件。

打开game_logic.c,在文件的开头发现了一串极其可疑的密文变量,定义在offset1024这里。然后看w函数,其传入的第一个参数a正是关卡索引的值。变量g看着很像是存放在栈上的一个结构体。随后,程序在将变量a和指定内存地址的数据做比较,而且可以发现仅当a==1488[0]时程序才会正常跳转到复杂的解密流程里去,也就是label B_b。我们在vscode中系统搜索所有用到1488[0]的代码,发现在加载关卡的函数u里试图过用关卡id参数对其直接赋值,不难猜出w函数正是要对我们到哪个关卡做验证。回到w函数,后面一行是对l函数的调用,并紧跟着d={}的一段很长的代码块,里面的作用逻辑并不明确,我们暂且跳过。我们直接跳到函数中间涉及到对地址1024操作的部分(直接搜索1024,可以定位到1878行前后),有这样关键的一行e=d+1024,而前面又有d=a<<6,非常之明显,这部分代码就是在根据目前所在的关卡取出指定偏移地址的密文。第三关对应a=2,即d=128,则e被赋值为1152,作为存放密文的指针被传到下面的循环里解密。

if (c > (c = (d = a << 6)[1027])) {
    f = select_if(1, c, c <= 1);
    e = d + 1024;
    i = e[0]:ubyte;
    d = 0;
    loop L_t {
      (b + d)[0]:byte =
        (d + i ^ ((e + d % 3)[0]:ubyte ^ ((d + e)[4]:ubyte ^ a))) ^ 58;
      d = d + 1;
      if (d != f) continue L_t;
    }
    (b + c)[0]:byte = 0;
    goto B_p;
  }

b+d这个指针的地址是在循环中被赋值的一方,我们解密后的数据正是被逐个比特计算后存放到该地址处(这在某个程度上揭示了为什么w函数收到的第二个参数是指针b)。e是基地址,d是偏移量,i是第三关数据块的第一个字节(ubyte),a是关卡id,(d + e)[4]:ubyte ^ a。d+e地址处偏移4位置的字节数据与a做异或,(e + d % 3)[0]:ubyte表示根据d%3的结果(0/1/2)从基地址处读取一个字节。内层逐一异或,值得注意的是外层还会和58(0x3A)异或一次。彻底理清了加解密的逻辑。最终第三部分的解密脚本和输出如下:

Strange puts

IDA打开,没有找到main函数,直接从start函数开始看。sub_140001010()函数是典型的编译器生成的CRT环境初始化函数,这时直接关注环境初始化函数最后的return result部分,可以定位到result被赋值为了sub_140001F28(),点进去可以发现是验证flag的主函数。sub_14000168B的加密逻辑很简单,就是一个基础的循环密钥XOR加密。写脚本:

key = [0x13, 0x37, 0x42, 0x99, 0xA5, 0x5A, 0xC3, 0x7E]
encrypted = [
    0x47, 0x5F, 0x2B, 0xEA, 0xFA, 0x33, 0xB0, 0x21,
    0x75, 0x56, 0x29, 0xFC, 0xFA, 0x3C, 0xAF, 0x1F,
    0x74, 0x37, 0x42, 0x99, 0xA5, 0x5A, 0xC3, 0x7E,
    0x13, 0x37, 0x42, 0x99, 0xA5, 0x5A, 0xC3, 0x7E,
    0x13, 0x37, 0x42, 0x99, 0xA5, 0x5A, 0xC3, 0x7E,
    0x13, 0x37, 0x42, 0x99, 0xA5, 0x5A, 0xC3, 0x7E,
    0x13, 0x37, 0x42, 0x99
]
flag = []
for i in range(len(encrypted)):
    flag_char = encrypted[i] ^ key[i % 8]
    flag.append(flag_char)
flag_bytes = bytes(flag)
print("Flag:", flag_bytes.decode('ascii'))

输出是"This_is_fake_flag",看来本题还另有猫腻。于是到这里线索一下子中断了,出题人可能在start函数设下了一些圈套。但好在Findcrypt插件帮了大忙(也许这算是非预期解),在0x1400050C0处发现了AES算法的S盒数据。按x引用逐步向上追踪调用,可以追踪到sub_140001728->sub_140001CA8->sub_140001DE2,终于发现了另外一个验证flag正确性的加密函数。仔细阅读后发现真加密函数的逻辑反而更简单了,就是一个标准的AES-128的ECB模式的实现。最终的解密脚本和输出如下:

Vigorcheck

本题放easy属实是太高看我了。IDA打开,发现main函数中的关键函数sub_401634无法用F5反编译,疑似是被花指令干扰。然后就是漫长的和花指令搏斗的过程(但有一说一这道题还挺锻炼手动修复花指令能力的,学到了很多)。

首先是将4016B1的一行jz和一行jnz全部nop掉,这是典型的无条件跳转花指令。紧接着是4016B5的call near ptr 43E05C81h(跳到一个超远地址)给直接nop掉,这里我选择直接patch上五个0x90。两个最明显的修复掉之后,发现函数仍然会遇到堆栈不平衡问题,为此可以在Options->General中开启StackPointer选项来做更方便的排查。发现罪魁祸首是call loc_40165F这里的代码,将其修改为jmp short loc_40165F即可解决,随后将整段401634函数按U设置为未定义部分,按C重新解析为代码,再按P和Tab后可以看到函数的伪代码就回来了(当然v1那行还有问题,后面才意识到)。

int sub_401634()
{
  char v0; // dl
  _BYTE *v1; // rcx
  _DWORD v3[4]; // [rsp+20h] [rbp-60h] BYREF
  char Str[72]; // [rsp+30h] [rbp-50h] BYREF
  int v5; // [rsp+78h] [rbp-8h]
  int i; // [rsp+7Ch] [rbp-4h]

  memset(Str, 0, 0x40u);
  printf("Speak the secret incantation to pass: ");
  scanf("%63s", Str);
  if ( strlen(Str) == 48 )
  {
    *v1 += v0 + (Str[64] < 0x30u);
    v3[1] = 572759408;
    v3[2] = 858950753;
    v3[3] = 1145436950;
    v5 = 6;
    for ( i = 0; i < v5; ++i )
      sub_401550((unsigned int *)&Str[8 * i], v3);
    if ( !memcmp(Str, &unk_403020, 0x30u) )
    {
      puts("\nGREAT ENEMY FELLED!");
      return puts("The Golden Order accepts your runes.");
    }
    else
    {
      puts("\nIncorrect incantation...");
      return puts("YOU DIED!");
    }
  }
  else
  {
    puts("\nThe incantation fizzles out.");
    return puts("YOU DIED!");
  }
}

阅读sub_401634,结果加密的关键一环sub_401550又不能正常反编译。在4015B1可以看到clc后面接一个jnb跳转,这同样是一个必然跳转,而且后面一些红色报错的跳转代码。于是将从4015B1开始的十个字节(一直到hlt)依然全部nop。于是sub_401550的逻辑终于浮现:

void __fastcall sub_401550(unsigned int *a1, _DWORD *a2)
{
  unsigned int i; // [rsp+20h] [rbp-10h]
  unsigned int v3; // [rsp+28h] [rbp-8h]
  unsigned int v4; // [rsp+2Ch] [rbp-4h]

  v4 = *a1;
  v3 = a1[1];
  for ( i = 0; i <= 0x1F; ++i )
  {
    v4 += v3 ^ (*a2 + 32 * v3) ^ ((v3 >> 3) + a2[1]);
    v3 += v4 ^ (a2[2] + (v4 << 6)) ^ ((v4 >> 4) + a2[3]);
  }
  *a1 = v4;
  a1[1] = v3;
}

但与标准tea算法比对后发现,sum必须是一个动态变化的值,可IDA似乎把sum优化掉了没有正确识别,这说明sum的值一直是0。与此同时伪代码也没有涉及到TEA魔数,然而在FindCrypt插件中能看到本题程序是定义了TEA数的,这一点非常之奇怪,说明我们的花指令去除并不成功。在重新对比了原始未经patch程序的字节发现,出题人极有可能在诱导我们修复花指令时顺带将关键的sum+=delta逻辑(对比标准TEA的汇编代码的字节位置就能发现)nop掉,从而干扰F5伪代码的翻译。此外,这段代码的TEA加密本身也并不标准,至少有两个地方做了相当明显的魔改:其一是该变体采用了一个串行反馈结构,可以看到在每一轮迭代中v3的更新依赖于当前轮次已经更新后的v4值;其二是是第一阶段更新v5的时候用的是<<5和>>3,第二阶段更新v3的时候用的是<<6和>>4(而标准TEA固定使用<<4和>>5),这点还是很坑的。

另外在取数据的时候还遇到一个新的问题是,v3[0]的数据似乎也因为被我意外nop掉而丢失了。又被迫去原始程序中仔细查看对应位置的字节,发现C7 45 A0 43 AE 10 11这段字节实际上对应着mov dword ptr [rbp-60h], 1110AE43h的汇编代码(这里也是推荐一个工具网站https://defuse.ca/online-x86-assembler.htm)。这个地方的花指令手动去除难度也是极大的,甚至可以说是本题最劝退的点,必须通过伪代码的不完整来反推出来。所幸在机器码层面还是能清楚的看到完整的128位TEA密钥。

虽说拿了一血但是过程筋疲力尽。折腾了半天后最终可以得到解密脚本和输出如下:

Bluntstone

查壳发现是upx,先交给upx脱壳,然后再用IDA分析。毕竟是逆向题中唯一的一道A2题,接下来就直接用ai来梭,所以出的也很快,省去了大量肉眼读代码的时间。这里的思路也就简述。

main函数首先调用了AddVectoredExceptionHandler注册了一个处理器Handle(即VEH异常处理,在XSWCTF决赛中也出现过)。程序运行是必定会触发这个的,随后会跳到真正的校验逻辑。当异常触发时,控制流跳转到Handler,它会获取异常发生的地址(0x1400013C6)并利用这个地址作为种子计算出一个XOR密钥,对位于0x14002AAA0的加密内存区域进行解密。解密逻辑只是异或,所以是很容易的。解密后的内存区就明了很多,实际代码是做了一个RC4变体加密,多加了一个与0xAB异或的逻辑。最终脚本和输出如下:

PWN

能力开发・缺陷电气

checksec后看到保护全开。阅读主函数发现这是个简单的字符串操作程序,用户可以输入操作次数、字符串以及每次的操作码。1是末尾拼接,2是提取子串并覆盖,3是插入到开头,4是查找子串位置。考虑到本题开了canary,我们先打信息泄漏,而且题目给了libc文件说明极大概率要泄漏libc基址。这里首选从操作码2入手,题目有明显的数组越界读漏洞,不妨给v7传入一个大数。计算一下偏移:字符串dest位于-E0即-224,所以到canary是216,到返回地址是232。因为main结束后必定返回到__libc_start_main中的某一行代码里,而那行代码到libc文件基地址的值是固定的(完全取决于libc版本),由此可泄漏出libc地址。

写脚本时还要注意到本题是典型的没有写setvbuf的,64位linux下会默认开启4096字节的全缓冲区,我们必须一次性发满4096字节程序才会有回显。这里的设计是前两次操作都选2,第一次操作是传入偏移217和8字节去除canary,第二次操作是传入偏移232和6字节(6已经足够定位)取出返回地址。接着我们调用一次操作1让程序吐出90个F字节以便后续定位。最后反复调用操作2,让程序不断吐字节以保证达到4096个字节(实际刷了至少4800个字节进去)。泄漏脚本和输出如下:

可以看到canary能正常泄漏出来。得到的返回地址是0x79a9deabd1ca。在linux虚拟机中输入objdump -d ./libc.so.6 | grep "1ca:"可以看到2a1ca: 89 c7 mov %eax,%edi,减去2a1ca这个偏移就能获得libc基址。接着打个ret2libc3即可拿到flag。写ROP链前已用readelf拿到了各个gadget的偏移。最终脚本和输出如下:

能力开发・路径穿过

比赛头两天拿到这题没啥思路,也许是自己第一次审计一个简易http服务器的源码而困难重重。结果没想到周二直接放提示了“漏洞位于 source.c 第 75 行。”,这可能是最直白的提示了吧。

第七十五行是一个拼接函数。程序本身是有一段防御路径穿越的检查的,只要..出现在?前后判定不合法,所以我们需要把..放在?后面。第七十五行是个strncpy函数,结合strncpy的特性,若可以控制query-uri的长度,使其刚好填满fs_path的剩余空间,那么fs_path缓冲区中原有的\0就被覆盖,且不会补上新的\0,这样我们就能越界读数据。而handle_client函数的栈上恰好有file,s1,v40三个变量,只要能填满file剩下的空间并覆盖掉整个s1缓冲区,file字符串在内存中就能直接和v40变量拼接。用IDA打开该函数计算一下偏移:255+512=767。

考虑到程序使用sscanf(req, "%511s %1023s %511s", method, uri, version)解析http请求,且后面的strstr只检查uri变量,做如下设计:第一个空格前的GET存入method,第二个空格前把超长斜杠字符串存入uri(用于触发 strncpy 溢出),第二个空格后的内容存入 version。把路径的payload藏在version字段就完事了。这里用socket库来打。最终脚本和输出如下:

能力开发・鸟瞰把握

本题的逻辑十分清晰但利用苛刻。好在学了汇编也总算是发挥一次巨大用处。

程序在随机地址mmap了一块RWX的内存,读入用户输入的最大0x1000字节payload,然而跳转过去前除rax外的所有寄存器都会被xor清零。显而易见本题的第一件事就是手动重建栈,通过赋值rbp和rsp在代码后方0x800处划一块内存当栈用。有了栈和rax我们可以调用syscall来破局了。本题的payload只能全程手写汇编。用checksec检查发现全开。ASLR意味着只让用RIP相对寻址。此外还察觉程序是开了IBT的,我们第一条指令必须是endbr64,否则jmp过去会崩。这里首先试了下盲读/flag发现不行,结合题意猜测要列出当前目录(符合鸟瞰的想法)。上网查询一波amd64架构下linux的系统调用编号,2是打开目录,0是读取文件,217是读取目录条目,1是打印,60是exit。手写实现syscall217的读取逻辑还是有些难度,这里反复改了多次。最终的扫描目录脚本和输出如下:

成功扫出来一个flag1_5h5kq8hd文件。对前面的脚本稍作修改,在扫描后面加上读取。读取脚本和输出如下:

拿到了flag的part1。题目提示第二个part在环境变量里。有趣的是尝试了几下发现本题压根没有挂载/proc/self/environ和/etc/environment。这里唯一的解法似乎就是暴力扫描整片内存,但是扫描并全部无脑write到屏幕显然不行,只能用mincore嗅探(这倒是ai想到的,系统调用号是27)。Linux用户态内存最高到0x7ffffffff000。可以从高地址开始向下按页扫描,只要mincore发现该页可读(有映射)就write到屏幕,解析时我们可以直接找键值对格式的字符串(写一个loop逻辑)。自己没太多经验,以至于开始误判要扫个一小时,结果没一会儿就出了。暴力扫描脚本和最终输出如下:

成功拿到flag的part2。

等长条

checksec依然是保护全开。一个俄罗斯方块游戏,正常打的话不可能达到超高分数然后拿flag。逆向发现游戏地图存储在一个全局数组game_state中。找了半天才看到检测方块下落的lock_piece函数是有边界不严漏洞的,程序只检查了y(行)的下界和x(列)的上界,明显就是要打这里了。目标是让10*y+x的结果指向score的内存地址,并将方块的1(代表存在)写入该地址。由于score是个四字节整数,写入1到它的高位字节会导致数值剧增,由此过关。但回看touches_bottom函数,正常下落时如果y达到19方块就会停止,这是个难点。突破口在于try_rotate旋转函数里的逻辑缺陷:方块处于地图最底部(y=18或19)按下w键旋转时,程序会计算旋转后的新坐标,旋转函数没有严格检查旋转后点是否超出范围时,就可配合lock_piece实现越界。不得不利用四格的长条方块。

想通关的办法就是等长条,出现后将它s到底,触底的瞬间按w再按space,接着再刷新一下分数(可以接着玩游戏消掉一行就行)看到输出flag。A2题就直接上ai解。最终通关的powershell脚本如下:

$IP = "127.0.0.1"
$PORT = 40520

Write-Host "[*] 正在连接到靶机 $IP : $PORT ..." -ForegroundColor Cyan

try {
    $client = New-Object System.Net.Sockets.TcpClient($IP, $PORT)
    $stream = $client.GetStream()
    $writer = New-Object System.IO.StreamWriter($stream)

    Start-Sleep -Milliseconds 600

    # 魔法连招:修改分数
    $payload = "wwaasssssssssssssssw "
    $writer.Write($payload)
    $writer.Flush()
    $buffer = New-Object System.Byte[] 8192

    while ($client.Connected) {
        if ($stream.DataAvailable) {
            $bytes = $stream.Read($buffer, 0, $buffer.Length)
            $text = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $bytes)
            # 直接将服务器画面的每一个字符实时打印出来
            Write-Host $text -NoNewline

            if ($text -match "W4ter{") {
                Write-Host "`n`n[+] Flag 已到手!" -ForegroundColor Green
                break
            }
        }
        # 捕获键盘输入发给服务器
        if ([console]::KeyAvailable) {
            $key = [console]::ReadKey($true)
            try {
                $writer.Write($key.KeyChar)
                $writer.Flush()
            } catch {
            }
        }
        Start-Sleep -Milliseconds 10
    }
}
catch {
    Write-Host "`n[-] 发生错误: $($_.Exception.Message)" -ForegroundColor Red
}
finally {
    if ($client) { $client.Close() }
}

附上得到flag的截图:

Forensics

损坏的 Sysprep

送分题。先用7zip将给的.vim文件解压。题目说windows的无人值守文件被删了,而所有的无人值守文件都是xml格式,换言之一定能在windows_sysprep.img中找出其字节序列。用010editor打开后直接搜索xml的文件签名<?xml可以看到三个结果:

其中的一个结果的标签下有一段<CommandLine>%SystemDrive%\Sysprep\ES5S\ES5S.exe /Deploy /Quiet /Optimization="Max" /LogPath="C:\Windows\Panther\ES5S.log" /Module="Core,Net,UI" /AuthToken="VzR0ZXJDVEZ7ZDMxZXQxTjlfRE9lc19ub3RfbUVhTl9kRUlldGVEfQ==" /Timeout=300 /Confirm /DoNotClean</CommandLine>,base64解码后得到W4terCTF{d31et1N9_DOes_not_mEaN_dEIeteD}。

PPC

日历

看到通知说A1改A2了,而且一血出的很快,于是就上ai了。这题是纯ai梭,ai让我在本地写了一个编译器文件,并修改了task.py文件以便本地测试。这里附上最后成功的编译器代码:

MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
code_blocks = [
    # 1月 (31): 门禁s=0。若i==81通关。若mem[i]!=0跳2月。若空位,压栈(面包屑),v=1,s=2,0c,跳4月。
    "s!#Zi99*-#@i[!#Zi1V2S0CZZ",

    # 2月 (28): 门禁s=0。活到这说明是预设字。i++,跳回1月继续找。
    "s!#Zi1+IZZ",

    # 3月 (31): 占位,分摊跳转压力
    "Z",

    # 4月 (30): 门禁s=2。行检查。冲突(!为1)则s=6。不冲突s=2。
    "s2-!#Zi9/9*c+[v-!4*2+SZZ",

    # 5月 (31): 门禁s=2。列检查。
    "s2-!#Zi9%c9*+[v-!4*2+SZZ",

    # 6月 (30): 门禁s=2。算宫基址,存入D。
    "s2-!#Zi9/3/3*9*i9%3/3*+DZZ",

    # 7月 (31): 门禁s=2。宫检查。
    "s2-!#Zc3/9*c3%+d+[v-!4*2+SZZ",

    # 8月 (31): 门禁s=2。检查全过!c++。如果c==9,s变成5。并对c取模。
    "s2-!#Zc1+$9-!3*2+S9%CZZ",

    # 9月 (30): 门禁s=6(失败)。v++。如果v>9,s变为7(回溯)。否则s=2。并清零c!
    "s6-!#Zv1+Vv9?5*2+S0CZZ",

    # 10月 (31): 门禁s=5(填入成功)。地址i在前,值v在后写入!i++,s=0继续找下一个。
    "s5-!#Ziv]i1+I0SZZ",

    # 11月 (30): 门禁s=7(回溯)。地址i在前,清0在后。弹旧i。复制新栈顶给i,读旧v。转s=6。
    "s7-!#Zi0].$Ii[V6SZZ",

    # 12月 (31): 兜底。
    "Z"
]
calendar_payload = []
print("正在编译日历代码...\n")
for i in range(12):
    code = code_blocks[i]
    code = code.strip()
    if len(code) > MONTH_DAYS[i]:
        print(f"[错误] 第 {i+1} 个月的代码太长了!({len(code)}/{MONTH_DAYS[i]})")
        exit()
    padded_code = code.ljust(MONTH_DAYS[i], ' ')
    calendar_payload.append(padded_code)
print("test_payload = [")
for line in calendar_payload:
    print(f"    '{line}',")
print("]")

可以得到正确的payload:

test_payload = [
    's!#Zi99*-#@i[!#Zi1V2S0CZZ      ',
    's!#Zi1+IZZ                  ',
    'Z                              ',
    's2-!#Zi9/9*c+[v-!4*2+SZZ      ',
    's2-!#Zi9%c9*+[v-!4*2+SZZ       ',
    's2-!#Zi9/3/3*9*i9%3/3*+DZZ    ',
    's2-!#Zc3/9*c3%+d+[v-!4*2+SZZ   ',
    's2-!#Zc1+$9-!3*2+S9%CZZ        ',
    's6-!#Zv1+Vv9?5*2+S0CZZ        ',
    's5-!#Ziv]i1+I0SZZ              ',
    's7-!#Zi0].$Ii[V6SZZ           ',
    'Z                              ',
]

附上拿到flag的截图:

OSINT

Find Me

开始由于工具没找对吃了大亏,实测证明这类精确的找位置题目只能使用谷歌地图,而不是mapillary和谷歌地球这样的网站(前者错误的标少了N277这条道路的完整路径,后者不能定位更高的精度)。在得知N277位于荷兰的东南地区后沿着地图上的N277的东南起始处逐个路口查看街景即可,找到指定时间和位置后在浏览器的搜索栏就直接有精确经纬度。

无论到哪都爱吃麦麦

浏览器大法,直接上谷歌搜索,其中一篇帖子的图片给了我们线索。

然后用谷歌地图定位。谷歌地图居功至伟,一个界面同时提供了街景、电话号码和经纬度。

Journey across...

被一道OSINT题从晚上十一点折磨到凌晨四点,和队友讨论到睡不着觉。这很难评。若没给那一些小提示的话这题放hard都不为过,因为题目完全没有指路。

谷歌暴力搜索Kazstrhv后可以找到https://wiki.arcaea.cn/index.php?title=User:Kazstrhv&oldid=76042,这是出题人在ARCENAwiki上的用户介绍。翻看修改历史可以顺着找到个人博客,博客里几篇文章需要重点关注。在4399美食大战老鼠吧里找到了“斗萷”这个id发的一篇文章。文章全程谜语人看不懂一点,在试图整段复制给ai时无意间发现中间藏了一段base64。解码后提示第一个flag是出题人Arcaea账号里最高分单曲的分数。

可以通过查分bot查到,遂获取第一个flag:

第二个flag在作者小号网易云音乐中对专辑a new sunrise的评论,为此甚至还专门下了个网易云音乐。小号的歌单中通过歌名拼接提示flag是作者“回老家了”那篇文章所在地点附近的学校。

用ai可以检索到是剑门学校。flag是W4terCTF{09983284_jianmenxuexiao}。