【长三角WP】长三角网络安全攻防大赛初赛WP(Misc&Crypto&数据安全)

Misc

git_echo

从完整 .git 找回不可达提交

当前 HEAD 位于 refs/heads/main,可见提交是 89316b3。先扫描对象库:

Plain 复制代码
git -C project_backup status --short --branch
git -C project_backup show-ref
git -C project_backup fsck --full --no-reflogs --unreachable --no-progress

fsck 报出三个 dangling commit 以及三个对应的 banner blob:

Plain 复制代码
2bfddca0a56f7f5f6aaac60b06ac58b41de6d004
b31a305c11f473991a7c326e8bd0e769e384cb4f
be7aaf7e82a116eacb436b14835541dfe9fd5778

沿 parent 指针读取历史:

Plain 复制代码
git -C project_backup log --oneline --reverse be7aaf7

得到:

Plain 复制代码
89316b3 initial production snapshot
2bfddca automated banner render 1
b31a305 automated banner render 2
be7aaf7 automated banner render 3
提交 父提交 作用
89316b336e58ddc69d8179c4b602891615d51054 --- 当前 production snapshot
2bfddca0a56f7f5f6aaac60b06ac58b41de6d004 89316b3 automated banner render 1
b31a305c11f473991a7c326e8bd0e769e384cb4f 2bfddca automated banner render 2
be7aaf7e82a116eacb436b14835541dfe9fd5778 b31a305 automated banner render 3

用 ls-tree 检查三棵树可见的文件,确认后续提交只替换 banner.png:

Plain 复制代码
git -C project_backup ls-tree -r 2bfddca
git -C project_backup ls-tree -r b31a305
git -C project_backup ls-tree -r be7aaf7

以二进制方式导出,避免 PowerShell 管道对 PNG 字节做编码转换:

Plain 复制代码
git --git-dir=project_backup/.git show 2bfddca:banner.png > render1.png
git --git-dir=project_backup/.git show b31a305:banner.png > render2.png
git --git-dir=project_backup/.git show be7aaf7:banner.png > render3.png
版本 Blob SHA-1 大小 属性
render 1 b41e22bbce73d5214f28363932b2418def0be767 126,572 B 720×720 RGB;通道值仅 0/255
render 2 383ab1ff7a7936407ee08a162fdeaa21ec367e7b 126,576 B 720×720 RGB;通道值仅 0/255
render 3 1c068d05031d3943cbc95853c8ac4aa5c21d33ff 126,687 B 720×720 RGB;通道值仅 0/255

每个 PNG 的结构只有 IHDR / IDAT / IEND,没有 tEXt/iTXt/zTXt 或尾随数据;单独查看只能看到二值噪声,因此转向跨版本像素重组。

三通道异或复原二维码

按历史顺序测试颜色通道组合,唯一能形成完整二维码定位角并被解码器识别的组合是:

  • render 1 的 R 通道

  • render 2 的 G 通道

  • render 3 的 B 通道

核心公式:

Plain 复制代码
raw_xor = r1[:, :, 0] ^ r2[:, :, 1] ^ r3[:, :, 2]
qr = 255 - raw_xor

先 XOR 得到"白色模块 / 黑色背景",再反相为标准"黑色模块 / 白色背景"。二维码有效区域约为 (64,64)--(655,655),即 37×37 modules、每个 module 16 px,外侧保留 quiet zone。

QR 解码

Plain 复制代码
flag{unreachable_does_not_mean_deleted}

完整EXP

Python 复制代码
from __future__ import annotations

import argparse
import io
import subprocess
from pathlib import Path

import cv2
import numpy as np
from PIL import Image

COMMITS = (
    "2bfddca0a56f7f5f6aaac60b06ac58b41de6d004",
    "b31a305c11f473991a7c326e8bd0e769e384cb4f",
    "be7aaf7e82a116eacb436b14835541dfe9fd5778",
)

def read_banner(repo: Path, commit: str) -> np.ndarray:
    raw = subprocess.check_output(
        ["git", "-C", str(repo), "show", f"{commit}:banner.png"]
    )
    image = np.array(Image.open(io.BytesIO(raw)).convert("RGB"))
    if image.shape != (720, 720, 3):
        raise ValueError(f"unexpected image shape: {image.shape}")
    if not np.all(np.isin(image, (0, 255))):
        raise ValueError("banner is not binary-valued")
    return image

def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("repo", type=Path, help="extracted project_backup directory")
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("reconstructed_qr.png"),
        help="where to save the reconstructed QR (default: reconstructed_qr.png)",
    )
    args = parser.parse_args()

    r1, r2, r3 = [read_banner(args.repo, commit) for commit in COMMITS]
    raw_xor = r1[:, :, 0] ^ r2[:, :, 1] ^ r3[:, :, 2]
    qr = 255 - raw_xor
    Image.fromarray(qr, mode="L").save(args.output)

    text, points, _ = cv2.QRCodeDetector().detectAndDecode(qr)
    if not text or points is None:
        raise RuntimeError("QR decoder did not return a payload")
    corners = [tuple(map(int, point)) for point in points.reshape(-1, 2)]
    print(f"decoded = {text!r}")
    print(f"shape = {qr.shape}, unique = {np.unique(qr).tolist()}")
    print(f"corner points = {corners}")
    print(f"saved = {args.output.resolve()}")

if __name__ == "__main__":
    main()

Crypto

byte_257

加密算法分析

task.py 的核心逻辑如下:

Python 复制代码
MODULUS = 257

def encrypt(data: bytes, a: int, b: int, state: int) -> bytes:
    encrypted = []
    for value in data:
        encrypted.append((value + state) % MODULUS)
        state = (a * state + b) % MODULUS
    return b"".join(struct.pack("<H", value) for value in encrypted)

对第 i 个明文字节 p_i,加密状态记为 s_i,则:

ci=(pi+si) mod 257c_i=(p_i+s_i)\bmod 257ci=(pi+si)mod257

si+1=(asi+b) mod 257s_{i+1}=(a s_i+b)\bmod 257si+1=(asi+b)mod257

注意输出使用小端 uint16 保存,因此 encrypted_image.bin 的 14590 字节对应 7295 个密文数值,而不是 14590 个明文字节。

密文中确实出现了数值 256(共 31 次);这也正好对应提示语 "A byte can count past 255",说明不能把文件直接按普通单字节流读取。

利用 PNG 文件头恢复状态

PNG 的固定文件头为:

Plain 复制代码
明文 p: 89 50 4E 47 0D 0A 1A 0A

密文前 8 个小端 uint16 值为:

Plain 复制代码
c: 165 80 219 172 242 35 87 110

由 s_i = (c_i - p_i) mod 257 得到前 8 个状态:

Plain 复制代码
s: 28 0 141 101 229 25 61 100

逐项对应关系如下(密文列是按 <H 解包后的数值):

i p_i****(HEX) c_i s_i=(c_i-p_i) mod 257
0 89 165 28
1 50 80 0
2 4E 219 141
3 47 172 101
4 0D 242 229
5 0A 35 25
6 1A 87 61
7 0A 110 100

只使用前三个状态即可解出 LCG 参数:

Plain 复制代码
s1 = a*s0 + b = 0       (mod 257)
s2 = a*s1 + b = b = 141 (mod 257)

所以:

Plain 复制代码
b = 141
28*a + 141 = 0 (mod 257)
28*a = 116 (mod 257)

因为 28⁻¹ = 101 (mod 257),得到:

Plain 复制代码
a = 116*101 mod 257 = 151
b = 141
s0 = 28

用参数递推得到的状态序列前几项为:

Plain 复制代码
28, 0, 141, 101, 229, 25, 61, 100, ...

与 PNG 文件头计算出的状态完全一致。

完整EXP

逆运算为:

pi=(ci−si) mod 257p_i=(c_i-s_i)\bmod 257pi=(ci−si)mod257

Python 复制代码
import struct
import sys
import binascii
import zlib
from pathlib import Path

MODULUS = 257
PNG_HEADER = b"\x89PNG\r\n\x1a\n"


def recover(input_path: Path, output_path: Path) -> None:
    raw = input_path.read_bytes()
    if len(raw) % 2:
        raise ValueError("密文长度不是 2 的倍数")

    values = [v for (v,) in struct.iter_unpack("<H", raw)]

    # 由已知 PNG 头自动求出前几个状态,再解 LCG 参数。
    first_states = [
        (values[i] - PNG_HEADER[i]) % MODULUS
        for i in range(len(PNG_HEADER))
    ]
    s0, s1, s2 = first_states[:3]
    delta = (s1 - s0) % MODULUS
    a = ((s2 - s1) * pow(delta, -1, MODULUS)) % MODULUS
    b = (s1 - a * s0) % MODULUS

    # 用已知头校验求出的参数。
    check = s0
    for expected in first_states:
        assert check == expected
        check = (a * check + b) % MODULUS

    plain = bytearray()
    state = first_states[0]
    for value in values:
        decoded = (value - state) % MODULUS
        if decoded > 255:
            raise ValueError("解密结果不是合法字节")
        plain.append(decoded)
        state = (a * state + b) % MODULUS

    if bytes(plain[:8]) != PNG_HEADER:
        raise ValueError("PNG 头校验失败")

    # 解析 PNG 块并校验 CRC/IDAT,避免只凭文件头误判。
    pos = 8
    chunks = []
    idat = bytearray()
    ihdr = None
    while pos < len(plain):
        if pos + 12 > len(plain):
            raise ValueError("PNG 块头截断")
        size = struct.unpack(">I", plain[pos:pos + 4])[0]
        kind = bytes(plain[pos + 4:pos + 8])
        end = pos + 12 + size
        if end > len(plain):
            raise ValueError("PNG 块数据截断")
        payload = bytes(plain[pos + 8:pos + 8 + size])
        expected_crc = struct.unpack(">I", plain[pos + 8 + size:end])[0]
        actual_crc = binascii.crc32(kind + payload) & 0xffffffff
        if expected_crc != actual_crc:
            raise ValueError(f"PNG CRC 错误: {kind!r}")
        chunks.append((kind, size))
        if kind == b"IHDR":
            ihdr = payload
        elif kind == b"IDAT":
            idat.extend(payload)
        pos = end
        if kind == b"IEND":
            break
    if pos != len(plain) or chunks[-1][0] != b"IEND":
        raise ValueError("PNG 未正常结束")
    if ihdr is None or len(ihdr) != 13:
        raise ValueError("缺少合法 IHDR")
    zlib.decompress(bytes(idat))

    output_path.write_bytes(plain)
    print(f"a={a}, b={b}, s0={first_states[0]}")
    print(f"recovered {len(plain)} bytes -> {output_path}")
    print("PNG chunks:", [(k.decode('ascii'), n) for k, n in chunks], "CRC OK")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        raise SystemExit(f"用法: {sys.argv[0]} encrypted_image.bin decrypted.png")
    recover(Path(sys.argv[1]), Path(sys.argv[2]))

本题实际解密时得到:

Plain 复制代码
a=151, b=141, s0=28
recovered 7295 bytes

打开恢复的截图后,中央文字为:

Plain 复制代码
flag{f257_known_header_breaks_stream}

wrapmac

算法分析

test.py 中的消息打包函数为:

Python 复制代码
def pack_record(record):
    return ((record["device"] << 56) |
            (record["command"] << 48) |
            (record["value"] << 16) |
            record["counter"])

因此 64 位消息的布局是:

Plain 复制代码
63          56 55        48 47                   16 15          0
+-------------+------------+-----------------------+--------------+
|   device    |  command   |        value          |   counter    |
+-------------+------------+-----------------------+--------------+

标签算法非常简单:

Plain 复制代码
tag = (a * message + b) & ((1 << 64) - 1)

即

t≡am+b(mod264).t \equiv a m+b \pmod {2^{64}}.t≡am+b(mod264).

其中 a 满足 1 ≤ a < 2^44(至多 44 位),b 是 64 位整数。flag 的生成规则是:将目标消息和目标标签分别按大端序打包为 8 字节,拼接后做 SHA-256,取摘要前 32 个十六进制字符。

解析公开记录

按 pack_record 重组得到以下消息整数(所有十六进制数均补足为 64 位):

编号 device command value counter **message **m **tag **t
0 126 142 748140490 20515 0x7e8e2c97b7ca5023 0xf84627af4c3598c2
1 93 44 1876572954 20515 0x5d2c6fda3b1a5023 0xa5539b3490d598c2
2 205 58 2828843097 1808 0xcd3aa89cb8590710 0xc611b4e1f1430f7c

目标记录为:

Plain 复制代码
device  = 192
command = 218
value   = 2425955337
counter = 63960

利用差分恢复 a

对前两条合法记录作差,常数项 b 被消掉:

Δt≡aΔm(mod264).\Delta t \equiv a\Delta m \pmod {2^{64}}.Δt≡aΔm(mod264).

注意差分必须按无符号模 2^64 计算:

Plain 复制代码
Δm = (m1 - m0) mod 2^64
   = 0xde9e434283500000
   = 0xde9e4342835 * 2^20

Δt = (t1 - t0) mod 2^64
   = 0xad0d738544a00000
   = 0xad0d738544a * 2^20

生成器明确构造了一个带 20 个尾零位的消息差,其中 0xde9e4342835 是奇数。因为两边都含有 2^20,可将方程约去这一因子,但模数同时降为 2^(64-20)=2^44:

更严格地说,2^64 | 2^20 (a·d-r) 等价于 2^44 | (a·d-r),所以这里是在模 2^44 下求逆,而不是试图在模 2^64 下求一个偶数的逆。

a⋅0xde9e4342835≡0xad0d738544a(mod244).a\cdot\texttt{0xde9e4342835} \equiv \texttt{0xad0d738544a} \pmod {2^{44}}.a⋅0xde9e4342835≡0xad0d738544a(mod244).

奇数在模 2^44 下可逆,计算得到:

Plain 复制代码
inv(0xde9e4342835, 2^44) = 0x9c944dbea1d
a = 0xa0fa1bb3062

该结果落在题目规定的 44 位范围内,因此解是唯一的;若没有这个约束,方程只会确定 a mod 2^44,还可能存在 a+ k·2^44 的 64 位提升。这里不能直接在模 2^64 下把 Δm 求逆,因为它是偶数;先约去 2^20 正是利用题目故意留下的结构。

恢复 b

代回第一条记录:

b≡t0−am0(mod264),b \equiv t_0-a m_0 \pmod {2^{64}},b≡t0−am0(mod264),

得到:

Plain 复制代码
b = 0x341715e9752b5b5c

用恢复出的参数重新计算三条记录:

Plain 复制代码
record 0: 0xf84627af4c3598c2  (match)
record 1: 0xa5539b3490d598c2  (match)
record 2: 0xc611b4e1f1430f7c  (match)

第三条记录并非求解所必需,但可以用来确认参数和模运算均正确。

伪造目标标签

将目标字段按同样的位布局打包:

Plain 复制代码
m_target = 0xc0da90992409f9d8

计算:

Plain 复制代码
t_target = (a * m_target + b) mod 2^64
         = 0x72bed45d789d800c

计算 flag

submission_flag 使用大端序,而不是把十六进制文本直接作为输入:

Plain 复制代码
message = struct.pack(">Q", m_target)
tag = struct.pack(">Q", t_target)
digest = hashlib.sha256(message + tag).hexdigest()

实际拼接的 16 字节为:

Plain 复制代码
c0da90992409f9d872bed45d789d800c

SHA-256 完整摘要为:

Plain 复制代码
ecac2529f5bcf5960478d73d4dc7df86923a64002187dc04b0f516b921f6350b

取前 32 个十六进制字符,得到最终 flag:

Plain 复制代码
flag{ecac2529f5bcf5960478d73d4dc7df86}

完整EXP

Python 复制代码
import hashlib
import json
import struct
from pathlib import Path

MASK = (1 << 64) - 1
SHIFT = 20
AMOD = 1 << 44

def pack_record(r):
    return ((r["device"] << 56) | (r["command"] << 48) |
            (r["value"] << 16) | r["counter"])

data = json.loads(Path("records.json").read_text())
items = data["records"]
m = [pack_record(x["record"]) for x in items]
t = [int(x["tag"], 16) for x in items]

dm = (m[1] - m[0]) & MASK
dt = (t[1] - t[0]) & MASK
assert dm % (1 << SHIFT) == 0
assert dt % (1 << SHIFT) == 0
q = dm >> SHIFT
r = dt >> SHIFT
assert q & 1                 # q 在模 2^44 下可逆
a = (r * pow(q, -1, AMOD)) % AMOD
b = (t[0] - a * m[0]) & MASK

assert all(((a * mi + b) & MASK) == ti for mi, ti in zip(m, t))

mt = pack_record(data["target"])
tt = (a * mt + b) & MASK
raw = struct.pack(">Q", mt) + struct.pack(">Q", tt)
flag = "flag{" + hashlib.sha256(raw).hexdigest()[:32] + "}"

print(f"a = {a:#x}")
print(f"b = {b:#x}")
print(f"target tag = {tt:016x}")
print(flag)

数据安全

shadow_route

提取目标公开打卡

读取 public_posts.json 的 target_handle,得到 blue_fox。只保留 handle == target_handle 的记录,排除所有 guest_* 打卡,并按 UTC 时间排序:

# published_at****(UTC) 公开地标 landmarks.json ** 映射的 **cell 文本
1 2026-07-21T09:05:00Z North Library G17-04 nice view
2 2026-07-23T13:10:00Z Riverside Cafe G22-11 arrived
3 2026-07-26T16:45:00Z West Stadium G05-19 nice view
4 2026-07-29T11:00:00Z Innovation Hall G31-08 quick stop

时间直接按附件中的 UTC ISO 字符串比较,不做本地时区转换;这正好对应 mobility.csv 的 start_bucket_utc。

地标映射

完整映射如下(本题实际用到前四项):

公开地标 粗化 cell
North Library G17-04
Riverside Cafe G22-11
West Stadium G05-19
Innovation Hall G31-08
Central Station G14-15
Art Center G27-02

mobility 关联方法

对每个公开观测点建立严格候选集合:

Plain 复制代码
start_bucket_utc == published_at
AND
origin_cell == landmarks[landmark]

公开打卡发生在地标处,因此将地标网格与行程的 origin_cell 对齐。单条观测可能命中多个匿名 token,最后必须对四个集合求交集。

完整EXP
Python 复制代码
import base64, csv, json
from pathlib import Path

p = Path(".")
landmarks = json.loads((p / "landmarks.json").read_text(encoding="utf-8"))
data = json.loads((p / "public_posts.json").read_text(encoding="utf-8"))

posts = sorted(
    (x for x in data["posts"] if x["handle"] == data["target_handle"]),
    key=lambda x: x["published_at"],
)

with (p / "mobility.csv").open(newline="", encoding="utf-8") as f:
    trips = list(csv.DictReader(f))

sets = []
for post in posts:
    cell = landmarks[post["landmark"]]
    s = {
        r["anonymous_token"]
        for r in trips
        if r["start_bucket_utc"] == post["published_at"]
        and r["origin_cell"] == cell
    }
    sets.append(s)
    print(post["published_at"], post["landmark"], cell, len(s), sorted(s))

common = set.intersection(*sets)
assert len(common) == 1
token = next(iter(common))

with (p / "support_tickets.csv").open(newline="", encoding="utf-8") as f:
    tickets = list(csv.DictReader(f))

ticket = next(r for r in tickets if r["anonymous_token"] == token)
flag = base64.b64decode(ticket["attachment_base64"]).decode("utf-8")
print(token, ticket["ticket_id"], flag)
候选集合与唯一性交集
公开观测 origin_cell 候选数 候选 token
2026-07-21 09:05Z / North Library G17-04 2 u_7fa56c8eb4519638、u_d38469e66bb02624
2026-07-23 13:10Z / Riverside Cafe G22-11 3 u_7015a96e1582ebc6、u_9b4e6ea36c0969dc、u_d38469e66bb02624
2026-07-26 16:45Z / West Stadium G05-19 1 u_d38469e66bb02624
2026-07-29 11:00Z / Innovation Hall G31-08 5 u_489861491414025c、u_7faced49baf3938a、u_c3bb67b085e5a00c、u_d38469e66bb02624、u_fb2f62cb15945f67

因此:

Plain 复制代码
S1 ∩ S2 ∩ S3 ∩ S4
= {u_d38469e66bb02624}

四个集合的交集基数为 1,唯一 token 为:

Plain 复制代码
u_d38469e66bb02624
关键 mobility 命中行

用唯一 token 回看 mobility.csv,四条公开打卡对应的关键行如下;该 token 在整个文件中共有 19 条行程,其余为噪声。

start_bucket_utc origin_cell destination_cell anonymous_token
2026-07-21T09:05:00Z G17-04 G32-06 u_d38469e66bb02624
2026-07-23T13:10:00Z G22-11 G09-16 u_d38469e66bb02624
2026-07-26T16:45:00Z G05-19 G35-17 u_d38469e66bb02624
2026-07-29T11:00:00Z G31-08 G24-13 u_d38469e66bb02624

工单定位与 Base64 解码

在 support_tickets.csv 中按 token 精确查找,命中且仅命中一行:

anonymous_token ticket_id attachment_base64
u_d38469e66bb02624 T-100317 ZmxhZ3syMDlmNjYzNDU1NDI5Zjc0MjAxMjE0NzkzMjMzNGQ0M30=

解码输出:

Plain 复制代码
flag{209f663455429f742012147932334d43}
相关推荐
xixiaoyunya2 小时前
局域网多电脑统一备份:用 80KM 搭建中小企业内网集中备份体系
安全·电脑·备份
微软技术分享12 小时前
面向二进制安全的大模型多智能体框架
网络·安全·web安全
学逆向的19 小时前
win32远程线程
网络安全·api·win32
Nebula_g19 小时前
JavaSE拓展:可变参数
java·开发语言·算法·安全·javase·可变参数
在角落发呆19 小时前
软件配置备份方法:从手动到自动化的实践指南
windows·安全
pencek20 小时前
MazeSec-113
网络安全
YonyouHRSaaS20 小时前
从安全、信创、运维三方面看,人力资源管理系统哪种部署方式更适合企业HR?
运维·安全·hr系统·人力资源管理系统·hr saas·人事管理系统
白猫不黑1 天前
网络安全专业:从入门到进阶的完整学习路线
学习·安全·web安全·计算机·网络安全·信息安全·编程
菩提小狗1 天前
每日安全情报报告 · 2026-10-07
网络安全·漏洞·cve·安全情报·每日安全