$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: Analyze an x86-64 Linux ELF memory core after a phishing compromise and identify the eBee rootkit's captured data. Solution: Reconstruct live eBPF IDRs and maps, reverse the eBeeKeys bytecode, and decode the split keylog records.
Рабочая станция под управлением Linux была скомпрометирована в результате фишинговой атаки. На подпольных форумах упоминается Linux-руткит
eBee. Проанализируйте дамп памяти.
We were given a memory image from a compromised Linux workstation. The goal was to identify the eBee implant in memory and recover the useful data it had captured.
The investigation was performed strictly on the local dump. URLs found in memory were treated only as forensic indicators and were not contacted.
The original image was preserved and analyzed through an APFS clone. Its SHA-256 was:
184464b8b14df2068e23a2e66b3fa42e2e3a1b44336b85d24aa5ded327a1ecca
file identified the 1,619,139,427-byte artifact as an x86-64 QEMU-style ELF core:
ELF 64-bit LSB core file, x86-64, version 1 (SYSV), SVR4-style
The embedded kernel banner identified Ubuntu kernel Linux 6.17.0-35-generic.
Initial searches for a plaintext flag, including ASCII, UTF-16LE, flag-shaped strings, and likely shell-history residue, found nothing. The references to a Linux rootkit also suggested an LKM or LD_PRELOAD implant, but neither model explained the surviving objects. The decisive artifacts were live eBPF programs and maps: eBee was an eBPF rootkit rather than an LKM or user-space preload hook.
The ELF core contains several copies of the kernel banner in logs and page cache. Selecting the first match produced the false value _text = 0x54a3a3c0, which is not suitably aligned and caused invalid downstream object reconstruction.
The corrected locator tested every banner occurrence and accepted a candidate only when:
_text address was 2 MiB-aligned; andpage_offset_base global contained a canonical, aligned direct-map address.The unique valid layout was:
linked banner file offset: 0x565a7878 linked banner physical: 0x565c5d20 _text physical: 0x54c00000 direct-map base: 0xffff89ec40000000
The essential validation from extract_bpf.py was:
candidate = banner_phys - (symbols["linux_banner"] - symbols["_text"]) if candidate & 0x1fffff: continue pob_phys = candidate + symbols["page_offset_base"] - symbols["_text"] pob = u64(read_phys(pob_phys, 8)) if pob >> 48 == 0xffff and pob & 0x3fffffff == 0: valid.append((file_banner, banner_phys, candidate, pob))
This check is important in raw Linux memory work: a correct banner string does not prove that its containing page belongs to the linked kernel image.
Using symbols from the matching System.map, the physical locations of the global BPF IDRs were derived relative to the validated _text base:
map_idr physical: 0x57622060 prog_idr physical: 0x57622080
Both IDRs use kernel XArrays. Internal entries have the low tag bits set; after removing the internal-node tag, each 64-slot node can be walked according to its shift. Direct-map pointers were translated by subtracting 0xffff89ec40000000. BPF program objects in vmalloc space required page-table translation through init_top_pgt; treating every pointer as direct-mapped would miss or corrupt those programs.
A compact form of the XArray traversal was:
def walk_xarray(head, base=0): if not head: return if head & 3 == 2: node_va = head - 2 data = read_va(node_va, 0x228) shift = data[0] for slot in range(64): ent = u64(data, 0x28 + slot * 8) if ent: yield from walk_xarray(ent, base | (slot << shift)) elif not head & 1: yield base, head
The reconstructed inventory exposed these malicious programs:
| ID | Name | Type | Relevant maps |
|---|---|---|---|
| 91 | eBeeEgress | SCHED_CLS | current_buffer, c2_databus |
| 92 | eBeeIngress | SCHED_CLS | current_buffer, c2_commands |
| 93 | eBeeKeys | KPROBE | c2_commands, mod_state, c2_databus |
The corresponding eBee maps were:
| ID | Name | Type | Value size | Maximum entries |
|---|---|---|---|---|
| 14 | current_buffer | ARRAY | 84 | 1 |
| 15 | c2_databus | QUEUE | 8 | 1024 |
| 16 | c2_commands | ARRAY | 2 | 2 |
| 17 | mod_state | ARRAY | 1 | 5 |
The program bytecode was carved from each struct bpf_prog. In particular, eBeeKeys contained 372 instructions starting at raw file offset 0x0a642ba0. The local disasm_bpf.py preserved instruction indexes while decoding eBPF ALU, jump, load/store, and helper-call operations.
Instructions 353–370 of eBeeKeys showed that each captured key is stored as an eight-byte record:
u32 kind = 1 u16 packed u16 zero = 0
For packed, the high byte is a per-record value from bpf_get_prandom_u32, while the low byte is the original ASCII byte XORed with both that random byte and the active command byte:
packed.high = prandom packed.low = ASCII XOR prandom XOR c2_commands[1][0]
Therefore the operation is self-inverting:
ASCII = packed.low XOR packed.high XOR c2_commands[1][0]
The live command byte was 0x31, stored at raw file offset 0x06f8f268.
The complete decoded map state was:
keeper<TAB>avito{REDACTED}<LF>
The flag crossed a map boundary:
current_buffer records 7–9 held the prefix avi, at raw offsets 0x06f8ee98..0x06f8eeaf.c2_databus entries 10–43 held the remaining 34 bytes, at raw offsets 0x0a625cb0..0x0a625dbf.For the queue, tail is the oldest live entry and head is the next insertion slot. Reading storage in an arbitrary physical order rather than iterating from tail to head would produce the wrong chronology.
After the structural reconstruction established the exact offsets and decoding rule, the result was independently reproduced directly from the raw image. This verifier does not depend on the IDR parser's decoded output:
#!/usr/bin/env python3 """Independently decode the split eBee keylog directly from raw file offsets.""" from pathlib import Path import struct import sys image = Path(sys.argv[1] if len(sys.argv) > 1 else "honeykeeper_working.img") with image.open("rb") as fh: # eBee c2_commands[1][0], map ID 16. fh.seek(0x06F8F268) xor_key = fh.read(1)[0] # `avi` occupies current_buffer records 7..9. The rest of the flag is in # c2_databus queue entries 10..43, in FIFO order. offsets = [0x06F8EE98 + i * 8 for i in range(3)] offsets += [0x0A625CB0 + i * 8 for i in range(34)] decoded = bytearray() for off in offsets: fh.seek(off) event = fh.read(8) kind, packed = struct.unpack_from("<IH", event) assert kind == 1, (off, event.hex()) enc = packed & 0xFF rnd = packed >> 8 decoded.append(enc ^ rnd ^ xor_key) flag = decoded.decode("ascii") assert xor_key == 0x31 assert flag == "avito{REDACTED}" print(f"xor_key={xor_key:#04x}") print(f"flag={flag}")
Run it locally against the working clone:
python3 verify_flag.py honeykeeper_working.img
The exact verified output was:
xor_key=0x31 current_buffer file offsets: 0x06f8ee98..0x06f8eeaf c2_databus file offsets: 0x0a625cb0..0x0a625dbf flag=avito{REDACTED}
LD_PRELOAD hypothesis: No evidence supported user-space hooks such as readdir or fopen; the KPROBE and traffic-classifier programs conclusively identified the implementation._text = 0x54a3a3c0. Requiring 2 MiB alignment and validating page_offset_base corrected the false selection.No remote endpoint was needed or contacted at any point.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar