Solution: Operation E.D.I.T.H. | Coding Club CTF · tryhackme.codingclub
solution.md | 1037 lines (20.7 KB) # Operation E.D.I.T.H. Solution
# Starting Point
Important
On the external challenge portal you receive three files:
CHALLENGE_BRIEF.md — narrative and setup
auth_backup.sba — a custom binary archive in the Stark Binary Archive format
sba_extract.py.broken — a Python extraction script with three functions deliberately broken
There is no portal to visit yet. Everything begins offline.
Your first task is to understand the extractor, repair it, and use it to unpack the archive.
Open sba_extract.py.broken.
Read it carefully—not just the implementation, but also the docstrings. The comments on each broken function describe exactly what it is supposed to do.
Three functions must be reconstructed.
The archive uses a custom RLE compression scheme.
The decompression loop already exists, but the escape-byte handler (0xBC) is intentionally incomplete.
Note
The escape byte has two behaviors:
0xBC 0x00 → emit a literal 0xBC
0xBC [count] [byte] → repeat byte exactly count times
def rle_decompress (data: bytes ) -> bytes :
out = bytearray ()
i = 0
while i < len (data):
if data[i] == 0x BC :
length = data[i + 1 ]
if length == 0x 00 :
out.append( 0x BC )
i += 2
else :
byte = data[i + 2 ]
out.extend([byte] * length)
i += 3
else :
out.append(data[i])
i += 1
return bytes (out)
The Key Scheduling Algorithm (KSA) is already complete.
The PRGA is missing one crucial line described in the docstring as the Stark modification .
the implementation XORs j with the current key byte before continuing.
def stark_rc4_decrypt (data: bytes , key: bytes ) -> bytes :
S = list ( range ( 256 ))
j = 0
for i in range ( 256 ):
j = (j + S[i] + key[i % len (key)]) % 256
S[i], S[j] = S[j], S[i]
i = 0 ; j = 0
out = bytearray ()
for idx in range ( len (data)):
i = (i + 1 ) % 256
j = (j + S[i]) % 256
j = (j ^ key[idx % len (key)]) % 256
S[i], S[j] = S[j], S[i]
t = (S[i] + S[j]) % 256
out.append(data[idx] ^ S[t])
return bytes (out)
"A name becomes a secret when passed through the crucible."
The RC4 key is simply the raw 16-byte MD5 digest of the build server hostname.
def derive_rc4_key (hostname: str ) -> bytes :
import hashlib
return hashlib.md5(hostname.encode()).digest() The hostname is not present inside the extraction script.
Instead, it appears inside build_server.log, which is one of the files extracted from the archive.
STARK INDUSTRIES BUILD SERVER: edith-build-04.stark.internal edith-build-04.stark.internal as the input to derive_rc4_key.
After repairing all three functions, execute:
python3 sba_extract.py auth_backup.sba The archive extracts six files.
File Encrypted build_server.logNo syslog.logNo shield_blueprint_alpha.pngNo shield_blueprint_beta.pngNo StarkEmployeePortal.exeYes README.txtYes
Important
Read README.txt completely.
It contains the roadmap for the remainder of the challenge, including riddles and hints for every subsequent phase.
Two images are recovered:
shield_blueprint_alpha.png
shield_blueprint_beta.png
At first glance they appear identical.
The README provides the clue:
"When the crimson veil is reversed, what truth hides in the azure depths? Eight whispers rise from silence."
This describes two observations.
The red channel of the beta image is inverted.
The blue channel of the alpha image stores hidden data in its Least Significant Bits (LSBs).
Each group of 8 bits forms one ASCII character.
from PIL import Image
import numpy as np
alpha = np.array(Image.open( "shield_blueprint_alpha.png" ))
beta = np.array(Image.open( "shield_blueprint_beta.png" ))
assert np.all(beta[:, :, 0 ] == ( 255 - alpha[:, :, 0 ]))
blue_flat = alpha[:, :, 2 ].flatten()
bits = [ int (blue_flat[i]) & 1 for i in range ( 32 )]
chars = []
for i in range ( 0 , 32 , 8 ):
byte_val = sum (bits[i + b] << b for b in range ( 8 ))
chars.append( chr (byte_val))
print ( "" .join(chars)) Important
The README explains that although the blueprints encode 0427, any mathematical use of the value must treat it as the integer 427.
Keep the leading zero only when reading the hidden message.
# Reconstructing the Master Employee Secretstrings StarkEmployeePortal.exe The executable contains an authentication constants section.
Constant Value MACHINE_GUID7948eaa2-7dfd-417d-8fb4-f8b9e2a930e3BUILD_EPOCH1781259200SHIFT_OFFSET427
The binary also includes the exact derivation formula.
Formula:
EMPLOYEE_SECRET = SHA256(GUID_bytes + EPOCH_bytes + OFFSET_bytes)[:16] Each component is individually encoded into bytes before concatenation.
import hashlib
MACHINE_GUID = "7948eaa2-7dfd-417d-8fb4-f8b9e2a930e3"
BUILD_EPOCH = 1781259200
SHIFT_OFFSET = 427
raw = (
MACHINE_GUID .encode()
+ str ( BUILD_EPOCH ).encode()
+ str ( SHIFT_OFFSET ).encode()
)
EMPLOYEE_SECRET = hashlib.sha256(raw).digest()[: 16 ]
print ( EMPLOYEE_SECRET .hex()) Important
This is equivalent to:
SHA256(b"7948eaa2-7dfd-417d-8fb4-f8b9e2a930e31781259200427")
Keep the UUID hyphens.
Concatenate raw bytes.
Use "427", not "0427".
It is required throughout the remainder of the challenge.
# Authenticating at the PortalThe page immediately requests an authentication challenge from:
GET /api/v1/auth/challenge?username=mreyes The response contains five values.
Field Description challenge_idUnique identifier for the authentication challenge challengeRandom 32-character hexadecimal string saltAlways "stark_audit_v5" timestampUnix timestamp when the challenge was issued blink_sequenceArray containing six color identifiers
Important
The authentication challenge remains valid for 30 minutes .
# Step 1 · Generate the HMAC ResponseConcatenate the challenge string with the supplied salt:
challenge + "stark_audit_v5" Use EMPLOYEE_SECRET as the HMAC key.
import hmac
import hashlib
challenge = "<challenge>"
salt = "stark_audit_v5"
message = (challenge + salt).encode()
response_hex = hmac.new(
EMPLOYEE_SECRET ,
message,
hashlib.sha256
).hexdigest() Submit the resulting hexadecimal string in the HMAC-SHA256 Response field.
# Step 2 · Solve the Blink SequenceThe portal displays six colored circles.
Group them into consecutive pairs.
Use the reference table displayed on the page.
The row corresponds to the first color.
The column corresponds to the second color.
Enter the resulting three-character code into the Enter Blink Code field.
Upon successful authentication the server issues a session_token and redirects you to the dashboard.
This marks the session as having accessed the dashboard and enables downloading the network capture.
# Downloading the HYDRA CaptureClick Download HYDRA Capture .
GET /api/v1/artifacts/hydra-capture The response includes the header:
The browser stores this value inside Local Storage as:
Important
The PCAP token is single-use .
If it expires or is consumed, simply download the capture again to obtain a fresh token.
Once downloaded, proceed to the calibration page using the Calibrate button.
The calibration interface displays two waveforms.
The cyan waveform is the reference.
The amber waveform is controlled by four sliders.
There is no numerical feedback.
The server only reports whether your submission passes or fails.
Parameter Symbol Target Tolerance Frequency ω0.82± 0.03 Phase φ2.14± 0.05 Amplitude A0.91± 0.03 Skew C0.07± 0.02
Note
You are limited to 6 submissions per minute .
The server provides no directional guidance. Use the visual alignment of the two waveforms.
Submit the calibration values:
import requests
headers = {
"Authorization" : f "Bearer { session_token } "
}
resp = requests.post(
"http://134.209.148.23/api/v1/calibrate/submit" ,
json = {
"freq" : 0.82 ,
"phase" : 2.14 ,
"amp" : 0.91 ,
"skew" : 0.07 ,
},
headers = headers,
)
print (resp.json()) Successful calibration redirects the session to:
Important
Calibration must be completed before the Director Terminal can be used.
# Breaking the Network CaptureOpen HYDRA_CAPTURE.pcapng using Wireshark or another packet analysis tool.
The capture contains four Enhanced Packet Blocks (EPBs) . Each stores an AES-256-CBC encrypted JSON payload prefixed with a 16-byte initialization vector.
EPB Host Status 1 REYES-DESKTOPDecoy — session ends in 401 2 REYES-LAPTOPActive session 3 HYDRA-SNIFFERDecoy 4 REYES-WORKSTATIONDecoy — fake ZKP parameters
Warning
Only EPB 2 contains valid authentication parameters.
EPB 4 intentionally contains fake values that will cause every zero-knowledge proof to fail.
# Recovering the Diffie-Hellman Private KeyEPB 2 contains the Diffie-Hellman exchange.
Parameter Value Prime p 0x9B15E3F0A1823B4E6C2D8A9F123C4B5A6E7D8F901BC2A3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4Generator 2Server Public Key Read from the dh_server_hello packet
The client private key is generated from a predictable Linear Congruential Generator.
import hashlib
NETBIOS_ID = "REYES-LAPTOP"
HOST_KEY = "STARK-FALLBACK-KEY-2026"
BUILD_EPOCH = 1781259200
seed_input = (
( NETBIOS_ID + HOST_KEY ).encode()
+ str ( BUILD_EPOCH ).encode()
)
seed = int .from_bytes(
hashlib.sha256(seed_input).digest()[: 8 ],
"big"
) Generate the private exponent:
LCG_A = 6364136223846793005
LCG_C = 1442695040888963407
MOD = 2 ** 64
def lcg_next (state):
return ( LCG_A * state + LCG_C ) % MOD
X1 = lcg_next(seed)
X2 = lcg_next(X1)
b = (X1 << 32 ) | X2
# Computing the Shared SecretRecover the shared Diffie-Hellman secret.
K = pow (A, b, p)
aes_key = hashlib.sha256(
K.to_bytes( 64 , "big" )
).digest()
Each encrypted payload is structured as:
[16-byte IV][ciphertext...] Decrypt it using AES-256-CBC.
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers import algorithms
from cryptography.hazmat.primitives.ciphers import modes
from cryptography.hazmat.primitives import padding
def decrypt_cbc (payload: bytes , key: bytes ):
iv = payload[: 16 ]
ciphertext = payload[ 16 :]
cipher = Cipher(
algorithms.AES(key),
modes.CBC(iv)
)
decryptor = cipher.decryptor()
padded = (
decryptor.update(ciphertext)
+ decryptor.finalize()
)
unpadder = padding.PKCS7( 128 ).unpadder()
return (
unpadder.update(padded)
+ unpadder.finalize()
) The decrypted JSON contains the observed Zero-Knowledge Proof parameters.
Field Description NRSA modulus public_keys_vPublic verification values kNumber of secrets
Important
Save these values.
They are required throughout the Director Terminal authentication sequence.
You are presented with a command-line interface.
The terminal supports the following commands.
Command Description helpDisplay available commands initInitialize a Director session connectOpen the authentication WebSocket schemaDisplay the WebSocket message schema statusDisplay session status and timeout information
# Step 1 · Initialize the SessionPOST /api/v1/session/init The server performs the following actions:
Validates the authenticated session.
Confirms calibration has been completed.
Generates a temporary nonce.
Generates a four-color flash sequence.
Returns both values to the client.
The Flash Code panel displays:
A cycling colored indicator.
Four numbered color blocks.
A two-character input field.
Split them into two pairs.
Use the Asgardian Lexicon table to convert the pairs into a two-character code.
Enter the result into the 2-CHAR CODE field.
The terminal opens the WebSocket:
ws://134.209.148.23/api/v1/admin/auth/ws
?pcap_token=<pcap_token>
&nonce=<init_nonce>
&flash_code=<flash_code> The server validates the request in the following order.
PCAP token exists.
Nonce is valid.
Flash code is correct.
Consume the PCAP token.
Important
An incorrect flash code does not consume the PCAP token.
Simply run init again to obtain a new flash sequence.
If the PCAP token has already been consumed, return to the dashboard and download the capture again.
# Step 3 · Server InitializationAfter the WebSocket is established, the server immediately sends:
{
"event" : "server_init" ,
"nonce" : "..." ,
"captcha_image" : "..." ,
"zkp_params" : {
"N" : "..." ,
"v" : [ "..." , "..." , "..." , "..." ],
"k" : 4
}
} Important
Save the session nonce received here.
This is not the nonce returned by session/init.
It is later used as the Additional Authenticated Data (AAD) during AES-GCM decryption.
The server also displays a CAPTCHA.
# Step 4 · Compute the ZKP SecretsUsing the recovered EMPLOYEE_SECRET and the modulus N, derive the four private secrets.
N = int ( "<N>" , 16 )
secrets_s = []
for i in range ( 4 ):
h = hashlib.sha256(
EMPLOYEE_SECRET + bytes ([i])
).digest()[: 8 ]
s = int .from_bytes(h, "big" ) % N
secrets_s.append(s) If the verification fails, the employee secret was derived incorrectly.
# Step 5 · Zero-Knowledge Proof — Round Oneimport random
r1 = random.randint( 1 , N - 1 )
x1 = pow (r1, 2 , N) {
"event" : "client_commit" ,
"captcha_input" : "<captcha>" ,
"x" : "<x1>"
} The server replies with a challenge vector.
y1 = r1
for j, bit in enumerate (e1):
if bit:
y1 = (y1 * secrets_s[j]) % N {
"event" : "client_respond" ,
"y" : "<y1>"
} If verification succeeds, the server advances to Round Two.
# Step 6 · Zero-Knowledge Proof — Round TwoRepeat the same procedure using a new random witness.
r2 = random.randint( 1 , N - 1 )
x2 = pow (r2, 2 , N) Generate the second proof exactly as before.
Important
Save the value of y2.
It becomes one of the components used to derive the final AES key.
{
"event" : "server_pow" ,
"salt" : "..." ,
"prefix" : "000000"
} Search for a nonce satisfying:
hashlib.sha256(
f " { salt }{ nonce } " .encode()
).hexdigest().startswith( "000000" ) pow_nonce = 0
while True :
if hashlib.sha256(
f " { salt }{ pow_nonce } " .encode()
).hexdigest().startswith( "000000" ):
break
pow_nonce += 1 Submit the discovered nonce.
Tip
A faster implementation can significantly reduce the search time.
Important
Save pow_nonce.
It is required during the final decryption stage.
# Step 8 · Receive and Decrypt the FlagAfter successfully completing the Proof of Work, the server sends the final message.
{
"event" : "directors_log" ,
"session_nonce" : "..." ,
"encrypted_flag" : {
"nonce" : "..." ,
"ciphertext" : "..."
}
} Important
Three different nonces exist throughout the challenge.
Do not confuse them.
Nonce Source Purpose Init Nonce POST /api/v1/session/initUsed when establishing the WebSocket Session Nonce server_init eventAdditional Authenticated Data (AAD) for AES-GCM GCM Nonce encrypted_flag.nonceAES-GCM initialization vector
Construct the key material by concatenating all six recovered values.
Component Description secrets_s[0]Stone 1 secrets_s[1]Stone 2 secrets_s[2]Stone 3 secrets_s[3]Stone 4 y2Stone 5 pow_nonceStone 6
key_material = (
hex (secrets_s[ 0 ]) +
hex (secrets_s[ 1 ]) +
hex (secrets_s[ 2 ]) +
hex (secrets_s[ 3 ]) +
hex (y2) +
str (pow_nonce)
)
aes_key = hashlib.sha256(
key_material.encode()
).digest()
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
gcm_iv = bytes .fromhex(
encrypted_flag[ "nonce" ]
)
ciphertext = bytes .fromhex(
encrypted_flag[ "ciphertext" ]
)
aad = session_nonce.encode()
flag = AESGCM(aes_key).decrypt(
gcm_iv,
ciphertext,
aad,
).decode()
print (flag) rvcectf{SH13LD_C0GN1T1V3_4UTH}
Warning
If the WebSocket closes with code 4008, the authentication session has expired.
Recovery procedure:
Return to the dashboard.
Download a fresh PCAP.
Obtain a new pcap_token.
Return to the Director Terminal.
Run init.
Complete the connection process again.
Note
The calibration endpoint provides only a binary pass/fail result.
There is no directional feedback.
Use the waveform visualization to manually align the curves.
Tip
The CAPTCHA character set is:
ABCDEFGHJKMNPQRSTUVWXYZ23456789
Characters such as 0, O, 1, I, and L are never used.
If the CAPTCHA is unreadable, reconnect to obtain a new one. Doing so consumes the current pcap_token, so you may need to download a fresh PCAP afterward.
Flag
rvcectf{SH13LD_C0GN1T1V3_4UTH}