TrendMicro CTF 2015 : Poison Ivy (Defense 300) write-up

TrendMicro CTF logo

The challenge

This challenge was one of the 25 (minus a few canceled ones) written and organized by TrendMicro for their TMCTF 2015. I played with the Swiss team “On est pas contents” and I won’t disclose how badly we ranked ūüôā Some challenges were really boring (a crossword where half the solutions come from the commercial product aisle? Not for me). Some were frustrating, and one was really great: Poison Ivy network capture.

TrendMicro was very fast in shutting down the whole CTF website, so I can’t get an hand on the original challenge text. From memory:

A hacker was caught using Poison Ivy on a real system. Please understand what he was doing to get the flag. (ps: password is admin).

With that exciting information I start downloading the pcap. Opening in wireshark, it appears it’s a single TCP connection on the 443 port. This doesn’t look like https and the wireshark dissector doesn’t want to parse it. Right click on a packet, “Decode as…” and check “do not decode” makes us see the raw exchange.

tmctf_wireshark1

Analyzing the exchange

A quick glance in the packets… and everything looks encrypted. A few google queries reveal no public wireshark dissector for Poison Ivy and no standalone dissector either [PS: I need to improve my google skills, see comments]. Let’s find out how it works !

A very nice paper from Adrien Chevalier and Robison Delaugerre from Conix Security describes the Poison Ivy protocol. Starts with the handshake:

The RAT generates 0x100 pseudo-random bytes and sends them to the C&C. Both of them
encrypt the blob with their Camellia key, and the C&C sends back the encrypted blob to the RAT.
The RAT verifies the response is valid and begins communication.

Looks like a great place to crack the password. We can see that the first two packets exchange 256 bytes, so it matches the protocol description. We need to find a camellia library for python, since that’s what poison Ivy is using. I found python-camellia through “pip install python-camellia”

#!/usr/bin/env python
#https://github.com/aris_ada/libctf
from libctf import *
import camellia
def crack():
    cleartext=open("cleartext").read()[:16]
    ciphertext=open("camellia").read()[:16]
    wordlist=open("/home/aris/wordlists/uniq.txt")

    print "Cleartext:"
    hexdump(cleartext)
    print "Ciphertext:"
    hexdump(ciphertext)

    for w in wordlist.readlines():
        w = w.replace("\n","").replace("\r","")
        w = w[:32]
        w = w + "\x00" * (32 - len(w))
        c = camellia.CamelliaCipher(key=w, mode=camellia.MODE_ECB)
        encrypted = c.encrypt(cleartext)
        if (encrypted == ciphertext):
            print "Found key !",repr(w)
crack()

A few minutes of cracking… and the password appears, it’s “admin”. I’m checking the question again and realize they gave it as an hint. I don’t know why they did this, cracking the password was the easiest task of this challenge.

Payloads

The next step involves sending two payloads to the RAT server. These payloads are encrypted and come with a very simple cleartext header that simply contains the payload size. The first payload size is always 0x15d4 so that’s a nice way of checking if we’re good. I solved the problem of feeding the pcap data by using wireshark “follow TCP stream” function and exporting to a file. This is a synchronous protocol (I really hoped so :p), and both client & server messages use the same packet format, thus this approach would work.

tmctf_wireshark2Let’s write a parser for the two headers + extract the payloads. I did decrypt them to be able to quickly peek a look, just in case the flag was in one of them.

from libctf import *
import camellia
from struct import unpack
key = "admin" + "\x00" * (32 - 5)
c = camellia.CamelliaCipher(key=key, mode=camellia.MODE_ECB)
stream = open("stream")
#bypass handshake
stream.read(512)
def print_payload(name):
    size = unpack("<I", stream.read(4))[0]
    print "size: %d %x"%(size, size)
    data = stream.read(size)
    padding = (16-(len(data)%16) % 16)
    data += "\x00" * padding
    data = c.decrypt(data)
    print name
    hexdump(data,highlight="\x00")
print_payload("payload 1")
print "unknown data 1"
hexdump(stream.read(4))
print_payload("payload 2")

The unknown data are 4 bytes (apparently not encrypted) that I couldn’t figure what they did. We’re now working on the headers and actual packet protocol.

Packet protocol

Again, the document explains quite well what we should expect in the protocol. Next follows a 32 bytes (encrypted) header giving more information about the next packet: size, type, compression and complete packet size. From now, everything is encrypted.

typedef struct _header
{
DWORD command_id;
DWORD stream_id;
DWORD dwDataLen;
DWORD dwRealDataLen;
DWORD dwUncompressedDataLen;
DWORD dwTotalStreamSize;
DWORD padding1;
DWORD padding2;
}header;

Command_id is the type of command. We’ll guess what happens on the RAT from there. DataLen is the size of the packet (excluding header) after encryption. RealDataLen is the size of the packet after decryption (without padding) but with compression. UncompressedDataLen is the size of the full packet, and totalStreamSize is the size of a full stream (may concatenate several packets). Compression? Let’s hope there’s none… it’s zlib right?

def unpack_multiple(data):
	data = list(chunkstring(data, 4))
	return map(lambda x: unpack("<I", x)[0], data)
def decode_header(name):
	print name
	data = stream.read(0x20)
	header = c.decrypt(data)
	hexdump(header) 
	cmd,id,datalen,realdatalen,uncompressedlen,totalstreamsize,padding1,padding2 =\
                unpack_multiple(header) 
	print "cmd:",hex(cmd),"id:",id,"len:",datalen,realdatalen,uncompressedlen,"total:", \
		totalstreamsize, padding1,padding2 
	if(uncompressedlen > realdatalen):
		print "compressed"
	data = stream.read(datalen)
	data = c.decrypt(data)
	hexdump(data)
	
for i in xrange(226):
	decode_header("header" + str(i))

We can now see a few packets going, we recognize a few words (like Administrator”) and we see many (too many) “compressed” messages. We can also recognize many packets of the 0x19 cmd type, that seem to all be part of a big (810054 bytes) stream:

header211
00000000: 19 00 00 00 01 00 00 00  70 0b 00 00 65 0b 00 00  ........p...e...
00000010: fc 0f 00 00 46 5c 0c 00  00 00 00 00 00 00 00 00  ....F\..........
cmd: 0x19 id: 1 len: 2928 2917 4092 total: 810054 0 0
compressed
header212
00000000: 19 00 00 00 01 00 00 00  60 0b 00 00 54 0b 00 00  ........`...T...
00000010: fc 0f 00 00 46 5c 0c 00  00 00 00 00 00 00 00 00  ....F\..........
cmd: 0x19 id: 1 len: 2912 2900 4092 total: 810054 0 0
compressed
header213
00000000: 19 00 00 00 01 00 00 00  c0 0b 00 00 b1 0b 00 00  ................
00000010: fc 0f 00 00 46 5c 0c 00  00 00 00 00 00 00 00 00  ....F\..........
cmd: 0x19 id: 1 len: 3008 2993 4092 total: 810054 0 0
compressed

 Decompressing the packets

Our hopes of simply using zlib vanish when reading the report, again:

Depending on header information, data can be compressed.
The compression is performed using the RtlCompressBuffer & RtlDecompressBuffer
functions using the COMPRESSION_FORMAT_LZNT1 algorithm identifier.

LZNT1 is not a very popular compression algorithm. Google lead me to the LaZy_NT package, but unfortunately I couldn’t make sense of the source code and reuse the LZNT1 decompression internals. Searching for “RtlDecompressBuffer” lead me to this interesting twitter discussion in which it was advised to use parts of the chopshop framework. That’s what I did and it worked nicely:

#https://github.com/MITRECND/chopshop/blob/master/ext_libs/lznt1.py
import lznt1
def decode_header(name):
    print name
    data = stream.read(0x20)
    header = c.decrypt(data)
    hexdump(header)
    cmd,id,datalen,realdatalen,uncompressedlen,totalstreamsize,padding1,padding2 = unpack_multiple(header)
    print "cmd:",hex(cmd),"id:",id,"len:",datalen,realdatalen,uncompressedlen,"total:", \
        totalstreamsize, padding1,padding2
    if(uncompressedlen > realdatalen):
        print "compressed"
    data = stream.read(datalen)
    data = c.decrypt(data)
    if(uncompressedlen > realdatalen):
        data = lznt1.dCompressBuf(data[:realdatalen])
    #hexdump(data)
    if(cmd == 0x19):
        return data
    else:
        return ""

The decompression made it more obvious the type of content in packets, but still no trace of a cleartext flag. We have to dig further. The pdf enumerates the different command types, and we learn that 0x19 is the “take screenshot” command.

Extract the screenshot.

Using the code above, we extract all packets having cmd=0x19 in a file:

img = ""
for i in xrange(226):
    img += decode_header("header" + str(i))
open("img.bmp","w").write(img)

Unfortunately the output file is not recognized by file and is too big:

$ ls -l img.bmp 
-rw-rw-r-- 1 aris aris 810511 sep 27 20:55 img.bmp

We expected a 810054 bytes file. The 457 bytes difference can be explained there:

header25
00000000: 19 00 00 00 01 00 00 00  70 01 00 00 6f 01 00 00  ........p...o...
00000010: c9 01 00 00 c9 01 00 00  00 00 00 00 00 00 00 00  ................
cmd: 0x19 id: 1 len: 368 367 457 total: 457 0 0
compressed
header26
00000000: 19 00 00 00 01 00 00 00  a0 01 00 00 9c 01 00 00  ................
00000010: fc 0f 00 00 46 5c 0c 00  00 00 00 00 00 00 00 00  ....F\..........
cmd: 0x19 id: 1 len: 416 412 4092 total: 810054 0 0
compressed
header27
00000000: 19 00 00 00 01 00 00 00  50 02 00 00 41 02 00 00  ........P...A...
00000010: fc 0f 00 00 46 5c 0c 00  00 00 00 00 00 00 00 00  ....F\..........
cmd: 0x19 id: 1 len: 592 577 4092 total: 810054 0 0

We forgot to remove the first stream. After this simple patch:

open("img.bmp","w").write(img[457:])

We get this picture (converted to jpg so save precious bandwidth):

imgMay_Flower is the flag. Full code here.

Conclusion & lessons learned

This challenge was interesting because little guessing was required, still actual work to decrypt, parse and decompress the protocol was needed. I’m just a little disappointed that the password was simply on the screenshot (I first expected the sample to be genuine, and having to find the password in the keylogger traces).

I was lucky that the protocol was synchronous and symmetrical, otherwise I would have lost a lot of time to follow the client/server traffic switch and mixed data.

The Buzz {1 trackbacks/pingbacks}

  1. Pingback: [NDH 2016][FORENSICS 200 – I’M AFRAID OF A GH0ST NAMED POISON IVY] WRITE UP | 0x90r00t on 08/07/2016

The Conversation {2 comments}

  1. Mike {Sunday September 27, 2015 @ 10:38 pm}

    You could get the flag by just running chopshop :/

    chopshop -s . -f net.pcap “poisonivy_23x -f -l -v -c”

  2. Aris Adamantiadis {Sunday September 27, 2015 @ 11:20 pm}

    I noticed literally 10 minutes after posting this that I was using parts of chopshop that itself speaks Poison Ivy. My google skills suck ūüôĀ

    But let’s be honest, one of the two ways had more learning potentials than the other.

Speak Your Peace

  • Comment Policy:Could go here if there's a nagging need Login Instructions: Would go here if there's a desire.