CTF-Writeups

Sōkyoku — Covert Timing Channel (incident.log) Writeup

Category: Forensics / Covert Channels
Author: N!L
Flag format: nexus{...}

Flag: nexus{1m_r3a11y_pr0ud_0f_yuu_d3t3ct0r}


Summary

This challenge hides data inside seemingly benign system logs by abusing timing anomalies:


Files


Key Observations

1) Covert channel carrier: scheduler delay microseconds

In the log there are hundreds of entries like:

kernel: ... scheduler: tick processing delayed (23000us) on cpu7

When extracted, all delays satisfy:

That’s a classic “symbol stream” hiding in timing values.

2) Framing: audit comm_marker boundaries

The log includes explicit markers:

audit: comm_marker group=LOTUS state=SEND_START ...
audit: comm_marker group=LOTUS state=SEND_END ...
audit: comm_marker group=SPIRE state=SEND_START ...
audit: comm_marker group=LOTUS state=PAYLOAD_START ...
audit: comm_marker group=LOTUS state=PAYLOAD_END ...

These timestamps divide the delay stream into multiple message segments (two for LOTUS + two for SPIRE + one payload).


Step-by-step Solution

Step 1 — Parse markers and scheduler-delay lines

  1. Parse all audit: comm_marker lines into (timestamp, group, state).
  2. Parse all scheduler-delay lines into (timestamp, delay_us).

This lets you select “the delays that occurred between marker X and marker Y”.

Step 2 — Segment the stream

Using the markers, the scheduler stream splits into five segments:

  1. LOTUS SEND (handshake)
  2. SPIRE SEND (response)
  3. LOTUS SEND (follow-up / instructions)
  4. SPIRE SEND (handoff)
  5. LOTUS PAYLOAD (binary payload)

The counts in the provided log add up exactly to the full scheduler-delay list, confirming the framing is correct.

Step 3 — Map delay symbols to Base64 characters

Let:

symbol = delay_us // 1000

Then map to Base64 with an off-by-one shift:

alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
char = alphabet[symbol - 1]

This produces a Base64 string per segment.

Step 4 — Base64-decode each segment

Segments 1–4 decode cleanly to ASCII messages:

Segment 5 decodes to a binary blob (not plaintext).

Step 5 — Recover the XOR key from audit “key_material”

Later in the log, there are AppArmor audit entries containing:

key_material="c2hhZG93"
key_material="ZnJhZ18wXw=="
key_material="ZnJhZ18xXw=="
...

These are Base64 strings. Decoding them yields:

The LOTUS instruction explicitly mentions:

“Extract shadow-class identifier.”

So the XOR key is:

key = b"shadow"

Step 6 — XOR-decrypt the payload → flag

The payload bytes are XORed with the repeating key:

plaintext[i] = payload[i] XOR key[i % len(key)]

Decryption reveals:

nexus{1m_r3a11y_pr0ud_0f_yuu_d3t3ct0r}

Proof of Work — Full Solver Script

Save as solve.py and run against incident.log.

import re
import base64
from datetime import datetime

LOG = "incident.log"
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

def parse_dt(line: str):
    p = line.split()
    return datetime.strptime(p[0] + " " + p[1], "%Y-%m-%d %H:%M:%S")

def b64decode_relaxed(s: str) -> bytes:
    s = s + "=" * ((4 - (len(s) % 4)) % 4)
    return base64.b64decode(s)

with open(LOG, "r", errors="replace") as f:
    lines = f.read().splitlines()

# --- collect comm markers ---
markers = []
for l in lines:
    if "audit: comm_marker" in l:
        dt = parse_dt(l)
        m = re.search(r'group=(\w+)\s+state=(\w+)', l)
        if m:
            markers.append((dt, m.group(1), m.group(2)))

def get_marker(group, state, occurrence=1):
    hits = [dt for dt,g,s in markers if g == group and s == state]
    if len(hits) < occurrence:
        raise ValueError(f"marker not found: {group} {state} occ={occurrence}")
    return hits[occurrence - 1]

# marker windows (per the given log)
lotus1_start = get_marker("LOTUS", "SEND_START", 1)
lotus1_end   = get_marker("LOTUS", "SEND_END",   1)
spire1_start = get_marker("SPIRE", "SEND_START", 1)
spire1_end   = get_marker("SPIRE", "SEND_END",   1)
lotus2_start = get_marker("LOTUS", "SEND_START", 2)
lotus2_end   = get_marker("LOTUS", "SEND_END",   2)
spire2_start = get_marker("SPIRE", "SEND_START", 2)
spire2_end   = get_marker("SPIRE", "SEND_END",   2)
pay_start    = get_marker("LOTUS", "PAYLOAD_START", 1)
pay_end      = get_marker("LOTUS", "PAYLOAD_END",   1)

# --- scheduler delay stream ---
sched = []
for l in lines:
    if "scheduler: tick processing delayed" in l:
        dt = parse_dt(l)
        m = re.search(r"\((\d+)us\)", l)
        if m:
            sched.append((dt, int(m.group(1))))

def window_to_b64(start, end):
    chunk = [us for (dt, us) in sched if start <= dt <= end]
    # off-by-one mapping discovered in analysis:
    return "".join(alphabet[(us // 1000) - 1] for us in chunk)

def decode_window(start, end):
    return b64decode_relaxed(window_to_b64(start, end))

# decode the four plaintext segments
m1 = decode_window(lotus1_start, lotus1_end).decode()
m2 = decode_window(spire1_start, spire1_end).decode()
m3 = decode_window(lotus2_start, lotus2_end).decode()
m4 = decode_window(spire2_start, spire2_end).decode()

print(m1)
print(m2)
print(m3)
print(m4)

# payload
payload_b64 = window_to_b64(pay_start, pay_end)
payload = b64decode_relaxed(payload_b64)

# XOR key from key_material -> "shadow"
key = b"shadow"
flag = bytes(payload[i] ^ key[i % len(key)] for i in range(len(payload)))

print(flag.decode())

Expected final line:

nexus{1m_r3a11y_pr0ud_0f_yuu_d3t3ct0r}

Flag

nexus{1m_r3a11y_pr0ud_0f_yuu_d3t3ct0r}