This challenge was the continuation of the Wreck IT 6.0 challenge titled "It Wrecked", which had a similar concept to this challenge (Linux forensics on a vulnerable binary).

Background

The idea for this challenge came when I was playtesting its intended behavior. While I was carving for stealer traffic evidence from the memory using tools like bulk_extractor, I couldn't find anything even though the stealer server received the stolen packets. This issue turned into a new idea for me with the logic of "if it got processed in memory, it should exist there." So I repeated the playtest and found out that my assumption was indeed correct: the ciphertext resides in the anonymous memory of the process.

Initial Triage

This challenge is a Linux memory forensics challenge. Of course you need its ISF to parse it using Vol3. But I'm not going to walk through that "regular" part because it's just an ordinary procedure whenever you triage a memory artifact.

vol -f memdump.lime --remote-isf-url 'https://github.com/Abyss-W4tcher/volatility3-symbols/raw/master/banners/banners.json' linux.pslist

I'll just run that command to see what processes are running in the current state of the captured memory. I use that --remote-isf-url so vol3 can directly check the repo and automatically use it as the ISF that fits the memory dump.

In the result of linux.pslist, there are two suspicious processes. One of them is intentionally a trap for sloppers who rely on automatic agentic AIs, and there are also anaother "decoy" that I put in the challenge.

0x8eeb17da0000  11618   11618   7402    session-handler 0       0       0       0       2026-05-12 15:02:14.826227 UTC Disabled
0x8eeb17da3d80  11619   11619   7402    gatekeeper      0       0       0       0       2026-05-12 15:02:14.826341 UTC Disabled

I'll skip that part and jump directly to the intended solution, which is the session-handler binary, and explain it.

If you decompile the binary, you will directly find some of the vulnerabilities in it.

      while ( *__errno_location() == 4 );
      perror("accept");
      close(fd);
      munmap(g_rwx_page, 0x1000u);
      return 0;
    }
    else
    {
      perror("listener");
      munmap(g_rwx_page, 0x1000u);
      return 1;
    }
  }
}
__int64 __fastcall serve_client(unsigned int a1)
{
  __int64 result; // rax
  _BYTE v2[512]; // [rsp+10h] [rbp-200h] BYREF

  send_text(a1, "WRECK IT 7.0 session handler\ncheck in before requesting status data\n");
  while ( 2 )
  {
    list_menu(a1);
    result = recv_line(a1, v2, 512);
    if ( result > 0 )
    {
      switch ( v2[0] )
      {
        case '1':
          create_session(a1);
          continue;
        case '2':
          show_active_session(a1);
          continue;
        case '3':
          run_status_callback(a1);
          continue;
        case '4':
          append_note(a1);
          continue;
        case '5':
          release_active_session(a1);
          continue;
        case '6':
          prime_lease(a1, 0);
          continue;
        case '7':
          prime_lease(a1, 1);
          continue;
        case '8':
          write_to_rwx_page(a1);
          continue;
        case '9':
          result = send_text(a1, "bye\n");
          break;
        default:
          send_text(a1, "unknown choice\n");
          continue;
      }
    }
    break;
  }
  return result;
}
__int64 __fastcall prime_lease(unsigned int a1, char a2)
{
  int v3; // r8d
  int v4; // r9d
  _BYTE v5[524]; // [rsp+10h] [rbp-220h] BYREF
  int v6; // [rsp+21Ch] [rbp-14h]
  void *s; // [rsp+220h] [rbp-10h]
  int free_slot; // [rsp+22Ch] [rbp-4h]

  free_slot = find_free_slot(g_leases, 8);
  if ( free_slot < 0 )
    return send_text(a1, "all lease slots are busy\n");
  s = malloc(0x50u);
  if ( !s )
    return send_text(a1, "lease allocation failed\n");
  memset(s, 0, 0x50u);
  snprintf((char *)s, 0x20u, "maint-lease-%d", free_slot);
  snprintf((char *)s + 32, 0x10u, "ops-%d", free_slot);
  *((_QWORD *)s + 6) = g_rwx_page;
  *((_QWORD *)s + 7) = 4096;
  *((_QWORD *)s + 8) = free_slot | (unsigned __int64)(time(0) << 8);
  *((_QWORD *)s + 9) = emit_safe_notice;
  if ( a2 )
  {
    send_text(a1, "lease template (hex blob, overlays the freed session chunk):\n> ");
    if ( recv_line(a1, v5, 512) <= 0 )
    {
      free(s);
      return send_text(a1, "template import aborted\n");
    }
    v6 = decode_hex_blob(v5, s, 80);
    if ( v6 < 0 )
    {
      free(s);
      return send_text(a1, "invalid template bytes\n");
    }
    if ( !*((_QWORD *)s + 6) )
      *((_QWORD *)s + 6) = g_rwx_page;
    if ( !*((_QWORD *)s + 7) )
      *((_QWORD *)s + 7) = 4096;
  }
  g_leases[free_slot] = s;
  return sendf(a1, (unsigned int)"lease %d primed\n", free_slot, (unsigned int)"lease %d primed\n", v3, v4);
}
void __fastcall create_session(unsigned int a1)
{
  int v1; // r8d
  int v2; // r9d
  _BYTE v3[512]; // [rsp+10h] [rbp-210h] BYREF
  void *ptr; // [rsp+210h] [rbp-10h]
  int free_slot; // [rsp+21Ch] [rbp-4h]

  free_slot = find_free_slot(g_sessions, 8);
  if ( free_slot >= 0 )
  {
    ptr = calloc(1u, 0x50u);
    if ( ptr )
    {
      *((_QWORD *)ptr + 6) = calloc(1u, 0x60u);
      if ( *((_QWORD *)ptr + 6) )
      {
        *((_QWORD *)ptr + 7) = 96;
        *((_QWORD *)ptr + 8) = ((__int64)free_slot << 32) ^ time(0);
        *((_QWORD *)ptr + 9) = emit_host_status;
        send_text(a1, "username:\n> ");
        if ( recv_line(a1, v3, 512) < 0
          || (copy_cstr(ptr, 32, v3), send_text(a1, "employee id:\n> "), recv_line(a1, v3, 512) < 0) )
        {
          free(*((void **)ptr + 6));
          free(ptr);
        }
        else // UAF vuln
        {
          copy_cstr((char *)ptr + 32, 16, v3);
          g_sessions[free_slot] = ptr;
          g_active_session = ptr;
          g_active_index = free_slot;
          g_active_is_stale = 0;
          sendf(
            a1,
            (unsigned int)"checked in at slot %d\nmaintenance profile ready\n",
            free_slot,
            (unsigned int)"checked in at slot %d\nmaintenance profile ready\n",
            v1,
            v2);
        }
      }
      else
      {
        free(ptr);
        send_text(a1, "note allocation failed\n");
      }
    }
    else
    {
      send_text(a1, "allocation failed\n");
    }
  }
  else
  {
    send_text(a1, "all session slots are busy\n");
  }
}

Vulnerability

The comment // UAF vuln I left in that decompiled output points at the exact bug. create_session allocates a Session struct (calloc(1, 0x50), 80 bytes) plus a separate 0x60-byte chunk for the note, then stores the pointer in two places: g_sessions[free_slot] (the "official" slot array) and g_active_session (a single global "currently checked in" pointer, basically a shortcut so the menu doesn't have to ask which slot you mean every time).

The bug lives in release_active_session (menu option 5):

free(g_active_session->note);
free(g_active_session);
g_sessions[g_active_index] = NULL;
g_active_is_stale = true;

It frees the session and clears the slot array entry, but g_active_session itself never gets set back to NULL. It just flips a flag (g_active_is_stale) that, spoiler, nothing outside of show_active_session actually checks. So after you release, g_active_session is a dangling pointer into freed heap memory. Textbook use-after-free.

Just freeing the session chunk doesn't do much on its own. You need something to land in that exact freed memory with attacker-controlled bytes, and that's what prime_lease (menu option 6/7) is for.

OpsLease and Session are the same size on purpose (both 0x50, 80 bytes), field for field:

offset  Session          OpsLease
0x00    username[32]     label[32]
0x20    employee_id[16]  operator_id[16]
0x30    note (ptr)       scratch_page (ptr)
0x38    note_cap         page_len
0x40    ticket_id        lease_id
0x48    status_cb (fn)   completion_hook (fn)

So if glibc hands the just-freed Session chunk back out for the next OpsLease allocation, whatever bytes you put into the OpsLease land at the same addresses the old Session used to occupy, including that last field, the function pointer.

calloc on this glibc would sometimes not reuse the tcache entry the way malloc does, which would break the overlap. Small detail, but it's the difference between the exploit working every time or being flaky.

Menu option 7 is the one that actually gives you control over the bytes. It lets you send a raw hex blob that gets decoded straight onto the freshly allocated OpsLease, byte for byte, with no validation on the function pointer field at all. So an attacker can craft a fake OpsLease where completion_hook (offset 0x48) is any address they want.

Offset 0x48 in the old Session layout is status_cb, the function pointer run_status_callback (menu option 3) blindly calls:

g_active_session->status_cb(client_fd, g_active_session);

g_active_session is still that stale pointer from before. After the overlap, the memory it points to isn't a Session anymore, it's the attacker's OpsLease, so status_cb at 0x48 is really whatever the attacker wrote as completion_hook. Call menu 3 and instead of getting CPU/RAM status back, you get a jump straight into attacker-controlled code.

Finding the payload

linux.malfind is the plugin that gets you moving here. It flags anonymous memory regions that look like injected code, and the RWX scratch page is exactly that: anonymous, no backing file, and executable, which is a big red flag compared to normal RW or RX file-backed mappings. That gives you the base address of the scratch page, which is also the seed the shellcode used for its whole key chain.

The second mapping (the RW-only one holding the ciphertext) won't get flagged by malfind the same way since it's not executable, so you're better off dumping the full set of anonymous mappings for the compromised PID and picking out the blob that isn't zero and isn't obviously plaintext. It tends to sit close to the flagged RWX page in the address space since both were created back to back by the same process, which helps narrow it down once you know what you're looking for.

Carving the Memory

When this challenge was released for the Qualification, there was a tool called "MemNixFS", which is the Linux-targeted version of MemProcFS. This tool allowed people to parse a memory dump and treat it like a disk dump. We can leverage this tool specifically to carve the ciphertext from the vulnerable binary's process memory.

When I did the playtest, MemNixFS was not yet released, so my own intended way was to carve it directly from the LiME file by inspecting the strings related to the binary. But if you think about it, it might sound like guesswork, yet there are a lot of other options I haven't explored yet.

C:\1Jonathan\apps\MemNixFS-1.2-win64>memnixfs --dump "C:\1Jonathan\CTFS\research-dir\vibe-authoring\wreck-it\dist\memdump.lime" --auto-fetch mount M:
Opening dump: C:\1Jonathan\CTFS\research-dir\vibe-authoring\wreck-it\dist\memdump.lime
Format: LiME
Detected kernel release: 5.15.0-25-generic (distro=ubuntu, banner shown above)
Loading symbols: C:\Users\Zenbook\AppData\Local\MemNixFS\symbols\5.15.0-25-generic.json.xz (auto-discover-cache)
/fs: 2873 dirs, 22958 regular files, 3277 symlinks (552 inodes skipped — no resolvable path, 1301 pseudo-fs skipped)
Loaded in 49.4s
Mounted at M:; press Ctrl+C to unmount.

Upon mounting the memory as an M: drive, you can directly check the process folder of the vulnerable binary, which resides at M:\proc\11618-session-handler, and you will see a dump file named proc.dmp. If you inspect the raw data of this dump, it has an "ELF" header, which means this is probably a coredump of the process.

If we check the maps file in the process directory of MemNixFS.

000000400000-000000401000 r--p 00000000 00:00 0          [mapped]
000000401000-000000403000 r-xp 00001000 00:00 0          [mapped]
000000403000-000000404000 r--p 00003000 00:00 0          [mapped]
000000404000-000000405000 r--p 00003000 00:00 0          [mapped]
000000405000-000000406000 rw-p 00004000 00:00 0          [mapped]
00000097b000-00000099c000 rw-p 00000000 00:00 0          
7f4543ac1000-7f4543ac4000 rw-p 00000000 00:00 0          
7f4543ac4000-7f4543aec000 r--p 00000000 00:00 0          [mapped]
7f4543aec000-7f4543c81000 r-xp 00028000 00:00 0          [mapped]
7f4543c81000-7f4543cd9000 r--p 001bd000 00:00 0          [mapped]
7f4543cd9000-7f4543cda000 ---p 00215000 00:00 0          [mapped]
7f4543cda000-7f4543cde000 r--p 00215000 00:00 0          [mapped]
7f4543cde000-7f4543ce0000 rw-p 00219000 00:00 0          [mapped]
7f4543ce0000-7f4543ced000 rw-p 00000000 00:00 0          
7f4543cfb000-7f4543cfe000 rw-p 00000000 00:00 0          
7f4543cfe000-7f4543d00000 r--p 00000000 00:00 0          [mapped]
7f4543d00000-7f4543d2a000 r-xp 00002000 00:00 0          [mapped]
7f4543d2a000-7f4543d35000 r--p 0002c000 00:00 0          [mapped]
7f4543d35000-7f4543d36000 rwxp 00000000 00:00 0          
7f4543d36000-7f4543d38000 r--p 00037000 00:00 0          [mapped]
7f4543d38000-7f4543d3a000 rw-p 00039000 00:00 0          [mapped]
7ffc0d431000-7ffc0d452000 rwxp 00000000 00:00 0          
7ffc0d578000-7ffc0d57c000 r--p 00000000 00:00 0          
7ffc0d57c000-7ffc0d57e000 r-xp 00000000 00:00 0          

Entries labeled with [mapped] and non-zero progressing file offsets (e.g., 00001000, 00028000, 00215000) represent file-backed mappings (such as the main binary image, libc.so, and ld.so) whose absolute file paths could not be resolved by the forensic tool. Anonymous memory regions are identified by a zeroed offset (00000000) and an empty trailing path field.

We also can see 7f4543d35000-7f4543d36000 rwxp 00000000 00:00 0 the rwx region which probably where the shellcode is residing.

Loading proc.dmp into GDB with pwndbg and disassembling the entry point of this RWX region reveals the shellcode behavior. It derives multiple 64-bit encryption keys directly from the base address of the RWX page (0x7f4543d35000) using bitwise rotations and multiplications.

It opens /secret.txt via SYS_open, dynamically allocates a buffer via sys_mmap, reads the file content into this buffer, and subsequently deletes the file from disk using SYS_unlink.

And then applies an 8-byte block shift, multi-round XOR operations against derived keys, and an index permutation loop in-place on the allocated buffer.

The shellcode exfiltrates the ciphertext over a TCP socket (192.168.154.1:4444), zeroes out the stack frame to destroy encryption keys, and enters an infinite SYS_pause loop.

Due to Linux x86_64's top-down memory allocation strategy, the SYS_mmap call placed the heap/scratch buffer into the first available contiguous gap right below ld-linux, mapping it at 0x7f4543cfb000.

And by checking the memory boundaries, 0x7f4543cfe000 is clearly ld-linux.so ([load14]-[load19]). So the whole layout basically looks like this:

▲ [0x000000000000 - LOWEST VIRTUAL ADDRESS]
│
├─ 0x400000        : Binary Base (.text, .rodata, .data)
├─ 0x97b000        : Heap (brk)
│
├─ 0x7f4543ac1000  : Anonymous Mmap (TLS / Runtime Allocations)
├─ 0x7f4543ac4000  : libc.so.6 Base
├─ 0x7f4543ce0000  : libc BSS / Anonymous Segment
├─ 0x7f4543cfb000  : Ciphertext Buffer (load13)
├─ 0x7f4543cfe000  : ld-linux.so Base (load14)
├─ 0x7f4543d35000  : Injected Shellcode RWX Stub (load17)
│
├─ 0x7ffc0d431000  : User Stack
└─ 0x7ffc0d57c000  : vDSO / Kernel Mapping
│
▼ [0x7fffffffffff - HIGHEST VIRTUAL ADDRESS]

This anonymous mapping yields the 34-byte ciphertext:

pwndbg> x/34bx 0x7f4543cfb000
0x7f4543cfb000: 0xbe    0x1f    0xef    0x19    0x80    0x54    0xe1    0x44
0x7f4543cfb008: 0x0f    0x90    0xc9    0x80    0xe8    0x0d    0x9f    0x93
0x7f4543cfb010: 0xd7    0xcc    0x9a    0xe8    0x0f    0xba    0xe3    0x58
0x7f4543cfb018: 0x54    0x37    0xf7    0x0a    0xb1    0x53    0xbc    0x67
0x7f4543cfb020: 0xe5    0x30

image

Solving the Challenge

Once you have the RWX base address and the ciphertext blob pulled out of the dump, recovering the flag is just replaying the same KDF chain on the solver side and running the cipher steps backwards.

You need to derive the stub_key, enc_key1, and enc_key2 exactly the way the shellcode did, then undo the custom encryption in reverse order by replaying the looping swap from the last index back to the first, xor enc_key2, xor enc_key1, then undo the 8-byte left rotate by shifting the last 8 bytes back to the front. After all of that you will get the real flag.