Hack My Bot Writeup for Beginners (PwnMe 2025 CTF)

Table of Contents
Hey! Today we will be exploring Hack My Bot 1 and 2 together, a lovely 2-part web challenge that came up in the recent PwnMe 2025 CTF and had ~11 solves towards the end of the competition. I solved it with my teammate @qlashx.
I will be covering some of the methodology to approach similar challenges while discussing topics such as Cross-Site Scripting (XSS), Same-origin Policy (SOP), WebSockets and more, trying to be beginner-friendly throughout.
Chapter 1: Eagle View⌗
First, and since we are given the source code, let’s start by exploring the app from a distance.
The app’s directory looks as follows:
We will start off by checking the Dockerfile
provided as it can often tell us many useful information about the target application.
Let’s see what can we take away from the Dockerfile.
- From the first line, we notice that we have a Node application.
- We notice
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
which is used to download Google Chrome, this hints at a potential XSS vulnerability. - We are setting up two interesting directories
/tmp/bot_folder/logs
and/tmp/bot_folder/browser_cache
. We will keep these in mind. - We have
/root/flag2.txt
. - Our Node application seems to be served through an nginx reverse proxy.
Great! Let’s see what nginx has to tell us by exploring nginx.conf
:
As we move forward with our code review, we should note all the “primitives” and vulnerabilities we come across as they may always be relevant for us at a later stage of exploitation.
Looking at the nginx.conf
, we can right away spot a very subtle, misconfigured alias. You can read more about this misconfiguration here and here
Simply, if we visit /log../a
, nginx is going to replace the prefix /log
with the alias, yielding /tmp/bot_folder/logs/../a
.
This will then be normalized to /tmp/bot_folder/a
, effectively traveling one directory up and reading content inside the bot_folder
directory instead of the logs
directory as we are expected.
We still do not know if this is useful to us, so we will keep it in our back pocket.
Let’s jump into the actual app!
Chapter 2: Landing our XSS⌗
Since we are dealing with a Node application, we can use a nifty feature of npm
(Node’s package manager) to check for low-hanging fruits and possible vulnerable dependencies. These could give us a quick win:
npm audit
looks at the dependencies in package.json
and checks for any reported CVEs or security advisories against them. We get a few hits, but none seem relevant upon inspection.
Let’s move to app.js
. We quickly scan its imports:
Nothing fancy, just an Express app. We can also see that puppeteer
is brought in, which explains why we saw Google Chrome downloaded in the Dockerfile earlier.
As with any web application, let’s check out its routes as these are often our only entrance to the application:
Let’s check the index page at views/index.ejs
:
The index page seems to contain a bunch of snippets to perform a variety of attacks. It looks like this:
We can notice that it is loading an interesting script.js
towards the end:
Exploring the script, we can see basic functionality to handle reports:
We notice that jQuery is being used as well as some search functionality. Let’s break it down:
We have
getSearchQuery
, responsible for extracting the?q=
search param:When the page loads, the search query is extracted using the previous function and we call another function,
searchArticles
, using that query:The
searchArticles
function looks like this:
The function is vulnerable to XSS when no results are found:
We need to get the function to return no results. However, notice that the search functionality acts as a naive filter that prevents us from reaching the vulnerable !found
code path. Here is an example with a typical XSS payload:
The search functionality does a few interesting things:
It splits by non-letter Unicode characters (This means we remove all punctuation and special characters from the search):
It checks if any word in the search query is present in the code snippets on the page:
There are many ways to bypass this trivial filter. We could have used a trivial eval
with atob
however this would fail because the search query is converted to lowercase at the start which breaks any Base64 encoding.
Here are three possible bypasses that all run alert(1)
:
These three payloads all work for two reasons:
- The “words” in them like
input
,iframe
andonloadstart
do not exist in the articles on the page. - The other parts that are included such as
alert(1)
are encoded using non-letter representations such as octal\141
and HTML entity encoded<
.
Essentially, they both allow us to bypass the filter and execute the insecure No results for code path:
What we have here is a DOM-based XSS. Why? Because without server involvement whatsoever, the client-side Javascript manipulates the user’ input allowing us to run arbitrary client-side code.
Note: This currently requires user interaction, as the user has to input the search query in the search box. Let’s see how we can weaponize it and possibly deliver it to the bot.
Chapter 3: XSS Delivery⌗
Getting a DOM-based XSS is not big on it’s own, until we can find a way to deliver it to our target.
Remember the getSearchQuery
function?
It allows us to use the ?q=
search parameter to enter our search query. For example, if we request:
http://localhost/?q=<video onloadstart="eval('\141\154\145\162\164\50\61\51')"><source></*>
We trigger an alert. However, this time it is deliverable. We can send it to our target!
Let’s write a simple Python script to help us experiment with different payloads faster:
This encodes our payload in octal, wraps it in our <input>
trigger, URL encodes it and gives us a URL that we can directly send to the bot on /report
.
Note: Because of the unconventional way the cookie is set on the bot, where it is set after the bot visits our URL, there is a race condition between our XSS payload executing and the cookie setting.
Because of that, we introduce a 2-second delay to give ample time for the cookie to be available before we exfiltrate it. This is achieved using
setTimeout
as seen above.
Two seconds later, we receive a callback containing the first flag:
Nice. We got our first flag!
Chapter 4: Bot Control⌗
So far, we found two primitives:
- A path traversal due to an off-by-slash nginx alias misconfiguration.
- A deliverable DOM-based XSS in the app’s search functionality
Our next objective is reading the second flag at /root/flag2.txt
as seen earlier in the Dockerfile.
Let’s look at the startBot
function we skipped over earlier:
Let’s break down the important parts:
- We create a
.log
file at${name}.log
, using it to log any events or errors that occur with the bot. - We use Puppeteer to launch a Chrome instance, launching it with the following flags:
'--remote-allow-origins=*' '--no-sandbox' '--disable-dev-shm-usage' `--user-data-dir=${browserCachePath}`
- We open the user provided URL, set the flag cookie for
http://localhost/
and sleep for 7 seconds before exiting.
These CLI flags piqued my interest, let’s see what each of them means.
--remote-allow-origins
: Enables web socket connections from the specified origins only.'*'
allows any origin.--user-data-dir
: Makes Content Shell use the given path for its data directory.--no-sandbox
: Disables the sandbox for all process types that are normally sandboxed.
Interesting. the Chrome user data directory is set to /tmp/bot_folder/browser_cache/
. We know that we can read any file in there thanks to our path traversal primitive.
Let’s inspect our challenge locally to see what is inside the Chrome user data directory, perhaps there is something useful to grab from there:
# ls -la /tmp/bot_folder/browser_cache
total 100
drwxr-xr-x 1 root root 4096 Mar 5 14:40 .
drwxr-xr-x 1 root root 4096 Mar 1 11:52 ..
drwx------ 31 root root 4096 Mar 5 14:40 Default
-rw-r--r-- 1 root root 60 Mar 5 14:40 DevToolsActivePort
drwx------ 2 root root 4096 Mar 5 14:40 GrShaderCache
drwx------ 2 root root 4096 Mar 5 14:40 GraphiteDawnCache
-rw-r--r-- 1 root root 13 Mar 5 14:40 'Last Version'
-rw------- 1 root root 3731 Mar 5 14:40 'Local State'
drwx------ 2 root root 4096 Mar 5 14:40 'Safe Browsing'
drwx------ 2 root root 4096 Mar 5 14:40 ShaderCache
-rw-r--r-- 1 root root 85 Mar 5 14:40 Variations
-rw------- 1 root root 49152 Mar 5 14:40 first_party_sets.db
-rw------- 1 root root 0 Mar 5 14:40 first_party_sets.db-journal
drwx------ 2 root root 4096 Mar 5 14:40 segmentation_platform
We can see Chrome user files including disk cache and other things. We can also notice DevToolsActivePort
which looks like this:
33785
/devtools/browser/4c7a5d50-9e18-45c6-8190-093bf4967eb0
The file contains a reference to the Chrome DevTools Protocol (CDP) port. This protocol is the engine behind the Chrome DevTools:
The Chrome DevTools Protocol allows for tools to instrument, inspect, debug and profile Chromium, Chrome and other Blink-based browsers. Many existing projects currently use the protocol. The Chrome DevTools uses this protocol and the team maintains its API. Reference
Wow! In normal instances, Same-origin Policy (SOP) wouldn’t allow us to connect to CDP because it lives on a different origin. However, our Chromium was launched with --remote-allow-origins=*
which as we saw earlier enables web socket connections from any origin.
Let’s try talking to it using our earlier XSS.
Note: There are plenty of ways to deliver a large payload through a URL
- We could use our initial XSS to open a new same-origin window (
window.open
) then usewindow.name
to deliver our complete XSS payload.- We could serve our complete payload on a Cross-origin Request Sharing (CORS) enabled site, and fetch-eval the served JS using our initial payload. I will be using this option.
Our initial XSS payload will always be:
This gives us a simple “stager” that allows us to freely serve the complete payload on http://webhook.site/<id>
.
We will first fetch the current DevToolsActivePort
. This does not breach SOP, since the path traversal is on our same origin:
Now that we have crafted the devTools
CDP endpoint, let’s confirm that we can talk to it by exfiltrating it’s response back to us.
First, we will look at the CDP documentation to see how we can speak with it. I really recommend reading that page.
The CDP protocol is based on web sockets. It also offers a few convenience HTTP endpoints such as /json/version
and /json/list
.
If we try to access any of the HTTP endpoints, we will notice they are inaccessible to us and we are unable read them due to SOP and CORS.
This confirms that the --remote-allow-origins=*
flag affects web socket connections only.
Let’s try to connect to the web socket endpoint directly then. With some research, we notice that there is two kinds of APIs available us through CDP:
- Browser-level
- Page-level
Note: Page-level APIs are available if we are connected to a page context such as
/devtools/page/<id>
. However, we got a browser context endpoint/devtools/browser/<id>
from theDevToolsActivePort
we read earlier.
We will keep this in mind as we move forward. For now, let’s try to call a browser-level method such as Browser.getVersion
Let’s try:
This opens up a new WebSocket connection, onopen
, it sends a JSON-encoded Browser.getVersion
and will exfiltrate any response back to our hook through POST requests.
Running this payload, we receive this back on our hook:
Excellent! We can communicate with CDP and get back responses.
Sadly, we can not call methods such as Page.navigate
yet because, as we said, we are not in a page context. Think we are controlling the browser, not a specific page yet.
Looking through the documentation, I found Target.getTargets which allows us to list targets, like pages, to attach to.
With the targetId
in hand, I tried to use Target.attachToTarget
to attach to our target:
In the payload, we use a simple switch
based on the data.id
to ensure correct handling of each message in the CDP command chain. We also utilize an exfil
function to help us exfiltrate useful data through our hook.
Looking at the output back at our hook, we see that the attaching occurred but we still couldn’t use Page.navigate
afterwards. Here are the outputs exfiltrated from each stage:
Let’s simplify things and just connect to a page-context CDP socket instead. We can do that by connecting to /devtools/page/<targetId>
instead of the /devtools/browser
endpoint.
Chapter 5: Finale⌗
We experimented with CDP and built a convincing exploit path.
Let’s put everything together.
First, we have our XSS stager, crafted and sent through this Python script:
Then, in our staged JS payload (served from hook), we will do the following:
- Use path traversal to grab CDP browser endpoint.
- Connect to CDP browser endpoint via web sockets.
- Use
Target.getTargets
to get the targetId of a blank page (we don’t want to disrupt our current “master” page). - Connect to CDP page endpoint via web sockets.
- Use the now-available
Page.navigate
to navigate to localfile:///root/flag2.txt
. - Read the page’s content using
Runtime.evaluate
, just as we would in a local devtools console.
Let’s illustrate that in code:
Running our payload, and if everything is correct, we get a callback with our lovely flag:
Notice how we got the minimal Chrome markup we see when we open a local file, pretty cool imo.
That’s it for this post, don’t hesitate to leave your questions in the comments or hit me up on Discord @aelmo1.