$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: a single PCAP capturing a custom C2 implant (ic2kp) download, an encrypted reverse-shell session, and a multipart exfiltration of a password-protected Firefox profile zip. Solution: reverse the stripped ELF to recover its software AES-128-CBC + HMAC-SHA1 protocol, decrypt the C2 session with continuous CBC chaining to recover the zip password, then firefox_decrypt the key4.db/logins.json to read the attacker's stored C2 panel credential, which is the flag.
Valentine's Day themed. A forensics analyst broke up with their "security engineer" partner after discovering years of spying. The partner is a malware-developing vigilante hacker; you are the forensics analyst. A single PCAP is provided.
English summary: We are given one packet capture. It contains a malware download, an encrypted command-and-control (C2) reverse shell, and a data-exfiltration upload. The flag is not in the traffic in cleartext — it must be recovered by reversing the custom C2 implant, decrypting the session to learn an archive password, and finally decrypting saved Firefox credentials from the exfiltrated profile.
challenge.zip is password-protected. The standard HTB password hackthebox extracts
capture.pcap (776 packets, ~151 KB, captured 2022-02-05).
capinfos capture.pcap tshark -r capture.pcap -z conv,tcp -q tshark -r capture.pcap -z io,phs -q
Three TCP streams are present. The protocol hierarchy shows http carrying an embedded
elf and a mime_multipart body:
| Stream | Port | Direction | Content |
|---|---|---|---|
| 0 | 80 | HTTP GET /ic2kp | Victim downloads a 9777-byte ELF C2 implant |
| 1 | 1234 | bidirectional encrypted | C2 reverse shell |
| 2 | 8000 | HTTP POST multipart/form-data | Exfiltration of b12gb.zip |
Hosts: victim (connect-back) 192.168.1.11; attacker listener 192.168.1.4:1234.
The implant ELF is 64-bit x86-64, PIE, stripped, dynamically linked, with no OpenSSL imports — meaning all cryptography is implemented in software.
Export HTTP objects:
tshark -r capture.pcap --export-objects http,http_objects/
This yields ic2kp (the implant ELF) and the multipart POST body.
The POST body is multipart form-data. Carve from the first PK\x03\x04 to obtain
b12gb.zip:
unzip -l b12gb.zip # cert9.db 229376 # key4.db 294912 # times.json # logins.json 1882
This is a Firefox profile. The ZIP is password-protected, so the password must be recovered from the C2 session.
file ic2kp # ELF 64-bit LSB pie executable, x86-64, stripped objdump -T ic2kp # recv/send/read/write/socket/connect/bind/listen/accept/openpty/ # ttyname/dup2/execl/memcmp/memcpy/gettimeofday/getpid/select/fork/ # setsockopt/putenv/gethostbyname/strtol/getopt — no OpenSSL
The .rodata contains the AES S-box (63 7c 77 7b ...) and AES T-tables, plus a
SHA1/HMAC implementation — confirming a hand-rolled AES + HMAC-SHA1 stack.
Usage string:
Usage: %s [ -c [ connect_back_host ] ] [ -s secret ] [ -p port ]
The embedded default secret is in .rodata as hex 53336372337450407373 = S3cr3tP@ss.
PITFALL: an initial partial hex read gave
S3cr3t@ss, which is WRONG and produces garbage on decryption. The correct secret isS3cr3tP@ss(note theP).
NOTE: "ic2kp" is not a public GitHub project. It is a custom HTB implant — do not waste time hunting for upstream source. Reverse it directly with Ghidra/radare2/objdump.
Handshake. The victim (connect-back side) sends the FIRST 40 bytes in cleartext:
packet[0] = IV_A(20) || IV_B(20)
Each 20-byte IV = SHA1(16 random bytes || 4-byte counter). This is the single 40-byte
victim→attacker packet at the start of stream 1.
After the IV exchange, both sides perform an encrypted mutual "magic" exchange. The magic
constant is 5890ae86f1b91cf6298395711dde580d, corresponding to the small 52/36-byte
handshake packets that follow.
Key derivation (per direction):
digest = SHA1(secret || iv20) # 20 bytes
AES-128 key = digest[:16]
CBC IV = iv20[:16]
HMAC-SHA1 key = digest # standard ipad 0x36 / opad 0x5c
IV_AIV_BMode / framing. AES-128-CBC, software AES. CBC chaining is continuous across all records within a direction (not per-packet). Each record is:
record = AES_CBC( [2-byte big-endian length n][n payload bytes][zero-pad to mult of 16] )
|| HMAC-SHA1(20)
The 20-byte HMAC trailer is NOT part of the CBC chain and must be skipped at each record boundary.
KEY PITFALL (FAILED approach): the naive "strip 20-byte HMAC per packet, then CBC-decrypt each packet independently" does NOT work — e.g. the 124-byte packet has a ciphertext length that is not a multiple of 16 (
% 16 == 8). You MUST reassemble each direction's full byte stream and walk records, skipping the HMAC only at record boundaries so the CBC chain stays intact.
Extract each direction of stream 1 as hex payloads:
tshark -r capture.pcap -Y "tcp.stream==1 && ip.src==192.168.1.11" \ -T fields -e tcp.payload > streams/c2_victim_to_attacker.hex tshark -r capture.pcap -Y "tcp.stream==1 && ip.src==192.168.1.4" \ -T fields -e tcp.payload > streams/c2_attacker_to_victim.hex
Decryptor (pycryptodome). Parse the 40-byte cleartext IV handshake, derive per-direction keys, then walk continuous CBC records skipping HMAC trailers:
#!/usr/bin/env python3 import hashlib, sys from Crypto.Cipher import AES SECRET = b"S3cr3tP@ss" def load(path): return [bytes.fromhex(l.strip()) for l in open(path) if l.strip()] v2a = load("streams/c2_victim_to_attacker.hex") a2v = load("streams/c2_attacker_to_victim.hex") hs = v2a[0] IV_A = hs[:20]; IV_B = hs[20:40] def derive(iv20): digest = hashlib.sha1(SECRET + iv20).digest() return digest[:16], iv20[:16], digest # aes key, cbc iv, hmac key def roundup16(x): return x if x % 16 == 0 else x + (16 - x % 16) def parse_stream(data, key, iv): """Each record = AES_CBC([2B BE n][n payload][pad16]) || 20B HMAC. CBC chains continuously across records; HMAC trailer is NOT chained.""" aes = AES.new(key, AES.MODE_ECB) prev = iv pos = 0 msgs = [] def dec_block(c): nonlocal prev p = bytes(a ^ b for a, b in zip(aes.decrypt(c), prev)) prev = c return p while pos + 16 <= len(data): first = dec_block(data[pos:pos+16]); pos += 16 n = (first[0] << 8) | first[1] remaining_aes = roundup16(n + 2) - 16 body = first while remaining_aes > 0 and pos + 16 <= len(data): body += dec_block(data[pos:pos+16]); pos += 16; remaining_aes -= 16 msgs.append(body[2:2+n]) pos += 20 # skip 20-byte HMAC (not chained) return msgs keyA, ivA, _ = derive(IV_A) keyB, ivB, _ = derive(IV_B) dataA = b"".join(v2a[1:]) # victim->attacker, skip handshake pkt0 dataB = b"".join(a2v) # attacker->victim outB = b"".join(parse_stream(dataB, keyB, ivB)) # commands outA = b"".join(parse_stream(dataA, keyA, ivA)) # shell output sys.stdout.buffer.write(outB) sys.stdout.buffer.write(outA)
The decrypted shell session contains the exfiltration command:
zip -9 -P nL98udHrzk5vhrLWns3hIDi b12gb.zip cert9.db key4.db times.json logins.json
→ ZIP password = nL98udHrzk5vhrLWns3hIDi.
unzip -P nL98udHrzk5vhrLWns3hIDi b12gb.zip python3 firefox_decrypt.py . # NSS over key4.db + logins.json, NO master password
Decrypted saved logins:
| Site | Username | Password |
|---|---|---|
| facebook.com | [email protected] | password1 |
| account.protonmail.com | [email protected] | 5SGcwI95HW9mcQ7U |
| login.cncserver.com:7443 | admin | HTB{...} ← the flag |
The flag is the attacker's own C2 panel password (login.cncserver.com:7443). It never
appears in the traffic plaintext — it is reached only by pivoting through the
C2-revealed ZIP password into the exfiltrated Firefox credential store.
Verified independently: unzip + firefox_decrypt reproduce the flag end-to-end.
S3cr3tP@ss, not S3cr3t@ss. A partial/misread hex string of
the embedded secret silently produces wrong keys. Always read the full .rodata hex
(53336372337450407373).% 16 == 8). Reassemble the whole direction stream
and skip the 20-byte HMAC only at record boundaries.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar