强网杯 2022 Summary

不愧是 PWN 杯,出了 17 道 pwn 题。最近队伍缺 web、re 和 pwn (这可以算是什么都缺了吧喂),我还是照常日我的 misc……

排名 60,没进前 32,差了两道题的程度,一方面可惜,另一方面队员还有待培养……

谍影重重

题目给了一个压缩包,内含一个流量包、一份配置文件和另一个压缩包。题目是一开始就上线了的,所以当时看到那份 config.json 就猜到了这是要做 vmess 的流量解析,于是翻出来了万恶的 vmess 文档,依照文档开始写解析脚本。

用 wireshark 将两份一去一回的流量取出,起了一个 jupyter,开始折腾。

首先解析客户端请求部分,起始 16 字节是认证信息,为一串 HMAC 哈希值,由用户 UUID、范围内随机时间戳计算得到,计算方式为:

hash = HMAC(key: uuid, msg: timestamp, digestmod: md5)

由流量包解析得到发送请求的时间戳,构建 HMAC 哈希函数并进行一次遍历便可得到用于加密的 timestamp 值。后续用于解码指令的 key 和 iv 便是用以上的数据计算得来:

  • cmd_key = md5(uuid + b"c48619fe-8f02-49e0-b9e9-edf763e17e21")
  • cmd_iv = md5(p64(timestamp, endian='big') * 4)

其中字节数组是第一处坑,论我把字符数组作为 UUID 后无论如何都无法解密,卡了两三小时…… cmd_iv 就很可爱了,时间戳转换为 uint64 后 padding 到 16 字节,而后计算得到 md5 值作为 key。

client_id = uuid.UUID("b831381d-6324-4d53-ad4f-8cda48b30811").bytes

md5 = lambda x: hashlib.md5(x).digest()
vmess_hmac = lambda x: hmac.new(client_id, x, hashlib.md5).hexdigest()

req = bytes.fromhex("4dd11f9b04f2b562b9db539d939f1d52" + "b48b35bf592c09b21545392f73f6cef91143786464578c1c361aa72f638cd0135f25343555f509aef6c74cd2a2b86ee0a9eb3b93a81a541def4763cc54f91ba02681add1b815e8c50e028c76bde0ee8a9593db88d901066305a51a9586a9e377ee100e7d4d33fcfc0453c86b1998a95275cd9368a68820c2a6a540b6386c146ea7579cfe87b2e459856772efdcf0e4c6ab0f11d018a15561cf409cbc00491d7f4d22b7c486a76a5f2f25fbef503551a0aeb90ad9dd246a9cc5e0d0c0b751eb7b54b0abbfef198b1c4e5e755077469c318f20f3e418af03540811ab5c1ea780c886ea2c903b458a26")

cut_time = 1615528962
target_hash = req[:16].hex()

def get_cmd_iv(time, target_hash):
    for t in range(time - 50, time + 50):
        cur_hash = vmess_hmac(p64(t))
        if cur_hash == target_hash:
            print(f"time    = {t}")
            return md5(p64(t, endian='big') * 4)

cmd_key = md5(client_id + b'c48619fe-8f02-49e0-b9e9-edf763e17e21')
cmd_iv = get_cmd_iv(cut_time, target_hash)

print(f"cmd_key  = {cmd_key.hex()}")
print(f"cmd_iv   = {cmd_iv.hex()}")

得到:

time     = 1615528982
cmd_key  = "b50d916ac0cec067981af8e5f38a758f"
cmd_iv   = "881eb47d4d3b67b24328c5178c0eedcc"

而后对指令部分进行解码,指令部分使用 AES-128-CFB 进行加密:

from fnvhash import fnv1a_32

cmd_aes = lambda: AES.new(cmd_key, AES.MODE_CFB, cmd_iv, segment_size=128)

cmd = req[16:]
ret = cmd_aes().decrypt(cmd)

print(f"ver   = {ret[0:1].hex()}")
print(f"iv    = {ret[1:17].hex()}")
print(f"key   = {ret[17:33].hex()}")
print(f"v     = {ret[33:34].hex()}")
print(f"opt   = {ret[34]:b}")
print(f"p     = {ret[35:36].hex()[0]}")
print(f"sec   = {ret[35:36].hex()[1]}")
p = int(ret[35:36].hex()[0], 16)
print()
print(f"cmd   = {ret[37:38].hex()}")
print(f"port  = {bytes_to_long(ret[38:40])}")
print(f"type  = {ret[40:41].hex()}") #ipv4
print()
print(f"host  = {'.'.join(str(i) for i in ret[41:45])}")
print(f"rand  = {ret[45:45 + p].hex()}")
print(f"F     = 0x{ret[45 + p:45 + p + 4].hex()}")

print(f"check = {hex(fnv1a_32(ret[:45 + p]))}")


data = cmd[45 + p + 4:]
data_iv = ret[1:17]
data_key = ret[17:33]

得到:

ver     = "01"
dat_iv  = "13277f5732da52ada790d87b8829daa9"
dat_key = "5e4a9aa9ba58c7e3ad36fe2499dca259"
v       = "a2"
opt     = 0b1101
p       = 6
sec     = 3

cmd     = 0x01
port    = 5000
type    = 0x01

host    = 127.0.0.1
rand    = "1ace7d9bb0b5"
F       = 0x39182c03
check   = 0x39182c03

这里遇到了第二个坑,因为文档太久,依然写着 sec = 0x03 时候所对应的加密方法为 ChaCha20-Poly1305 然后实测了很久依然无法解密成功,持续了约半天到一天后翻找代码才看到 sec = 0x03 时候对应的实际加密方法为 AES-128-GCM,才有了下一步的处理。

由于需要 SHA3-shake128 生成随机的 padding 长度和 size 掩码,附带整个解密函数,另外所需的全部函数在 Crypto 包中均有提供:

from Crypto.Hash import SHAKE128

class SizeParser:
    def __init__(self, nonce):
        self.shake = SHAKE128.new(nonce)

    def next(self):
        return bytes_to_long(self.shake.read(2))

    def enc(self, size):
        return self.next() ^ size

    def dec(self, size):
        return self.next() ^ size

    def next_padding(self):
        return self.next() % 64

def decrypt(arr, key, iv):
    count = 0
    parser = SizeParser(iv)
    output = []
    print(f"key     = {key.hex()}")
    print(f"iv      = {iv.hex()}")
    while len(arr) > 0:
        padding = parser.next_padding()
        L = parser.dec(bytes_to_long(arr[:2])) - padding

        arr = arr[2:]
        e_iv = p64(count, endian='big')[6:] + iv[2:12]

        try:
            dec = AES.new(key, AES.MODE_GCM, e_iv).decrypt_and_verify(arr[:L - 16], arr[L - 16:L])
            output.append(dec)
        except:
            print("[!] Decryption failed")
        finally:
            count += 1
            arr = arr[L + padding:]
    return output

解密得到请求信息:

decrypt(data, data_key, data_iv)
key     = 5e4a9aa9ba58c7e3ad36fe2499dca259
iv      = 13277f5732da52ada790d87b8829daa9

GET /out HTTP/1.1
Host: 127.0.0.1:5000
User-Agent: curl/7.75.0
Accept: */*
Connection: close

使用 wireshark 将响应保存为数据文件,以供解码时直接读取。首先解码指令部分,其密钥对为:

  • res_key = md5(data_key)
  • res_iv = md5(data_iv)
res = open('res.bytes','rb').read()

res_key = md5(data_key)
res_iv = md5(data_iv)

print(f"res_key = {res_key.hex()}")
print(f"res_iv  = {res_iv.hex()}")

res_aes = lambda: AES.new(res_key, AES.MODE_CFB, res_iv, segment_size=128)
dec_res = res_aes().decrypt(res[:16])

print(f"v       = {dec_res[0:1].hex()}")
print(f"opt     = {dec_res[1:2].hex()}")
print(f"cmd     = {dec_res[2:3].hex()}")
print(f"c_l     = {dec_res[3:4].hex()}")
cmd_len = int(dec_res[3:4].hex(), 16)
print(f"cmd     = {dec_res[4:4+cmd_len].hex()}")

data = res[4 + cmd_len:]

plaintext = decrypt(data, res_key, res_iv)
res_key = b22984cda4143a919b5b6de8121b6159
res_iv  = fa2a8ab0fadb4854943df690335a99b5
v       = 0xa2
opt     = 0x00
cmd     = 0x00
c_l     = 0x00
cmd     = ""

解密所得文件为一个 html 文件,其中以 base64 编码存放有一份宏病毒。因此这里取出其内容,实测电脑中的杀毒软件对此病毒十分敏感,一旦落入文件系统文件立刻会被损坏,最后为了查看内容直接存储为 zip 文件解压后查看。

from base64 import b64decode

data = "".join(i.decode() for i in plaintext)

start = data.find("atob('") + len("atob('")
end = data.find("');", start)

binary = b64decode(data[start:end])
check_sum = hashlib.sha256(binary).hexdigest()

open("doc.zip", "wb").write(binary)

得到此病毒的 sha256: 3a5648f7de99c4f87331c36983fc8adcd667743569a19c8dafdd5e8a33de154d

PS: 其实到这一步就已经可以把病毒查出来了,但是这里又去解包看了看内部的数据,最后使用其子 dll 部分的哈希值同样有效。

使用 foremost 工具抽出 _Ole10Native 文件中的 dll,并检查其哈希值:

$ sha256sum 00000000.dll
0d7aa23a72d22dcf47f8723c58d101b3b113cbc79dd407a6fac0e65d67076ea1  00000000.dll

搜索得到:0d7aa23a72d22dcf47f8723c58d101b3b113cbc79dd407a6fac0e65d67076ea1 | ANY.RUN - Free Malware Sandbox Online

在其行为分析中:

PID: 3528
Process: rundll32.exe
Method: GET
HTTP Code: 200
IP: 3.220.57.224:80
URL: http://api.ipify.org/
CN: US

于是使用 md5(b"api.ipify.org") = "08229f4052dde89671134f1784bed2d6" 作为解压密码,得到压缩包内文件。

Gob 文件可以使用 degob - Go library/tool for viewing and reversing Go gob data [Moved to GitLab] 进行反序列化。

编译后运行得到结果为三个字节数组,其中最长的为一个被打乱的 PNG 文件,结合所给时间和语言,推测是 Go 语言的 shuffle。

对于被打乱的字节数组,恢复其只需要记录一个序列数组被打乱后的值就可以进行反推,于是借助 copilot 写了段 Go:

package main

import (
	"fmt"
	"math/rand"
	"time"
	"os"
)

func main() {
	index: = make([]int, 70475)
	for i: = 0; i < 70475; i++ {
		index[i] = i
	}
	t: = time.Date(2022, 7, 19, 14 - 8, 49, 56, 0, time.UTC)
	rand.Seed(t.Unix())
	rand.Shuffle(len(index), func(i, j int) {
		index[i], index[j] = index[j], index[i]
	})
	f, err: = os.OpenFile("flag.index", os.O_CREATE, 0666)
	if err != nil {
		fmt.Println(err)
		return
	}
	for _, i: = range index {
		fmt.Fprintf(f, "%d,", i)
	}
}

而后进行逆向恢复处理:

png_data = bytes(eval(open("flag.bs", "r").read()))
shuffled_idx = eval(f"[{open('flag.index','r').read()}]")

res = [0 for _ in range(len(png_data))]

for i in range(len(png_data)):
    res[shuffled_idx[i]] = png_data[i]

ans = bytes(res)
open("flag.png", "wb").write(ans)

图片这里就不放了,使用多方工具检查,只觉得似乎透明度部分有些可疑,抱着试一试的心态提取了像素:

from PIL import Image
import numpy as np

img = Image.open("flag.png")
arr = np.array(img)

ans = bytes(arr[:,:,3].reshape(2000 * 973)).replace(b'\xff',b'').replace(b'\x00',b'')
print(ans)

得到:

b'flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}flag{898161df-fabf-4757-82b6-ffe407c69475}...

也即最后的 flag 为:

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

后续:据说是 r3 队里的空白出的题,等有机会一定和他认识认识.jpg

more?

基本就是这样了,misc 手也就做了个 misc 题,最近忙着写新的 CTF 比赛平台,进度一直在推。不过这两年基本上没钻什么特别的方向,不过毕竟玩 CTF 也是为了玩嘛……希望校赛能够顺利举办,让我认识更多志同道合的朋友。

先这样了,面对缺少逆向和web手的现状还是要操心怎么培养培养。