Tags: web 

Rating: 5.0

The downloaded `tar.xz` file contains a Dockerfile and all resources needed in order to create a local setup for the challenge as well as a comment on top of the Dockerfile with the necessary commands for running the Docker container.

The web application running at port 8001 was written in PHP.

Users can perform the following actions:

* Submit writeups by issuing a `POST` request to `/add.php` with the body parameters `c` (csrf token, generated once per session, consisting of 16 random hex characters) and `content` (writeup text).
* View writeups from anybody, if they know the writeup id which consist of 16 hex characters by issuing a `GET` request to `/show.php?id=[writeup-id]`.
* Like writeups from anybody, if they know the writeup id by issuing a `POST` request to `/like.php` with the body parameters `c` (csrf token) and `id` (writeup id)
* Show their writeups to admin by issuing a `POST` request to `/admin.php` with the body parameters `c` (csrf token) and `id` (writeup id). There is a Python script that uses Selenium to locate and click on an input field with the id `like` using the admin account, which is protected by basic authentication with a 32 random alphanumerical characters, which is too much to be bruteforced in a feasible amount of time.

All flags are stored in a MariaDB database. The flag is the content of a writeup that was created by the admin user:

MariaDB [writeupbin]> select * from writeup;
| id | user_id | content | created_at |
| 55f6dd1b865d4672 | 634dc200ed58cc66 | No captcha required for preview. Please, do not write just a link to original writeup here. | 2019-12-28 21:25:50 |
| b1c863839934502d | admin | hxp{FLAG} | 2019-12-28 20:04:50 |
2 rows in set (0.001 sec)

After analyzing the source code, testing the behaviour of the app and manipulating requests using BurpSuite, we noticed that we can insert arbitrary content into the DOM of the page where writeups get displayed by submitting a writeup containing HTML markup. This works because the content of a writeup is simply inserted between `` tags without any server-side validation or output escaping. Client-side validation forbids angle brackets and enforces a minimum length of 140 characters. However, this can of course be bypassed easily by manipulation HTTP requests before submission.

We could then show our manipulated writeup to the admin user and therefore hoped to be able to execute JavaScript in order to steal either his session cookie or the link to his writeup that contains the flag because all links to one's own writeups are displayed on the same page that gets rendered when viewing any writeup.

However, we noticed that we cannot make the Admin user execute any JavaScript using a manipulated writeup because of several protection mechanisms in place that can be observed by checking the HTTP response headers:

* The CSP (Content-Security Policy) is very strict, allowing only inline scripts with a nonce that consists of 16 hex characters and gets regenerated on every new page load, as well as two JavaScript files loaded from external servers. It also forbids us to insert JavaScript inside `image` tags, links, attributes such as `onerror` etc.:

Content-Security-Policy: default-src 'none'; script-src 'nonce-ODViOTU5YzE5ODhjZGI0Mw==' https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.0/jquery.min.js https://cdnjs.cloudflare.com/ajax/libs/parsley.js/2.8.2/parsley.min.js; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; require-sri-for script style;

We checked the policy with [Google's CSP evaluator](https://csp-evaluator.withgoogle.com/), which did not find an easy bypass either. The CSP evaluator advised us to ensure that the two Javascript files included from external URLs do not server JSONP replies or Angular libraries, which was not the case for either of them.

* The cookie attribute `HttpOnly` forbids us to read the cookie of the Admin user via JavaScript. The cookie attribute `SameSite=Strict` serves as CSRF prevention in modern browsers.

Set-Cookie: PHPSESSID=deaf2vppo2ncn1fbr9osskfel2; path=/; HttpOnly; SameSite=Strict

* Other headers such as `x-xss-protection`, `X-Frame-Options` and `Feature-Policy` were used as well, we did not look at those in detail though

The Admin users uses a headless version of Google Chrome 78, which does not include the XSS auditor anymore. This means that we cannot misuse its "features" for our purposes:

User-Agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/78.0.3904.108 Safari/537.36"

However, we can probably still use all JavaScript already present on the page that can be triggered via HTML attributes for our purposes. Therefore, we took a closer look at the scripts loaded from external servers:


* JQuery is a precondition for using Parsley, therefore we mainly looked into Parsley.
* [Parsley](https://parsleyjs.org/) is a library for clientside validation with "No need to write even a single JavaScript line for simple form validation." This sounds useful for our purpose.

We discovered that we can insert error messages at arbitrary places in the DOM by using the `data-parsley-errors-container` attribute and making an input field fail validation. This attribute allows customizing the place where error messages are positioned in the DOM. We can use JQuery selectors in order to define the parent HTML element. JQuery allows us to use regexes or check if HTML elements contain a certain string.

Furthermore, we found out that we can customize the error message displayed by using the `data-parsley-error-message` attribute on an input field. We can even display HTML inside the error text, which means we can insert arbitrary HTML anywhere in the DOM.

Therefore, we decided to attack the admin user with the following writeup:

* Construct a form that gets validated with parsley immediately after page load
* Insert an input element into that form which fails valiation when left empty
* Set the `data-parsley-error-container` attribute of that input field to the text `Writeup - X` where `X` is the first character guessed to be part of the writeup ID
* Set the `data-parsley-error-message` attribute to `<input id=like type=button>`, which is yet another input field that gets selected by the Python script as a candidate for the click it issues.

In case of multiple input fields with the same ID, the Python script will only cause a click on the first match of the XPath query, because that's the behaviour of Selenium. Therefore, if our guessed character is correct, the fake input field with the id `like` will be placed as a child of the link to the admin user's writeup which is above the real like button. The click that gets issued hits the first button and will therefore not trigger a like. If our guess is wrong, the error text will be placed inside the writeup text's form we injected, which is below the real like button. The admin user will submit the real like request when clicking the first button with id `like`. As the users which liked a writeup are displayed, this behaviour serves as an oracle for guessing the flag character by character.

The hardest part for us was making the form valiation happen before the click of the Admin user. The `data-parsley-trigger` attribute controls on which JQuery event the form valiations triggers. After a ton of experiments that failed (`load`, `ready` etc. failed), we discovered that the validation gets triggered when using the `blur` event.

We used the following script for extracting the flag:

import requests
import re
import time


def submit_writeup(cookies, csrf_token, payload):
url = f"{BASE_URL}/add.php"
data = { "c" : csrf_token,
"content" : f"""<form data-parsley-validate><input type="text"><input type="text" id="like" data-parsley-trigger="blur" autofocus name="some-field" data-parsley-error-message="<input id=like type=button>" data-parsley-required data-parsley-errors-container="a:contains('Writeup - {payload}'):eq(0)" /></form>""" }
response = requests.post(url, data=data, cookies=cookies)
writeup_id = re.search('<h2>Writeup - (.*?)</h2>', response.text, re.DOTALL)
return writeup_id.group(1)

def post_like_request(cookies, csrf_token, writeup_id):
url = f"{BASE_URL}/admin.php"
data = { "c" : csrf_token,
"id" : writeup_id }
response = requests.post(url, data=data, cookies=cookies)

def show_writeup(cookies, writeup_id):
url = f"{BASE_URL}/show.php"
params = { "id" : writeup_id }
response = requests.get(url, params=params, cookies=cookies)
searchtext = response.text.replace("\n", "").replace("\r", "")
return "Liked by</h3>admin" in searchtext

if __name__ == "__main__":
alphabet = "0123456789abcdef"
cookies = { "PHPSESSID" : "mlabfcco1v7ij153p0mjk0h3su" }
csrf_token = "72c087c467c5e1b9"
payload = ""
for i in range(16):
for c in alphabet:
print(f"Testing: {c} - Retrieved: {payload}")
writeup_id = submit_writeup(cookies, csrf_token, payload + c)
post_like_request(cookies, csrf_token, writeup_id)
liked = show_writeup(cookies, writeup_id)
if not liked:
payload += c
print(f"Found: {payload}")

Running the script gave us the ID to the writeup of the admin user that contains the flag, which was `1800a15d252d318a`.

Afterwards, we submitted a `GET` request to `/show.php?id=1800a15d252d318a` in order to retrieve the flag: