corCTF 2022 - sleeper_agent

  • Author: pottm
  • Date:



An attacker has breached the company network and is using the openssl command line tool to create a TLS1.3-encrypted channel for data exfiltration.


Our SIEM generated an alert about an outgoing TLS-connection with a suspicious destination port, originating from one of our internal servers. Luckily, one of our analysts was paying attention and logged on to the server right away. She locked the attacker out, but not before generating a core dump of the process that they used for their encrypted communications. We need to figure out which data the attacker might have exfiltrated from our systems. Combined with the full traffic capture of the incident, surely a forensics prodigy such as yourself can get it done.


The player is provided with a full packet capture of the incident (challenge.pcap), as well as a core dump file of the openssl process (challenge.core). To solve the challenge, they must decrypt the attacker’s communications using the encryption key material hidden within the core dump.

In the past, one could simply locate the master secret within the core dump, plug it into wireshark and be rewarded with an automated decryption. With TLS1.3 however, there are now numerous different secrets for handshake and application traffic, all of which are needed for wireshark to decrypt the connection. To make things worse, once the TLS handshake finishes, the corresponding handshake secrets are discarded and their memory contents are zero-ed out. Therefore, decryption has to be done manually.

Solving The Challenge

A quick peek over the packet capture discloses that

To better understand what’s going on, the relevant parts of the challenge can be replicated, while instructing openssl to log the key materials to a keylogfile. A core dump of the client process can be created using the gcore command line utility. tcpdump is used to capture the network traffic.

 1# capture localhost traffic on port 1337 to example.pcap
 2tcpdump -w challenge.pcap -i lo port 1337
 4# spawn a server, and create a server certificate if it doesn't exist
 5if [[ ! -f cert.pem || ! -f key.pem ]]; then openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes; fi
 6openssl s_server -msg  -key key.pem -cert cert.pem -port 1337
 8# in another shell, connect to it
 9openssl s_client -quiet -connect -keylogfile keylogfile.txt -tls1_3 -ciphersuites TLS_AES_256_GCM_SHA384 
11# in yet another shell, create core dump and move it to example.core
12gcore $(pgrep -f "openssl s_client") && mv core.* challenge.core
14# clean up
15pkill '(openssl|tcpdump)'

The keylogfile will then have contents like this (in this case, it’s the actual data from the challenge):

SERVER_HANDSHAKE_TRAFFIC_SECRET f4d7ba2fa472bb79e111f68044bf706df1d4d54825c0e3a393de3a4a4659652c bf3efc3f0d1fd132aa6d291991d9fa57e7066eed78cea07d6adb0784c498157f2244d6c3715d1b934a89e508ec90c1ae
CLIENT_HANDSHAKE_TRAFFIC_SECRET f4d7ba2fa472bb79e111f68044bf706df1d4d54825c0e3a393de3a4a4659652c cb4e6dd72686c992e2c1deb8cf0d64424966971e846094ec9792ebb0721682debbdee46f30ec813b23f92cfee47e81c1
EXPORTER_SECRET f4d7ba2fa472bb79e111f68044bf706df1d4d54825c0e3a393de3a4a4659652c 2d6e9a34c670093a140e1230780365f5f6f85eb15f30f90e2e03929c817e53d6b766a4c580eb2d8545cc0050a4dbfc0e
SERVER_TRAFFIC_SECRET_0 f4d7ba2fa472bb79e111f68044bf706df1d4d54825c0e3a393de3a4a4659652c e99499535710122ea6abc3626c3b50ab1251f06680000a659b9afdbf9bf8132c30acdc0a1aec08ae606c4f78de723347
CLIENT_TRAFFIC_SECRET_0 f4d7ba2fa472bb79e111f68044bf706df1d4d54825c0e3a393de3a4a4659652c 38a63ec591323b3a75d1c19595b721e4b1b8005c4b14e2826bb81adcc8261052f982ad99af2075f574585f785629b790

The first of those hex values is the Client Random and is used as an identifier for the TLS connection. The second hex value is the actual secret. Opening challenge.pcap in wireshark, the Client Random can be seen in the Client Hello packet (tls.handshake.random). No other value can be found - they’re secret, after all. Wireshark can decrypt the connection when provided to the path to keylogfile.txt under Edit -> Preferences -> TLS -> (Pre)-Master-Secret log filename. The TLS debug file path can be set to instruct wireshark to generate debugging information during decryption of TLS packets. The debugging information contains secrets and derived encryption keys and initialization vectors.

A reasonable idea to solve the challenge at this stage would be to locate the five secrets in the newly created core dump and from there figure how to find them in the challenge core dump. Once found, a keylog file could be constructed and the traffic decrypted. However, after the handshake completes, the corresponding secrets are discarded by openssl and the memory is zero-ed out.

 2$ cat challenge.core | xxd -p -c 1000000 | grep --byte-offset --only-matching bf3efc3f0d1fd132aa6d291991d9fa57e7066eed78cea07d6adb0784c498157f2244d6c3715d1b934a89e508ec90c1ae
 5$ cat challenge.core | xxd -p -c 1000000 | grep --byte-offset --only-matching cb4e6dd72686c992e2c1deb8cf0d64424966971e846094ec9792ebb0721682debbdee46f30ec813b23f92cfee47e81c1
 8$ cat challenge.core | xxd -p -c 1000000 | grep --byte-offset --only-matching 2d6e9a34c670093a140e1230780365f5f6f85eb15f30f90e2e03929c817e53d6b766a4c580eb2d8545cc0050a4dbfc0e
12$ cat challenge.core | xxd -p -c 1000000 | grep --byte-offset --only-matching e99499535710122ea6abc3626c3b50ab1251f06680000a659b9afdbf9bf8132c30acdc0a1aec08ae606c4f78de723347
16$ cat challenge.core | xxd -p -c 1000000 | grep --byte-offset --only-matching 38a63ec591323b3a75d1c19595b721e4b1b8005c4b14e2826bb81adcc8261052f982ad99af2075f574585f785629b790

This means that an automated decryption with wireshark isn’t possible. Using the example data, wireshark is kind enough to write down the used keys and IVs for each decrypted packet in the debug log at debug.txt.

... <snip> ...

trying to use TLS keylog in E:\keylogfile.txt
tls13_load_secret transitioning to new key, old state 0x97
tls13_load_secret Retrieved TLS 1.3 traffic secret.
Client Random[32]:
| f4 d7 ba 2f a4 72 bb 79 e1 11 f6 80 44 bf 70 6d |.../|
| f1 d4 d5 48 25 c0 e3 a3 93 de 3a 4a 46 59 65 2c |...H%.....:JFYe,|
| 38 a6 3e c5 91 32 3b 3a 75 d1 c1 95 95 b7 21 e4 |8.>..2;:u.....!.|
| b1 b8 00 5c 4b 14 e2 82 6b b8 1a dc c8 26 10 52 |...\K...k....&.R|
| f9 82 ad 99 af 20 75 f5 74 58 5f 78 56 29 b7 90 |..... u.tX_xV)..|
tls13_generate_keys CIPHER: AES256
tls13_generate_keys key_length 32 iv_length 12
Client Write Key[32]:
| 20 5a e2 31 84 25 60 66 fc b7 3c a2 ab 77 5a 29 | Z.1.%`f..<..wZ)|
| f4 11 99 8d 61 61 51 ba 7d fc 79 0e 7f 09 ca b5 |....aaQ.}.y.....|
Client Write IV[12]:
| a5 ec e2 4b 94 35 eb b1 29 61 eb 6c             |...K.5..)a.l    |
tls13_generate_keys ssl_create_decoder(client)
decoder initialized (digest len 48)

... <snip> ...

The key and IV can be found in the core dump.

1$ cat challenge.core | xxd -p -c 10000000 | grep --byte-offset --only-matching 205ae23184256066fcb73ca2ab775a29f411998d616151ba7dfc790e7f09cab5
4$ cat challenge.core | xxd -p -c 10000000 | grep --byte-offset --only-matching a5ece24b9435ebb12961eb6c

Indeed, the intended solution is to extract both the key and initialization vector (IV) of each of the the AES 256 GCM-ciphers (there’s one for each direction) and use them to decrypt the application data manually.

To locate them in the challenge pcap them, it’s best to study openssl’s source code first. openssl will create a struct ssl_st data structure for every TLS-Connection it handles. It is defined in ssl_local.h.

Equipped with this knowledge, a hex editor can be opened to look for the “header” 04 03 00 00 00 00 00 00. There are a bunch of hits, but only one of them - at file offset 0x72680 - is followed by a couple of plausible pointer values.

The version header is followed by the bytes 80 17 D3 0C BB 7F 00 00. To uniquely identify the struct within the memory dump, the byte sequence 04 03 00 00 00 00 00 00 80 can therefore be used.

Scrolling down, the IVs are located at offsets 0x448 and 0x478 from the beginning of the struct, and pointers to the state contexts are at offsets 0x440 and 0x470.

Unfortunately, the pointers can’t easily be followed using the hex editor. The core file can be loaded with the python library pwntools to get the dumped memory sections mapped to their correct virtual memory addresses and vice versa, as shown in an example below.

 1# pip3 install pwntools
 2from pwn import *
 4core = ELF("challenge.core")
 6ssl_struct_vaddr = next("040300000000000080")))
 8client_iv_vaddr = next("a5ece24b9435ebb12961eb6c")))
 9client_iv_offset = core.vaddr_to_offset(client_iv_vaddr)
10print(f"client iv   vaddr={hex(client_iv_vaddr)}   offset={hex(client_iv_offset)}")
11# client iv   vaddr=0x562c9f39b2d8   offset=0x72af8
13client_ctx_vaddr = u64( + 0x470, 8))
14client_ctx_offset = core.vaddr_to_offset(client_ctx_vaddr)
15print(f"client ctx  vaddr={hex(client_ctx_vaddr)}   offset={hex(client_ctx_offset)}")
16# client ctx  vaddr=0x562c9f3b2190   offset=0x899b0
18client_key_vaddr = next("205ae23184256066fcb73ca2ab775a29f411998d616151ba7dfc790e7f09cab5")))
19client_key_offset = core.vaddr_to_offset(client_key_vaddr)
20print(f"client key   vaddr={hex(client_key_vaddr)}   offset={hex(client_key_offset)}")
21# client key   vaddr=0x562c9f3b2240   offset=0x89a60

By digging around a bit (for instance, looking at the client_ctx at file offset 0x899b0 in a hex editor) the following can be figured out:

Below is a python script to extract those values from the core dump using pwntools.

 1# pip3 install pwntools
 2from pwn import *
 4core = ELF("./challenge.core")
 6ssl_struct_addr = next("040300000000000080"))) # find the virtual address of the ssl struct
 8server_iv = + 0x448, 12) # 12-byte server side iv @ ssl[0x448]
 9client_iv = + 0x478, 12) # 12-byte client side iv @ ssl[0x478]
11server_ctx_addr = u64( + 0x440, 8)) # pointer to server cipher context @ ssl[0x440] 
12client_ctx_addr = u64( + 0x470, 8)) # pointer to client cipher context @ ssl[0x440] 
14server_key_addr = u64( + 0x78, 8)) # pointer to server encryption key @ server_context[0x78]
15client_key_addr = u64( + 0x78, 8)) # pointer to client encryption key @ client_context[0x78]
17server_key =, 32) # dereference the pointer to get the 32-byte encryption key
18client_key =, 32) # dereference the pointer to get the 32-byte encryption key

All that’s left now is to decrypt the traffic. This can be done manually (copy-pasting values out of wireshark) or with the help of tshark and some scripting. The following two invocation of tshark can be used to filter out all relevant packets with application data. Do note that a TCP packet can hold multiple TLS-application-data records, which tshark will seperate with commas.

 1$ client_finished_frame_number="$(tshark -r   challenge.pcap -Y 'tcp.dstport == 1337 && tls.record.content_type == 0x14'     -T fields -e frame.number 2>/dev/null)"
 2#                                                              ^^^^^^^^^^^^^^^^^^^                              ^^^^^^     ^^^^^^^^^^^^^^^^^^^^^^^^^^
 3#                                                             from client to server                           "Finished"   print only the frame number
 5$ tshark -r challenge.pcap -Y "tls.app_data && frame.number > $client_finished_frame_number" -T fields -e tcp.srcport -e tls.app_data 2>/dev/null
 6#                            ^^^^^^^^^^^^               ^^^^^^^^^^^^^^^^^^^^               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 7#                          frame has app data             starting at frame X              print source port and hex-encoded app data             
 9... <snip> ...
1035338   6a606db1d076070a2d5d9438d608b83727d4958db3b920789cd7439bc1dccec7a3438bbf9981cace3a44c150db940d5cadfec598bf6e89dc6b536c0827622022be8678be8aa791a6
111337    74ecfde5e51133bac87e7bd3d22c521dff6fd0767141b6e82c1bdebce25be367c0c6149371dabcba53f78a0441b133030132db3739f6de8d0dd0d8d49e551ad62ac3401261f0489df097ac3bbee4ef5eb998bb1b10e715f8906ac7cd3e6ec2d3b88e7234739ec2f98bfda0ed2d65175cb9eccec340877795102bba0e2222cb6fdeb3b189f2f9e0450dffd6057df4a9228b1eed9a279684c8d896d8a2341e251a8c98ef3666647a0f0f29584245c14c5fcda032a49857daf09070c69342604626159107fd445d8a36484e58d067855cd1fd2647717517de4a45ffe4444c1f6e66d796452a142fbe75bf4bb9a1f67081a31b1880af0769956ac324
121337    fdb19111afbd683e763cd9558bca42bd93694189dfa7082fcfeec2388f57697139272dec70271d02be3ad5ad4e41dceecfdeccd5b105f176269803605046e408abcfaf99a999d4df9c317126dae609093ca872eec7ad03b3c078c4b040eeb066b6642a64dbab5bcc927c3f625dd20634d3795bd4ea398a3ca879f07139ae46928805c543fd3a8d8fc38fc5039b45050de522bea2fcbc2e69cb4a8b979e9ab36c959b4414bd2a0d5da0554771316bdc2760cbf95ad08b2096bc032ba16ef75a5daea7c4e27b9b7e74a6fa7e6f9a3d850b9591f46132fc68a20d9a6ff71a72cd70881670941e936a8b9d537d40c1308464f49f327796bc2325f6bd
131337    b79f5fa810efb6828f1bf800651da371cfebb490df7a057583bb94565bbfdeba2f5b030e3e09c78c21b190005959e6762fec4709cd0d0a18fb901278ed1fefc67715183bb02dfada494dc5f118677e96e7e9fde875c241e5452643a68e99
1435338   662fa04ca08484ac3889919de67c3f06fd7bfbf08d51ee2a847bff7cbc037dd6642ed9a5e97272c31208d8328a7d0b37
15... <snip> ...

Each direction of traffic can be decrypted independently from the other. The process is as follows.

Below is the final script that combines everything.

 1from pwn import *
 2from Crypto.Cipher import ChaCha20_Poly1305
 4context.arch = "amd64"
 5context.os = "linux"
 7core = Corefile("challenge.core")
10for vaddr in"0403000000000000")):
11    pointers = unpack_many( + 8, 24)) # read three qwords following the 0x0304 header
12    if all(core.memory.overlaps(p) for p in pointers): # check if every qword points to a mapped virtual address
13        ssl_struct_vaddr = vaddr 
14        break
16    print("could not find ssl_struct")
17    exit(0)
19print(f"[ssl struct] vaddr={hex(ssl_struct_vaddr)} offset={hex(core.vaddr_to_offset(ssl_struct_vaddr))}")
21server_iv = + 0x448, 12) # 12-byte server side iv @ ssl[0x448]
22server_ctx_vaddr = u64( + 0x440, 8)) # pointer to server ssl context @ ssl[0x440] 
23server_key_vaddr = u64( + 0x78, 8)) # pointer to server encryption key @ server_context[0x78]
24server_key =, 32) # dereference the pointer to get the 32-byte encryption key
25server_packet_counter = 0
26server_output_file = open("server.txt", "wb")
27print("[server key]", enhex(server_key))
28print("[server iv] ", enhex(server_iv))
31client_iv = + 0x478, 12)
32client_ctx_vaddr = u64( + 0x470, 8))
33client_key_vaddr = u64( + 0x78, 8))
34client_key =, 32)
35client_packet_counter = 0
36client_output_file = open("client.txt", "wb")
37print("[client key]", enhex(client_key))
38print("[client iv] ", enhex(client_iv))
41# use tshark to find the pcap frame number for the TLS_CLIENT_FINISHED packet
42client_finished_frame_number = process("""tshark -r challenge.pcap -Y "tcp.dstport == 1337 && tls.record.content_type == 0x14" -T fields -e frame.number 2>/dev/null""", shell=True).readallS().strip()
44# use tshark to get all the TLS payload data
45tls_appdata_frames = process(f"""tshark -r challenge.pcap -Y "tls.app_data && frame.number > {client_finished_frame_number}" -T fields -e tcp.srcport -e tls.app_data 2>/dev/null""", shell=True).readallS().strip()
47for line in tls_appdata_frames.splitlines():
49    srcport, encrypted_data_and_gcm_tags = line.split()
51    for encrypted_data_and_gcm_tag in encrypted_data_and_gcm_tags.split(","):
53        encrypted_data = unhex(encrypted_data_and_gcm_tag)[:-17] # discard gcm tag
55        if srcport != "1337": 
56            # packet sent by client
57            iv = xor(client_iv, pack(client_packet_counter, word_size=8*12, endian="big")) # xor client iv with 12-byte big-endian packet counter
58            client_packet_counter += 1
59            decrypted_data =, nonce=iv).decrypt(encrypted_data)
60            print("[client]", decrypted_data)
61            client_output_file.write(decrypted_data)
63        else: 
64            #packet sent by server
65            iv = xor(server_iv, pack(server_packet_counter, word_size=8*12, endian="big"))
66            server_packet_counter += 1
67            decrypted_data =, nonce=iv).decrypt(encrypted_data)
68            print("[server]", decrypted_data)
69            server_output_file.write(decrypted_data)
74os.system("cat client.txt | grep -v www-data | tail +4 | tr -d '\r' | base64 -d > && unzip -j -o -P '?11111?c00lb34nZ?11111?' && xdg-open evidence_of_misconduct.jpg &")

The output is as follows:

[server] b'exec python3 -c \'import pty; pty.spawn(["/bin/bash", "-i"])\';\n'
[client] b'www-data@ubuntu:/var/www/html$ '
[server] b'exec 2>&1; export TERM=xterm-256color; export SHELL=bash; stty rows 100 columns 200;  stty -echo; stty intr "^";\n'
[client] b'\r<y rows 100 columns 200;  stty -echo; stty intr "^";\r\n'
[client] b'www-data@ubuntu:/var/www/html$ '
[server] b"find /srv -type f -regex '.*\\(docx?\\|pdf\\|jpe?g\\|png\\)' 2>/dev/null | xargs ls -alh\n"
[client] b'-rwxrw-rw- 1 root root 10K Jul 29 07:30 /srv/hr/cases/20220512410001/evidence_of_misconduct.jpg\r\n'
[client] b'www-data@ubuntu:/var/www/html$ '
[server] b'zip -e /dev/shm/ /srv/hr/cases/20220512410001/evidence_of_misconduct.jpg\n'
[client] b'Enter password: '
[server] b'?11111?c00lb34nZ?11111?\n'
[client] b'\r\nVerify password: '
[server] b'?11111?c00lb34nZ?11111?\n'
[client] b'\r\n'
[client] b'  adding: srv/hr/cases/20220512410001/evidence_of_misconduct.jpg'
[client] b' (deflated 3%)\r\n'
[client] b'www-data@ubuntu:/var/www/html$ '
[server] b'find /srv/hr/cases/20220512410001/ -type f | xargs shred\n'
[client] b'find /srv/hr/cases/20220512410001/ -type f | xargs shred\r\n'
[client] b'www-data@ubuntu:/var/www/html$ '
[server] b'base64 < /dev/shm/\n'
[client] b'base64 < /dev/shm/\r\n'
[client] b'UEsDBBQACQAIAMU7/VRifnVuiyYAAKQnAAA2ABwAc3J2L2hyL2Nhc2VzLzIwMjIwNTEyNDEwMDAx\r\nL2V2aWRlbmNlX29mX21pc2NvbmR1Y3Q'
... <snip> ...

It appears the attacker was actually a company employee! They stored a file in a password-protected zip file (password: lasagna), downloaded it and then shredded the original file on the victim server. The challenge’s flag is found within the image.

1cat client.txt | grep -v www-data | tail +4 | tr -d '\r' | base64 -d > && unzip -j -o -P '?11111?c00lb34nZ?11111?' && xdg-open evidence_of_misconduct.jpg &