TasmotaHack - BDSec CTF 2025
Hey Hackers!
Welcome to the official writeup for our reverse engineering challenge, TasmotaHack — a puzzle we cooked up reverse engineering lovers and low-level tinkerers alike. Big thanks to the awesome open-source Tasmota project, whose firmware we used as a base to craft this challenge.
The challenge gives you a single file: tasmotaHack.bin
. At first glance, it looks like a regular ESP32 firmware dump — nothing out of the ordinary. But once we throw it into binwalk
, things start to get interesting.
We’re greeted with four embedded ELF binaries, all targeting x86. That’s your first hint: these definitely aren’t part of any standard ESP32 firmware. Something custom has been embedded here — and that’s exactly where the rabbit hole begins.
Let’s extract each one using the dd
command. Since we don’t know the exact size of each ELF segment yet, we’ll extract until the start of the next one. For the last ELF, we'll extract everything from the start point to the end of the file.
Here’s how to do it:
# Extract first ELF (from 2382256 to 2398640)
dd if=tasmotaHack.bin of=elf1.bin bs=1 skip=2382256 count=$((2398640 - 2382256))
# Extract second ELF (from 2398640 to 2415024)
dd if=tasmotaHack.bin of=elf2.bin bs=1 skip=2398640 count=$((2415024 - 2398640))
# Extract third ELF (from 2415024 to 2431408)
dd if=tasmotaHack.bin of=elf3.bin bs=1 skip=2415024 count=$((2431408 - 2415024))
# Extract fourth ELF (from 2431408 to end of file)
dd if=tasmotaHack.bin of=elf4.bin bs=1 skip=2431408
With all four ELF binaries extracted, the next step is to reverse engineer and analyze each of them. Normally, you'd go one by one — inspecting symbols, disassembling or decompiling code.
But to keep this writeup focused and digestible, we’ll skip straight to the one that matters: the second ELF binary (elf2.bin
). This one turned out to be the real deal — it’s where the actual flag is hidden.
Let’s dive into that one and see what it’s trying to tell us.
The function FUN_0001125b
looks like the main logic responsible for checking if the input provided by the user is correct — i.e., where the flag is validated.
/* WARNING: Function: __i686.get_pc_thunk.bx replaced with injection: get_pc_thunk_bx */
undefined4 FUN_0001125b(void)
{
int iVar1;
undefined4 uVar2;
undefined4 in_ECX;
int in_GS_OFFSET;
uint local_58;
byte local_54 [33];
byte local_33 [35];
int local_10;
undefined4 local_c;
local_10 = *(int *)(in_GS_OFFSET + 0x14);
local_c = in_ECX;
FUN_000111f1(&DAT_00012045,40000);
FUN_000111f1("oor...\n",40000);
iVar1 = FUN_00011064(local_33,0x23,uRam00000000);
if (iVar1 == 0) {
FUN_00011084("enter: ",1,0x15,uRam00000000);
uVar2 = 1;
}
else {
iVar1 = FUN_00011044(local_33," input\n");
local_33[iVar1] = 0;
iVar1 = FUN_000110a4(local_33);
if (iVar1 == 0x21) {
for (local_58 = 0; local_58 < 0x21; local_58 = local_58 + 1) {
local_54[local_58] = local_33[local_58] ^ (char)local_58 * 'U';
}
iVar1 = FUN_00011074(local_54,&DAT_00012024,0x21);
if (iVar1 == 0) {
FUN_000111f1("enied.\n",30000);
uVar2 = 0;
}
else {
FUN_000111f1("nput\n",30000);
uVar2 = 1;
}
}
else {
FUN_000111f1("nput\n",30000);
uVar2 = 1;
}
}
if (local_10 != *(int *)(in_GS_OFFSET + 0x14)) {
uVar2 = FUN_000113f4();
}
return uVar2;
}
It begins by calling a couple of functions (FUN_000111f1
) that appear to print strings to the output like "Welcome to backdoor" and so on.
Then it uses FUN_00011064
to read user input into the buffer local_33
, which is 35 bytes long. If the read fails (returns 0), it prints a prompt using FUN_00011084
and returns 1.
Assuming input is successfully read, it does some cleanup using FUN_00011044
(probably stripping newlines or formatting the string), and then checks if the length of the input is exactly 0x21
(33 in decimal). That seems to be the expected flag length.
If the input is the right length:
- It enters a loop where it XORs each byte of the input (
local_33
) with(index * 'U')
and stores the result intolocal_54
. - This means each byte is being transformed with a dynamic key: the index (0 through 32) multiplied by ASCII
'U'
(which is0x55
or85
). - Then it compares the transformed result (
local_54
) to a hardcoded buffer atDAT_00012024
usingFUN_00011074
.
If the transformed result matches the expected data:
- It prints success message and returns 1. Otherwise:
- It prints a rejection message and returns 0.
If the input length is incorrect, it prints rejection and also returns 1.
Now that we understand how the checker works, we need the exact 33 bytes it compares against.
FUN_00011074(local_54, &DAT_00012024, 0x21)
tells us two things:
- The comparison length is 0x21 (33) bytes.
- The source buffer starts at
DAT_00012024
.
When you dump that region, the first eight bytes (0x12024–0x1202B) are just 00
s (padding).
The real encrypted block begins at 0x1202C and continues through 0x1204C, giving a full 33‑byte table:
00 00 00 00 00 00 00 00
42 11 F9 BA 17 D2 8C 60
DE B8 20 D4 CF 0E C3 B5
37 CC B4 7C 97 8B 7F CD
9F 12 EB A4 13 E7 83 05
DD
Here's a simple Python script you can use to reverse the XOR logic and recover the flag:
enc = [
0x42, 0x11, 0xF9, 0xBA, 0x17, 0xD2, 0x8C, 0x60,
0xDE, 0xB8, 0x20, 0xD4, 0xCF, 0x0E, 0xC3, 0xB5,
0x37, 0xCC, 0xB4, 0x7C, 0x97, 0x8B, 0x7F, 0xCD,
0x9F, 0x12, 0xEB, 0xA4, 0x13, 0xE7, 0x83, 0x05,
0xDD
]
flag_bytes = [b ^ ((i * 0x55) & 0xFF) for i, b in enumerate(enc)]
flag = bytes(flag_bytes).decode('ascii')
print(flag)
I hope you enjoyed solving this challenge and found the writeup insightful.
Until next time — happy reversing, and see you around!