August 4, 2026
How I Got Full RCE (And Accidentally Skipped Two Flags Doing It) — Hacker101 CTF…
Hacker101 CTF Writeup — Y2FuIHlvdSByZWNvbj8

By Kagan Coskun
6 min read
First look
Bare bones app. Upload form with two fields: a text input called filename, and an actual file input called upload. Uploaded a random PNG just to see the request shape in Burp:
`- WebKitFormBoundary… Content-Disposition: form-data; name="filename" image_name
- WebKitFormBoundary… Content-Disposition: form-data; name="upload"; filename="appicon_mf.png" Content-Type: image/png [binary png data]`
Two file names in one request — the filename text field and the actual multipart filename. That's already a little suspicious, two separate places where a name gets specified usually means the backend is doing something interesting with at least one of them.
Response said "File uploaded successfully" but then immediately below it: "No files Uploaded." Contradiction. Either the success message is a lie, or the listing mechanism doesn't agree with the upload mechanism.
Chasing the wrong thing for a bit
First instinct was to worry about the listing bug — why does it say uploaded but not show the file. Checked for a session cookie to see if maybe files were being tied to a session that wasn't being set. Nope, zero cookies in the response. No session tracking at all.
That was actually the useful signal, just not in the direction I was originally looking. If there's no session, there's no per-user file list to worry about — the app isn't tracking who owns what. Which meant the real question wasn't "why can't I see my file," it was "where did my file actually go on disk."
Testing the filename field
Given there's no session and two filename-ish inputs, the natural thing to prod is whether the filename text field is being used as a literal disk path. Threw a classic path traversal string in there.
Got back: "You are not allowed to write to this directory."
That response is gold, actually — it's not "invalid filename" or a generic error, it's specifically about permission to write to a directory. Meaning the traversal was accepted and processed, it just got blocked by some kind of path check, presumably a check against system directories.
Dialed it back to a single level:
Text
../test.png
That one worked. Checked the browser, /../test.png (well, /test.png after path resolution) actually loaded the image. So: one level of traversal escapes the upload directory and lands you in what's apparently the webroot directly above it. The block was specifically stopping deep traversal into system paths, not traversal in general.
From file write to code execution
Once you can write outside the upload directory and into the webroot, the obvious next question is: can I write something that executes instead of something that just gets served as a static file?
Checked the server banner off a 404 page: Apache/2.4.41 (Ubuntu). Classic Apache+PHP territory.
Tried uploading with filename ../shell.php and body content:
PHP
<?php echo "IT_WORKS_12345"; ?>
Got blocked immediately: "php files are not allowed." So there's an extension check specifically for .php, separate from the directory traversal check. Two different defenses stacked on top of each other.
Bypassing the extension filter
This is the part where you just work through the standard list of PHP-adjacent extensions that Apache will sometimes still execute depending on config:
.php5,.php7,.pht,.phar— worth trying blind.phtml— the one that's most commonly still wired up to the PHP handler on older/misconfigured Apache setups- Case variants (
.PHP,.Php) — filter might be case sensitive - Double extensions (
shell.php.png,shell.png.php) — sometimes the filter only checks the last extension, sometimes Apache's MultiViews will still execute based on an earlier extension in the chain
Tried deneme.php.png first. Got through the filter (no "php files are not allowed" message), but when I hit the uploaded file's URL, the PHP tag showed up as literal text — <?php echo "IT_WORKS_12345"; ?> sitting right there unrendered. So that combination bypasses the filter, but Apache on this box doesn't treat .php.png as executable. Good to know, wrong tool.
Went back to .phtml:
Text
../shell.phtml
Same PHP payload inside. Uploaded clean, no filter complaint. Hit the URL — and this time IT_WORKS_12345 actually rendered as output, not as source. .phtml is wired to the PHP handler on this Apache config, and critically, it wasn't on whatever blacklist was checking for .php. That's the win condition for this stage — extension-based filters that check for one specific string (.php) rather than validating against an allowlist of safe extensions are exactly this fragile.
From code execution to shell
Once you've got arbitrary PHP execution, the natural upgrade is stop hardcoding output and start taking a command as input:
PHP
<?php system($_GET['cmd']); ?>
Re-uploaded as ../shell.phtml with that body. Hit:
Text
shell.phtml?cmd=id
Got back uid=33(www-data) gid=33(www-data) groups=33(www-data). Full RCE as the web server user. That's the point where the challenge stops being about the upload mechanism and starts being a straightforward "you have a shell, go find the flag" exercise.
Recon (the actual "can you recon" part)
Kept it methodical instead of just throwing find / -iname flag* blind:
pwd→/var/www/html/public. Know where you are before you go poking around.ls -lain that directory → turned up a handful of files includingadmin,admin-settings.js, and a few of my own test uploads sitting alongside the app's real files (index.php).- Tried hitting
/admindirectly → 403 Forbidden. Noted for later, didn't chase it since the shell already gave a more direct path. - Pulled
admin-settings.js→ it was obfuscated (string-array + index-shifting obfuscation, the kind that's trivially readable once you see the pattern), but readable enough to spot afetch()call posting asettings_servervalue to the backend, tied to some kind of scheduled backup/polling feature. Didn't need it for this challenge, but it's the kind of thing that smells like an SSRF hook if you wanted to keep digging. - Ran
find / -iname "*flag*" 2>/dev/nullfrom the shell. Turned up a long list of unrelated kernel/proc noise, but buried in it:/var/www/html/flags.txtand/var/www/html/Flag.php. catboth. All three flags for the challenge were sitting right there in one place.
Afterward — turns out this wasn't the intended path at all
Checked the official flag hints after finishing, expecting them to line up with what I did. They didn't, not even close:
- Flag 0 hint: "How can you abuse the site to disable or rewrite the Apache rule?"
This points at uploading a malicious
.htaccessfile through the same traversal bug, to disable whatever Apache directive is blocking.phpexecution, then uploading a normal.phpshell afterward. I never touched.htaccess. I just brute-forced alternate extensions until.phtmlhappened to already be wired to the PHP handler on this box — a different bug (loose extension filtering) landing on the same outcome (arbitrary code execution) as the intended one (Apache config override). - Flag 1 hint: "The endpoint doesn't return much information, maybe there's some other parameters" Points at some app endpoint that behaves differently with extra GET/POST params. Never found or needed this. My shell skipped past whatever this flag was gating.
- Flag 2 hint: "Check out the obfuscated JS, can you discover an endpoint and its parameters"
This is
admin-settings.js, which I did find and skim (it has afetch()call posting asettings_servervalue, looked like it fed some kind of backup/polling feature), but I didn't deobfuscate it properly or chase the endpoint. Once RCE was live,find / -iname "*flag*"turned up all three flags sitting in the same files (flags.txt/Flag.php) regardless of which "stage" they were meant to gate.
So what actually happened: I got full RCE through a side door (extension filter bypass via .phtml instead of the intended .htaccess override), and from there just read every flag file directly off disk instead of solving the three intended sub-puzzles that were supposed to gate each one individually. Less "solved the challenge" and more "got root-adjacent access and let that make the rest of the puzzle irrelevant." Valid in the sense that it's a real vulnerability chain and nobody handed me anything — but it's an unintended solve, not the designed one.
Worth going back and doing the actual intended chain for the practice: upload a crafted .htaccess to re-enable or redirect PHP execution, find whatever sparse endpoint Flag 1 is hinting at and fuzz its parameters, and properly deobfuscate admin-settings.js to chase the SSRF-shaped lead in Flag 2 instead of skipping past it once the shell made it moot.
Takeaways
- No session cookie on an upload feature isn't just "weird," it's a hint that file placement is probably deterministic and directly tied to what you send, worth prodding immediately.
- A "you don't have permission" error on a path traversal attempt is a much stronger signal than a generic 404 — it confirms the traversal was processed, just blocked past a certain point. Worth dialing back the depth instead of assuming it's fully closed.
- Extension filters that blacklist one string (
.php) instead of allowlisting safe types are almost always bypassable —.phtml,.phar,.phtare the first things to try on Apache, and double-extension tricks are worth a shot even when they don't pan out, because a negative result still tells you something about how the filter works. - Once you've got RCE, resist the urge to
find /blind immediately —pwdandlsfirst, get oriented, and check what's sitting next to the app's own source before going wide. It's faster and it's the difference between recon and flailing. - Getting all the flags doesn't automatically mean you found the intended bugs. Worth checking the official hints after solving something that felt too fast — RCE is powerful enough to trample over multi-stage puzzles that were designed to be solved one gate at a time. Good to know the shortcut exists, still worth going back for the real chain.