Introduction
During RealWorldCTF 2022 there was a RE challenge named “Ferris Proxy”. The challenge includes 2 executables (client
and server
) and a TCP packet capture file (pcap
) that contains data of communication between the client and server.
Our team (xSTF) did not solve the challenge on time but I decided to make a writeup anyway because the challenge was fun and mixed multiple categories besides reverse engineering such as network analysis and also cryptography.
In resume the challenge requires to reverse engineer client & server, understand the protocol and find a way to decrypt the traffic. These type of tools are commonly used by people from countries with heavy internet censorship and monitoring (such as China) however they may introduce flaws that could expose citizens traffic or browsing history when being used with, e.g. standard configurations, default keys, flawed cryptography etc.
Analyzing the binary
A quick file
command indicates the files are Linux executables ELF
64-bit.
1
2
3
4
5
6
7
8
9
10
$ file client server FLAG.pcapng
client: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0,
BuildID[sha1]=fd0a5208bedd3a88e6e85a68a85eefc300da0cd5, with debug_info, not stripped
server: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0,
BuildID[sha1]=0db24ed5919dd9d442073779b3a28dcbd03ce884, with debug_info, not stripped
FLAG.pcapng: pcapng capture file - version 1.0
Fortunately the ELFs were not stripped and the debug info is included to assist while reversing the binaries.
By having a look at strings
we read some references to the tokio-rs library/platform used for the Rust 🦀 programming language.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ strings client
(...)
read_unaligned<[core::mem::maybe_uninit::MaybeUninit<u8>; 16]>
read<core::mem::maybe_uninit::MaybeUninit<(u32, tokio::io::util::mem::DuplexStream)>>
into_inner<core::mem::maybe_uninit::MaybeUninit<bytes::bytes_mut::BytesMut>>
util
process_transfer
runtime
drop_in_place<tokio::runtime::task::harness::poll_future::{closure#0}::Guard<core::pin::Pin<alloc::boxed::Box<lib::protocol::mux::{impl#3}::process_transfer::{async_fn_env#0}<tokio::io::util::mem::DuplexStream>, alloc::alloc::Global>>>>
drop_in_place<core::result::Result<core::result::Result<(), std::io::error::Error>, tokio::runtime::task::error::JoinError>>
drop<core::result::Result<(), std::io::error::Error>>
AddrNotAvailable
drop_in_place<core::result::Result<(), tokio::sync::mpsc::error::SendError<(u32, tokio::io::util::mem::DuplexStream)>>>
(...)
Network communication
On the flag.pcap
file we notice the 3-way handshake, both source & destination ports and also the TCP data (unreadable).
Since the first SYN is sent from a seemingly random high port we’ll assume the the client started the connection and the server is listening on port 8888
.
To understand what the client
and server
are supposed to do we execute both on virtual machines and observe their behavior by analyzing dynamically. We start by running the client:
1
2
$ ./client
Error: Os { code: 111, kind: ConnectionRefused, message: "Connection refused" }
This error means the client tried to connect to server but since it is not running, it couldn’t connect and exited so we must start the server
in first place. In fact the ss
utility confirms that server
is indeed listening on 8888
for TCP connections.
1
2
3
4
$ ss -tlp
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
(...)
LISTEN 0 1024 0.0.0.0:8888 0.0.0.0:* users:(("server",pid=1665474,fd=9))
After playing with the programs a bit we realize the client
also starts listening on port 12345
. The output from ss
is the following:
1
2
3
4
5
$ ss -tlp
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
(...)
LISTEN 0 1024 0.0.0.0:8888 0.0.0.0:* users:(("server",pid=1665474,fd=9))
LISTEN 0 1024 0.0.0.0:12345 0.0.0.0:* users:(("client",pid=1668470,fd=10))
In general this is more or less what it looks like. Note that the packet capture originates from client
and is proxied into server
before reaching its final destination. Technically it works like a tunnel.
The proxy may be tested with, for example, cURL to inspect how it handles incoming connections and how it forwards to server:
curl -v -x socks5://127.0.0.1:12345 https://vrls.ws
Decrypting the traffic 1
Digging a little bit deeper analyzing the client
binary statically we find out some interesting things.
After all the tokio runtime stuff has been initialized we reach the client::main
function where the actual program begins.
One of the first things to do before the client starts listening and forwarding TCP to the server, is loading the configurations from a YAML string embedded in the executable because there is no additional file. So we see a call to serde_yaml::from_str
that parses the following YAML string:
This is part of a RSA PUBLIC KEY. Extracting the whole string from client
gives us the following string:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pubkey: |
-----BEGIN RSA PUBLIC KEY----
MIIBCgKCAQEA231sNrX6WxlQpfsy8u6VR9rw4H4lY93fspAmXefjDhQhYCPM+Syp
(..)
H5YyTi1cOvMFsN+oPGxgBGpSqYCxmPwINwIDAQAB
-----END RSA PUBLIC KEY-----
mkey: explorer
privkey: |
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC93GDINPM6HX2+
(..)
p2eXdqH59DvauggbiS3h4p9k2kxWTocztarvdftMW0ncmA4yCKiUQEmWD784JCyx
OupNNfr2rgViWggVBEtJUIg=
-----END PRIVATE KEY-----
# additional strings without null terminator (Rust 🤯)
can not parse yaml 127.0.0.1:8888 0.0.0.0:12345 ...
The public key on client
should belong to server
and vice-versa (assymetric key cryptography). Note there is an additional value for mkey
.
Going further on client::main
we find some references to TcpStream::connect
and lib::protocol::rc4
. In resume, this function is responsible for handling TCP streams RC4 encryption & decryption.
By placing few breakpoints in the context of process_transfer
function we can read the RC4 key
from memory and it does match the mkey
value from YAML file:
explorer
is the key to RC4 encrypt and decrypt.
Note the decrypted data is still unreadable. Since we know there are RSA keys it means it could have additional layers of encryption. However, there are some leading zeroes visible at the beginning of the RC4 decrypted data.
If we start by splitting each 4 bytes we can observe a pattern:
1
00000008 00000000 00000000 00000008 00000000 00000001 00000048 00000001 00000000 01001db2ee547837c2b0394f9316a3c672109c854769d9627ce715aa82ae76af4fc7d468903ffa4f7c77 (...)
After splitting the “metadata” (fields with a lot of zeroes) from the actual data and aligning them it looks like the following:
In order to really understand what those values preceding the actual message mean we should look at what is being written into and read from sockets.
Demuxing TCP streams
The first value is the length of everything succeeding. For example:
00000008 | 00000000 00000000
means there are0x08 = 8 bytes
0000000a | 00000001 00000000 b30c
means there0x0a = 10 bytes
to be read and so on
However there are still two unknown fields (the 2nd and 3rd column).
At the very beginning of client::process_connection
, whenever the client
receives an incoming connection and before forwarding to the server, its capable of doing multiplexing. This means multiple users may use the client
simultaneously while it do use the same established connection with the server
. other words, multiple client requests are tunneled over the same client-server connection and there must be a way to handle them in order to deliver the responses to the correct users: lib::protocol::mux
.
In the following image we may observe the id
being mapped into a stream
, stored in a HashMap
and then being incremented by 1 for a future stream:
If we perform multiple cURL requests for example, each one will have a distinct stream id
and the value is reflected on the 3rd header field to keep track of which stream belongs to which user:
1
2
3
$ curl -kv -x socks5://127.0.0.1:12345 https://vrls.ws --connect-timeout 999999 & \
curl -kv -x socks5://127.0.0.1:12345 https://vrls.ws --connect-timeout 999999 & \
curl -kv -x socks5://127.0.0.1:12345 https://vrls.ws --connect-timeout 999999 &
Example after demuxing by stream_id
(3rd header value):
Regarding the 2nd header after demuxing we can see it has basically three distinct values: 0, 1 and 2
translate to:
0: new connection
1: in progress/transferring data
2: transfer completed
TLDR
- Protocol:
length (4 bytes) + state (4 bytes) + stream_id (4 bytes) + message (x bytes)
Key exchange
Another important step before decrypting the transmitted message is to find out the actual key. RSA is used to perform a key exchange between the client & server that is further used to encrypt the stream.
It begins by initializing a random number generation utility rand::rng::Rng::gen
and feeding into rsa::key::RsaPublicKey::encrypt
. Basically it RSA-encrypts an array of 16 randomly generated bytes using server
public key (the server
does the same with client
public key).
We are able to inspect the 2048-bit ciphertext in memory during runtime.
For sanity check we grab the server
private key that is embedded into the server executable and try to decrypt the ciphertext. As a result we see the plaintext matches the client generated 128-bit key.
After the keys are exchanged they must be verified by both ends to ensure each one received the correct key in order to proceed to with communication. But first there is an additional operation that takes both keys (i.e. the key generated on client and the key received from server). It starts by zipping 16-byte arrays and then mapping XOR function to each pair resulting in a session key.
For example, suppose the client generated the key 133cb1a120196141af208b4d112f26c3
and received the server key f41f823cc17a9a5bcf5d253cf6b8cea2
. The result after zipping both keys and applying a XOR function to each item (tuples in this case) will be:
- 0x13 ^ 0xf4 = 0xe7
- 0x3c ^ 0x1f = 0x23
and so on…
- Derived session key:
e723339de163fb1a607dae71e797e861
The Python equivalent of this operation would be something like the following:
1
2
3
4
client_key = bytes.fromhex('133cb1a120196141af208b4d112f26c3')
server_key = bytes.fromhex('f41f823cc17a9a5bcf5d253cf6b8cea2')
session_key = map(lambda x: x[0]^x[1], zip(client_key, server_key))
# e723339de163fb1a607dae71e797e861
When the session key has been derived both client
and server
calculate its respective SHA-256 hash digest (openssl::sha::sha256
), exchange it and compare to ensure both have derived the same key.
Decrypting the traffic 2
From this point and considering all the steps we just went through it is trivial to decrypt the pcap
contents. After the session key hash has been succesfully validated by both client
and server
it starts by encrypting with AES-128-CBC
mode using the derivated key from the previous section.
Continuing the previous example and after getting the whole TCP stream from Wireshark we may try to decrypt using the parameters we found. Consider the following silly Python script to dump client -> server
packets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# client -> server
data = '0000000800000000000(...)200000000'
# server -> client
#data = '000000080000000000000000(...)200000000'
idx = 0
while idx < len(data):
length = int(data[idx:idx+8],16)
state = int(data[idx+8:idx+16],16)
sid = int(data[idx+16:idx+24],16)
messagae = data[idx+24:idx+24+2*length-8-8]
print(length, state, sid, messagae)
# move to next packet
idx += length*2 + 8
It will output the following results:
Some things we may observe in the protocol:
- The data from 7th line match the SHA256 hash we got during key exchange on previous example:
118d31c061936b811327f1645c9f8deef4c6628e3ee21b39950213ddd0cf2141
- Everything after that line will probably be AES encrypted, while before that line will be RSA encrypted and related with key exchange.
- The message length from
2nd, 3rd, 4th and 5th
lines are respectively (64+64+64+64 = 256 bytes = 2048 bits = RSA key size) - Note: there is an additional header for the length of RSA or AES ciphertexts which was not parsed properly by the script due to laziness to write a decent script.
Now let’s try to decrypt the data using the key we got previously and confirm it works!
Flag
To get the flag we just need to reproduce the same steps but using the actual FLAG.pcap
from given for the challenge.
- Must be repeated for both
client -> server
andserver -> client
packets
In resume:
1 - De-multiplex the streams (there are 4 distinct streams on FLAG.pcap
)
2 - Decrypt each key exchange with server
private key and client
private key respectively
3 - Derive session key by XOR’ing both exchanged keys
- For stream id #3 the keys are respectively:
171c41af47c5340eff9aa3421b2a4cc8
&859a42b1d67e8e864712d04b4ba56e06
- Session key:
9286031e91bbba88b8887309508f22ce
- (repeat for remaining streams…)
4 - Repeat and decrypt the remaining messages using AES-128-CBC mode with the derived key, per stream, until we eventually reach the flag
rwctf{l1fe_1s_sh0rt_DO0_not_us3_rust}
Conclusion
Finally, in very generic terms and after several hours of reversing, the main program flow is similar to the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
client::main
serde_yaml::de::from_str (read YAML string config including RSA keys and RC4 key)
tokio::net::tcp::stream::TcpStream::connect (connect to server)
lib::protocol::rc4::Rc4::new (key: explorer, handle TCP data encryption/decryption)
lib::protocol::rc4::Rc4::process_transfer
lib::protocol::rc4::Rc4::process_data
openssl::symm::Crypter::update
lib::protocol::mux::MuxCore::new (initialize multiplex handler)
std::collections::hash::map::HashMap (storage map id -> stream)
lib::protocol::mux::MuxCore::process_transfer
lib::protocol::mux::MuxCore::read_peer_dispatch
tokio::net::tcp::listener::TcpListener::bind (listen on port :12345)
tokio::net::tcp::listener::TcpListener::accept
client::process_connection
lib::protocol::mux::MuxInterface::get_stream
std::collections::hash::map::HashMap::insert (map id -> stream)
lib::protocol::mux::MuxChannel::new
lib::protocol::mux::MuxChannel::connect (get into the correct channel)
lib::protocol::mux::MuxChannel::process_transfer
lib::protocol::crypto::Crypto::new
pkcs1::traits::DecodeRsaPublicKey::from_pkcs1_pem
pkcs8::traits::DecodePrivateKey::from_pkcs8_pem
lib::protocol::crypto::Crypto::key_exchange
rand::rng::Rng::gen (generate random 16-bytes key)
rsa::padding::PaddingScheme::new_pkcs1v15_encrypt (encrypt the key)
tokio::io::util::async_write_ext::AsyncWriteExt::write_all
tokio::io::util::async_read_ext::AsyncReadExt::read_exact
rsa::padding::PaddingScheme::new_pkcs1v15_encrypt
rsa::key::RsaPrivateKey::decrypt (decrypt the received key from the server)
core::iter::adapters::zip::zip
core::iter::traits::iterator::Iterator::map
core::iter::traits::iterator::Iterator::collect (map(xor, zip(key1, key2)))
openssl::sha::sha256 (hash session_key to validate successful key exchange)
tokio::io::util::async_write::AsyncWriteExt::write_all (send hash)
tokio::io::util::async_read::AsyncReadExt::read_exact (receive hash and compare)
lib::protocol::crypto::Crypto::process_transfer
lib::protocol::crypto::Crypto::process_data_enc (process the actual connections)
lib::protocol::crypto::Crypto::process_data_enc_in
openssl::symm::Cipher::aes_128_cbc (aes encrypt using session_key obtained)
tokio::io::util::async_read_ext::AsyncReadExt::read_buf
rand::rng::Rng::gen
openssl::symm::encrypt
tokio::io::util::async_write_ext::AsyncWriteExt::write_all_buf
Thanks to:
- zh-explorer (the challenge author)
- Nevsor (Sauercloud) for the hint