Four random challenges, four different techniques

posted: August 9, 2026

Ran through a batch of random challenges in one sitting, writing all four up together instead of splitting them into separate posts. Each one uses a completely different technique, so it works well as one combined set of notes.


Part 1: A stack string built byte by byte

This one only got partially documented on my end — I’ve only got a single screenshot from this session, so this section covers what’s decodable from it and stops where the screenshot stops.

Opening the binary in IDA and landing near the entry point, the very first thing in the function is a long, repetitive block of single-byte stores onto the stack:

sub     rsp, 40h
mov     [rbp+var_1], 6Eh
mov     [rbp+var_2], 65h
mov     [rbp+var_3], 78h
mov     [rbp+var_4], 75h
mov     [rbp+var_5], 73h
mov     [rbp+var_6], 7Bh
mov     [rbp+var_7], 64h
mov     [rbp+var_8], 31h
mov     [rbp+var_9], 73h
mov     [rbp+var_A], 34h
mov     [rbp+var_B], 73h
mov     [rbp+var_C], 73h
mov     [rbp+var_D], 33h
mov     [rbp+var_E], 6Dh
mov     [rbp+var_F], 62h
mov     [rbp+var_10], 6Ch
mov     [rbp+var_11], 33h
mov     [rbp+var_12], 5Fh
mov     [rbp+var_13], 79h
mov     [rbp+var_14], 30h
mov     [rbp+var_15], 75h
mov     [rbp+var_16], 72h
mov     [rbp+var_17], 5Fh
mov     [rbp+var_18], 64h
mov     [rbp+var_19], 30h
mov     [rbp+var_1A], 75h
mov     [rbp+var_1B], 62h
mov     [rbp+var_1C], 74h
...

Order Of Hex Bytes This is a stack string — instead of a plaintext string literal in .rodata where it’d show up immediately under Strings, the string gets written onto the stack one byte at a time as immediate values baked directly into the instructions. It defeats a plain string search but does nothing against just reading the immediates back off, since each mov is right there in the disassembly holding one ASCII byte.

Reading the immediates in order and converting each to its character:

6E 65 78 75 73 7B 64 31 73 34 73 73 33 6D 62 6C 33 5F 79 30 75 72 5F 64 30 75 62 74
n  e  x  u  s  {  d  1  s  4  s  s  3  m  b  l  3  _  y  0  u  r  _  d  0  u  b  t}

That gives nexus{d1s4ss3mbl3_y0ur_d0ubt}, already clearly reading as “disassemble_your_doubt(s)”. The stack allocation is sub rsp, 40h (64 bytes), so there’s more string past var_1C that this screenshot doesn’t capture — I don’t have the rest of the byte list to finish the decode. If I dig up the missing part of this session later I’ll come back and close this section out properly.


Part 2: Hacking a game’s score with Cheat Engine

A little arcade-style shooter with a score counter, a “High” score, and a “Target” that isn’t realistically reachable by actually playing.

Step 1: Sizing up the target

The HUD shows:

  • Score: 500
  • Target: 1,000,000
  • High: 500

A target that far out of reach usually means the win condition is a straightforward score >= target check somewhere in the update loop, and the score itself is a plain variable sitting in process memory — no need to actually earn it.

Step 2: Attaching Cheat Engine

Paused the game first, so the value can’t drift mid-scan, then attached Cheat Engine to the process (00001BEC-chall.exe in the title bar).

Cheat Engine attached, scanning for the score value

Step 3: Exact Value scan

Since the current score is already known (500), this goes straight to an Exact Value scan instead of the usual unknown-initial-value → narrow-down process:

  • Scan Type: Exact Value
  • Value Type: 4 Bytes
  • Value: 500

One scan, one result: address 16F78C82118, value 500, matching both the “Previous” and “First” columns.

Step 4: Editing the value

Double-clicked the Value field for that address and set it to 1000000, matching the target. Cheat Engine writes it directly into the process’s memory.

Step 5: Resuming

Unpaused the game. The next check of score >= target passes immediately, triggering the win state and drawing the flag on screen:

Win screen after the memory edit, flag drawn over the game

nexus{ch4ng3_th3_rul35_br34k_th3_g4m3}

Part 3: An env-var-gated crackme — XOR, rotation, and base64

An ELF binary this time. No password prompt — the “input” is an environment variable the binary expects to already be set before it does anything.

Step 1: Running it cold

$ ./chall
Error: DECRYPT_KEY not set

Setting an arbitrary value gets further, but not much:

$ export DECRYPT_KEY="KVP3msmE90DFycH+x9vLwqfAyrPbw62iYfQN2A=="
$ ./chall
Invalid key

(That guess was the base64 blob from config.bin itself, just to rule out the key and the payload being the same thing. They weren’t.)

Invalid key rejection after setting an arbitrary DECRYPT_KEY

Step 2: Decompiling main

F5 in IDA on main lays out the whole check:

v16[1] = __readfsqword(0x28u);
config_key = getenv("DECRYPT_KEY", argv, envp);
if ( config_key )
{
    v4 = config_key;
    v5 = j_strlen_ifunc(config_key);
    v6 = 0;
    if ( v5 == 20 )
    {
        while ( EXP[v6] == (__ROL1__(*(_BYTE *)(v4 + v6), 3) ^ 0xAA) )
        {
            if ( ++v6 == 20 )
            {
                key = fopen64("config.bin", "rb", 20, EXP);
                v9 = key;
                if ( !key )
                    return main_cold();
                fseek(key, 0, 2);
                v10 = ftell(v9);
                fseek(v9, 0, 0);
                v11 = malloc(v10 + 1);
                _fread_chk(v11, v10 + 1, 1, v10, v9);
                *(_BYTE *)(v11 + v10) = 0;
                fclose(v9);
                v12 = b64dec(v11, v10, v16);
                free(v11);
                v13 = v16[0];
                v14 = malloc(v16[0] + 1LL);
                if ( v13 )
                {
                    for ( i = 0; i != v13; ++i )
                        *(_BYTE *)(v14 + i) = __ROR1__(*(_BYTE *)(v12 + i) ^ *(_BYTE *)(v4 + i % 0x14), 3);
                }
                *(_BYTE *)(v14 + v13) = 0;
                puts(v14);
                free(v12);
            }
        }
    }
}
fwrite("Invalid key\n", 1, 12, stderr);

Decompiled main showing the getenv check and length gate

Broken down:

  • getenv("DECRYPT_KEY", ...) pulls the env var. Missing entirely → the DECRYPT_KEY not set message.
  • strlen(config_key) == 20 — hard length gate, same idea as the XOR crackme from before: wrong length, immediate reject.
  • The while loop validates each byte with a two-step transform: EXP[i] == (ROL(key[i], 3) ^ 0xAA).
  • If all 20 bytes pass, it opens config.bin, reads it, base64-decodes it, and decrypts the result using the same key cycled every 20 bytes: output[i] = ROR(decoded[i] ^ key[i % 20], 3).

Two layers: EXP exists purely to validate the key, and config.bin is a separate encrypted payload that only gets decrypted once the key checks out.

Step 3: The EXP array

EXP is a hardcoded byte array sitting in .rodata:

.rodata:0048918A public EXP
EXP     db 78h, 69h, 0Bh, 23h, 38h, 50h, 0D9h, 33h, 19h, 33h, 39h
        db 50h, 8, 39h, 23h, 0B1h, 0Bh, 60h, 2 dup(2Bh), 1Ch dup(0)

EXP array sitting in .rodata

Read out (2 dup(2Bh) = two more 0x2B bytes, 1Ch dup(0) = 28 bytes of trailing zero padding, irrelevant since the loop only checks the first 20):

78 69 0B 23 38 50 D9 33 19 33 39 50 08 39 23 B1 0B 60 2B 2B

Step 4: Undoing the transform

Rather than doing the bit rotation by hand for 20 bytes, I ran it through two online tools.

First, undo the XOR — dcode.fr’s XOR Cipher tool, hex key AA:

XORing EXP against the 0xAA constant

That strips the ^ 0xAA, leaving ROL(key[i], 3):

D2 C3 21 89 92 FA 73 99 B3 99 93 FA A2 93 89 1B A1 CA 82 82

A left-rotate undoes with a right-rotate of the same amount — CyberChef, Rotate right, amount 3:

CyberChef Rotate right by 3, undoing the ROL1

5A 78 24 31 52 5F 6E 33 76 33 72 5F 54 72 31 63 34 59 50 50

Hex to ASCII:

Zx$1R_n3v3r_Tr1c4YPP

Hex-to-ASCII conversion of the recovered key bytes

Step 5: Cleaning up the transcription

A few of those characters are ambiguous glyphs in a monospace disassembly font — 0/8, $/4, 0/P all look close enough to misread when copying 20 hex bytes by eye. n3v3r and Tr1c4 clearly want to read as leetspeak “never” and “Trick”, so the $ and the trailing PP were the likely misreads. Swapped those and tested directly against the binary:

$ export DECRYPT_KEY="Zx41R_n3v3r_Tr1c4Y00"
$ ./chall
nexus{3nv_v4r5_4r3_p0w3rful}

Correct key set, binary prints the decrypted flag

Step 6: Full math, start to finish

key[i]    = ROR( EXP[i] ^ 0xAA, 3 )              # → "Zx41R_n3v3r_Tr1c4Y00"
config.bin (base64) → decoded bytes[i]
flag[i]   = ROR( decoded[i] ^ key[i % 20], 3 )    # → decrypted flag
EXP:      78 69 0B 23 38 50 D9 33 19 33 39 50 08 39 23 B1 0B 60 2B 2B
key:      Zx41R_n3v3r_Tr1c4Y00

config.bin (base64): KVP3msmE90DFycH+x9vLwqfAyrPbw62iYfQN2A==
decrypted:            nexus{3nv_v4r5_4r3_p0w3rful}

Part 4: A flag built from word/byte pairs, reordered using a notes file

This one comes with a companion notes.txt sitting next to the binary (tapi) — a list of made-up fantasy-style words, followed by the flag itself in plaintext at the bottom:

Crerceon
Ezains
Ummufh
Zonnu
Vinzo
Cuzads
Myrrh
Ohols
Tarquts
Azuris
Mohtus
Pheilons
Throrqiek
Khehlan
Gozreth
Lendrens
Munis
Inphas
Thauvi
Pakroith
Drathis
Throquels
Krolkel
Orthis
Creiqex
Zimil
Zohir
Honzor
Falnain
Ukteils

nexus{th3_c0d3_l135_t0_1ts3lf}

So the flag is already sitting right there. What’s more interesting is that the binary independently reconstructs the exact same flag if you process it correctly, which is worth documenting since it’s the actual mechanism behind the challenge.

Step 1: Looking at main

tapi is an unstripped ELF, so main is easy to read directly. It’s a long chain of calls, each one loading a word string and a single byte before calling a helper:

lea     rax, aXaroth      ; "Xaroth"
mov     rsi, rax
mov     edi, 5Ah          ; 'Z'
call    func_01
lea     rax, aDrathis     ; "Drathis"
mov     rsi, rax
mov     edi, 74h          ; 't'
call    func_02
lea     rax, aCuzads      ; "Cuzads"
mov     rsi, rax
mov     edi, 7Bh          ; '{'
call    func_03
lea     rax, aBelthor     ; "Belthor"
mov     rsi, rax
mov     edi, 51h          ; 'Q'
call    func_04
lea     rax, aMyrkul      ; "Myrkul"
mov     rsi, rax
mov     edi, 57h          ; 'W'
call    func_05
lea     rax, aThrorqiek   ; "Throrqiek"
mov     rsi, rax
mov     edi, 64h          ; 'd'
call    func_06
lea     rax, aEzains      ; "Ezains"
...

Each call passes a word plus a byte into a helper function

Each call passes one word plus one raw byte into what IDA guesses is a process_char(word, byte)-style helper, called once per character.

Step 2: Not every word belongs to the flag

Further down, more of these calls show up:

call    func_11
lea     rax, aVelkor       ; "Velkor"
mov     rsi, rax
mov     edi, 41h           ; 'A'
call    func_12
lea     rax, aKrolkel      ; "Krolkel"
mov     rsi, rax
mov     edi, 5Fh           ; '_'
call    func_13
lea     rax, aOhols        ; "Ohols"
mov     rsi, rax
mov     edi, 68h           ; 'h'
call    func_14
lea     rax, aUkteils      ; "Ukteils"
mov     rsi, rax
mov     edi, 7Dh           ; '}'
call    func_15
lea     rax, aZephron      ; "Zephron"
mov     rsi, rax
mov     edi, 4Bh           ; 'K'
call    func_16
lea     rax, aThauvi       ; "Thauvi"
mov     rsi, rax
mov     edi, 35h           ; '5'
call    func_17
lea     rax, aQorthis      ; "Qorthis"
mov     rsi, rax
mov     edi, 4Dh           ; 'M'
call    func_18

Later calls in main, function names non-sequential

Cross-referencing the words used in main against notes.txt: we just compare them with names in the notes.txt with the word bytes and yea

Step 3: The actual ordering

The call order in main isn’t the right order to read the bytes in — Cuzads ({) gets called third in main, but it’s the 6th word in notes.txt. The correct approach is:

  1. For every call whose word also appears in notes.txt, record the (word, byte) pair.
  2. Ignore calls whose word doesn’t appear in notes.txt at all — those are just extra, unrelated calls.
  3. Sort the recorded pairs by the position of their word in notes.txt, top to bottom, not by call order in main.
  4. Concatenate the bytes as ASCII in that order.

notes.txt has 31 words, but one of them — the lone t on its own line — doesn’t correspond to any real character and gets skipped, leaving exactly 30 words to fill the 30 characters of nexus{...}.

Checking that against the known word/byte pairs confirms it lines up exactly with the word’s position in the list:

word rank in notes.txt word byte flag position flag char
6 Cuzads 7Bh 5 {
9 Ohols 68h 7 h
14 Throrqiek 64h 12 d
20 Thauvi 35h 18 5
22 Drathis 74h 20 t
24 Krolkel 5Fh 22 _
31 Ukteils 7Dh 29 }

Filling in the rest of the word/byte pairs the same way and reading them off in notes.txt order reconstructs:

nexus{th3_c0d3_l135_t0_1ts3lf}

— matching the plaintext copy already sitting at the bottom of notes.txt.


Wrap-up

nexus{d1s4ss3mbl3_y0ur_d0ubt...}        (partial — stack string)
nexus{ch4ng3_th3_rul35_br34k_th3_g4m3}  (Cheat Engine memory edit)
nexus{3nv_v4r5_4r3_p0w3rful}            (env-var XOR + ROL chain)
nexus{th3_c0d3_l135_t0_1ts3lf}          (word list / byte reordering)

Four different techniques: reading immediates straight out of a stack string, live memory editing with no static analysis at all, a two-layer XOR/rotate cipher with a base64 payload behind it, and a lookup-and-reorder puzzle across a set of word/byte pairs.

Special Thanks Towards the creator @Zx41R