题目是 arm64 下 linux,开个虚拟机跑起来大概长这样:可以按照这个输出顺下来流程

$ ./main
[Kei] Searching for Alice...
[Kei] There you are.
[Kei] So, she wants to play a game with you...
[Kei] But don't expect to meet her directly.
[Kei] I'll be the one watching. Always.
[Kei] Hmm...
[Kei] Hmm...
[Alice] Hey there, brave challenger~ 
[Alice] I'm Alice, and you've just stepped into my little puzzle world! 
[Alice] The board is all set up and ready to go... Are you feeling ready to take on the challenge? 
[Alice] Now, show me your solution when you're ready!
> {aaaaaaaaa}
[Kei] I see... A secret message from Alice?
[Kei] Hmm...
[Alice] Hmm... that doesn't seem quite right...
[Alice] Ah... can't you play more seriously?

因为题目外层没有去符号,应该也能看个大概出来是怎么样的。一开始父进程先 ptrace ATTACH 子进程上去(一开始 Kei 说的前五行话),之后是一系列状态同步检查和错误处理(都可以不看)。

之后是 ptrace 主循环,里面 if 检查的是 ptrace 返回的 status,根据常量查找知道对应的意义:如果子进程产生 SIGSTOP 信号并且 has_pending_input==true,那么触发 mangle 函数。如果子进程踩到 brk 0 了(触发 SIGTRAP 信号)就触发 handle_sigtrap 函数,开始解 SMC。

mangle 函数功能是将字符串反转写回:

其参数来自于之前注册的信号处理函数:

而信号处理函数里面刚好有把 has_pending_input 赋值为 true 的语句,那么这个逻辑就说得通了:子进程的信号被父进程捕获了,这里作为父进程的信号处理函数执行。因为这个是“实时信号”,里面有指针信息能够传递给父进程,这个指针就是反转的地址。所以子进程中抛出信号所带的指针就是需要反转的字符串指针。

handle_sigtrap 函数则是继续完成这道题的关键(也许是题眼罢)

查找 ptrace 的使用方法可以知道,这里是先获得寄存器信息,结构体是 user_regs_struct(https://stackoverflow.com/questions/70334705/how-would-a-debugger-running-in-linux-windows-read-the-pc-register-on-arm32-aa),然后需要用 iovec 包装起来,最后 v12 所在偏移量是 pc。然后读取 pc 所在位置内存,如果前 32 字节是 0xD4200000 则加上一个 delta,并知道下一次读取到的是 0xD4200000 停下来。所以这个玩意就是一个调试器版本的 SMC。

回到 tracee_main 函数,这个函数调用了一个 syscall

memfd_create 是在内存中创建一个虚拟文件的系统调用,这里给它起了个名字并获取 fd。然后写文件,通过 fexecve 执行这个虚拟文件。(有一些函数需要进去出来才能正确显示参数)。所以提取出 payload 出来,可以发现也是一个 elf 文件。

根据上面的分析,找主函数要搜索 brk 0(0xD4200000),应该有两个地方,低地址的那个才是。之后如果符号不好看的话可以先把外层的符号提取出来,做成 sig 文件然后给内层用,这样静态的标准库函数基本都能看了。然后写一个脚本把中间的 SMC 恢复一下,就能看到子进程的主逻辑了。

子进程进去的第一件事是注册了一个信号处理函数,其实没什么用,直接 NOP 掉就行。之后是解压缩 puzzle,大概长这样:

int vb_decode(const uint8_t *input, int input_len, int32_t *output) {
    int out_index = 0;
    int32_t num = 0;
    int shift = 0;
 
    for (int i = 0; i < input_len; i++) {
        uint8_t byte = input[i];
        num |= (byte & 0x7F) << shift;
        if ((byte & 0x80) == 0) {
            output[out_index++] = num;
            num = 0;
            shift = 0;
        } else {
            shift += 7;
        }
    }
    return out_index;
}

是一个解压缩数字的算法,因为数字可能会比较小,不用 32 位也能装得下,所以有压缩的空间。至于装不装得下用最高位来表示。

将子程序弄出来之后,是可以把所有有关 ptrace 的东西全部 patch 掉开始调试弄明白逻辑的。中间一大坨大概就是输入,然后 strchr 确定大括号里面的内容(检查格式),然后又是一大坨复制出来到 v32

这里后面开始就是关键函数了:用 linux_eabi_syscall_w 发送一个实时信号,这里就和父进程交互了(就是那个 secret message,把指针给传进去了)。父进程最开始也注册了一个信号处理函数,里面收到信号会把 has_pending_input 设置成 True,然后从信号里获取到一个指针 pending_ptr(就是输入的东西)。之后子进程自己停下来,父进程收到暂停,在主循环里面把这玩意反转了(mangle 函数),再塞回去原来的位置(PTRACE_POKEDATA

sub_401D00 将 hex 转为 byte

然后和上面一样解压缩出来,并测试是否合法(是不是和已经填入数字冲突了),如果冲突 Alice 就会不耐烦 Ah... can't you play more seriously? 如果没有冲突,则对每个格子调用一次 sub_401BD0 。这里的游戏叫 Fillomino ,规则相同数字的块连在一起称为一个区域,每个区域的块数必须等于每个块的数字,有在线的求解器可以使用。检验逻辑如下:

bool visited[BOARD_SIZE] = {};
 
constexpr bool is_valid(int x, int y) {
    return x >= 0 && x < 9 && y >= 0 && y < 9;
}
 
// DFS 检查连通区域大小
int dfs(int x, int y, int8_t val, const int8_t *b) {
    int count = 1;
    visited[x * 9 + y] = true;
    int dx[4] = {0, 1, 0, -1};
    int dy[4] = {1, 0, -1, 0};
 
    for (int dir = 0; dir < 4; dir++) {
        int nx = x + dx[dir];
        int ny = y + dy[dir];
        if (!is_valid(nx, ny))
            continue;
        int idx = nx * 9 + ny;
        if (!visited[idx] && b[idx] == val) {
            count += dfs(nx, ny, val, b);
        }
    }
    return count;
}
 
// Main validation loop
bool is_correct = true;
for (int i = 0; i < 9; i++) {
    for (int j = 0; j < 9; j++) {
        int idx = i * 9 + j;
        if (input[idx] == 0 || visited[idx])
            continue;
        int8_t val = input[idx];
        int region_size = dfs(i, j, val, input);
        if (region_size != val) {
            is_correct = false;
        }
    }
}

解题脚本如下,最后要记得 md5:

import struct
 
# 答案棋盘,81 个 int8_t
board = [
    3, 6, 6, 6, 6, 6, 6, 2, 2,
    3, 3, 2, 2, 4, 4, 3, 3, 1,
    6, 6, 6, 6, 5, 4, 4, 3, 6,
    6, 2, 2, 6, 5, 6, 6, 6, 6,
    4, 7, 7, 7, 5, 5, 5, 8, 6,
    4, 4, 4, 7, 7, 6, 3, 8, 8,
    5, 6, 6, 6, 7, 6, 3, 3, 8,
    5, 6, 6, 6, 7, 6, 6, 8, 8,
    5, 5, 5, 2, 2, 6, 6, 8, 8
]
 
# 将每 4 个 int8_t 合并为一个 int32_t,然后进行 vb_encode
def vb_encode(num):
    output = []
    while num >= 128 or num < 0:
        output.append((num & 0x7F) | 0x80)
        num >>= 7
    output.append(num & 0x7F)
    return output
 
encoded = []
 
# 如果不能整除4,末尾补零
if len(board) % 4 != 0:
    board += [0] * (4 - (len(board) % 4))
 
for i in range(0, len(board), 4):
    chunk = board[i:i+4]
    num = struct.unpack('<i', bytes(chunk))[0]  # 小端序解为 int32
    encoded.extend(vb_encode(num))
 
# 转换为十六进制字符串 反转
flag = ''.join(f"{byte:02x}" for byte in encoded)[::-1] 
 
# md5 哈希
import hashlib
hash_object = hashlib.md5(flag.encode())
flag = hash_object.hexdigest()
print(flag)