Ways to beat the GoGuardian restrictions: what the capture file says works
0 certificates pinned, 76,772 bytes of policy for one replayed GET, & 82 sites the district never blocked
Post one beautified 31 files & captured 2,800 requests to answer what the extension does. This one uses the same capture file, 4,011 flows as of 22:09:20, to answer the opposite question: what can a student on this machine do about the filter, & what does the district see when they try. Every test below ran against the live copy of Shield 4.1.246/4.1.247 while it talked to the production servers on November 5, 2026; claims resting on code alone cite line numbers from the beautified bundle.
Three numbers decide most of what follows: 0 certificate pins anywhere in the path, 76,772 bytes returned by a single replayed GET, & 82 sites on this district's allow list that were never blocked in the first place.
The full policy body is 76,772 bytes & the server hands it over
The extension polls /v1/policy every 15 minutes with
previousPolicyHash=48e2f2dd… & gets a 122-byte
"unchanged" reply; 22 polls by 22:07:05, every one identical. Drop the hash
parameter & replay the request with the captured bearer token, byte for
byte unchanged, & production answers HTTP 200 with the entire
76,772-byte body1. No password, no admin
login, no cracking: the request the extension already makes, minus one query
parameter.
Nothing on the path objects. There is no certificate pinning anywhere in the stack: 1,949 GoGuardian requests decrypted through the capture CA, which sits in both the user & machine root stores at 24 certificates each, & zero of the capture's 572 transport errors contain the word certificate2. The token I replayed was minted at 21:36:03 & expires at 21:36:03 the next day, exactly 24 hours of life3.
What comes back is the whole rulebook: blockSubFrames: false,
scheduling off so filtering runs 24/7,
allowTeacherOverride: false, 43 of 43 categories blocked, &
417 flagged terms with 133 at severity 2 or above4.
Every wall, its exact height, in one GET.
The allow list first: 82 sites were never blocked
The cheapest move on this list is not a hack, it is reading. The policy
hands over the district's own allow list, 82 entries deep:
*discord*, click.discord.com, canva.com,
chess.com, typing.com, blogger.com,
bandlab.com, classic.minecraft.net, even
chrome://newtab5. 132 entries sit
on the block side, & the asterisks mean *discord* matches
whenever the string appears, click.discord.com included.
Read the list before fighting the list. A district that allowed chess.com & canva.com already answered the question you were about to spend all night on5.
The iframe hole: blockSubFrames is false
Second cheapest: put the blocked site inside an iframe. The filter returns
early on sub-frame requests whenever blockSubFrames is off, two
lines of JavaScript6, & this district's
live policy reads blockSubFrames: false4.
A page with an iframe pointing at a known-blocked game site loads the frame
with HTTP 200 & no GoGuardian redirect; I ran that test in
post one6.
Chromebook & Windows run the same byte-for-byte filter code, so the two
machines can only differ where an admin flipped this switch.
Deleting the extension's storage did not detach it
I stopped Chrome, copied the 10 files out of
Local Extension Settings\haldlgldplgnggkjaafhelgiaglafanh into
C:\gg-backup\LES-backup, deleted them, & restarted Chrome on
the school profile through Win32_Process Create7.
The extension came back with disable_reasons: [], a service
worker already marked started, & a fresh token whose iat
lands at 21:36:03, the same second as the restart.
The wipe did clear one thing: that first poll at 21:36:03 went out with no hash & the server re-sent all 76,772 bytes7. One request of cost, then 21:52:05 & 22:07:05 were back to 122-byte acknowledgements. Deleting local state is a cache eviction, not a disconnect; the gateway re-sends whatever you erased.
Deleting its files brings them back at next startup
Every Chrome startup in the capture runs the same errand:
clients2.google.com update pings 10 times, latest 21:37:02;
GET ext.goguardian.com/stable.xml 3 times, latest 21:37:03;
then the full package GET shield.goguardian.com/release/v4.1.247.crx
answered 200 with a Cr24 signed body8. The
code behind it fires chrome.runtime.requestUpdateCheck() whenever
extSettings.latestShieldVersion stops matching the installed
manifest, then schedules chrome.runtime.reload() 10 seconds
out9. Delete the folder & the next startup
runs the same three requests against the same URLs.
Pulling its scripts off a page is undone in 25 seconds
Content scripts come back on a timer. Every 25 seconds the health check
queries all complete tabs for http://*/*, https://*/*
& about:blank & re-injects whatever is missing; a second
listener watches webNavigation.onErrorOccurred for
net::ERR_ABORTED & re-injects on that too, under a log line
that reads checking content script health (ESC key exploit)10.
The code names the trick it expects students to try, then budgets 25 seconds
to undo it.
The bypass password is checked by their server
The extension has an escape hatch for blocked pages: a password prompt,
bypassPassword/passwordAttempt from the content script. The
extension never judges the answer. It ships the URL, capped at 131,072
characters, & the password, capped at 1,024, to
GET /v1/admin/validate-bypass-password, & only a
valid: true body buys a hostname-scoped exemption that expires
at now + durationMs11.
No config for it exists on this machine: the full 76,772-byte policy
contains the string "password" 0 times, &
/v1/ext-settings is a 270-byte blob of log level, 5 feature
flags & the shield version12. The
exemption list cleans itself every 5 minutes, resets on any session change
& dies with the browser; 1 request hit the route in the capture,
17:13:57, HTTP 20011. Guessing locally buys
nothing, because every guess still travels to the district's admin server.
The proxy classifier reads pages, not your proxy
The ML pipeline labels exactly 3 things: CATEGORY_EXPLICIT,
CATEGORY_PROXY, CATEGORY_GAME, judged in one
analysis loop13. 17 predict requests are in
the capture: the proxy label fired once at CONFIDENCE_NONE,
rawProbability 0.0114; explicit sat at CONFIDENCE_NONE across
13 responses, 0.0055 to 0.0646; the game label hit
CONFIDENCE_VERY_HIGH at rawProbability 1, on a block
page14.
What the classifier never sees is your system proxy: grep for
proxySettings, getProxy or system.proxy
across all 31 beautified files, 0 hits13.
What it does see is page text, & the org configured "Default Smart Alerts
for Proxies Trigger" at CONFIDENCE_HIGH for
CATEGORY_PROXY with block target: whole page15.
A proxy-shaped landing page gets blocked & reported; the proxy configured
in Windows settings is read by none of the 31 files.
What the district sees when you try any of this
Startup traffic already uploads an inventory:
chrome.management.getAll() feeds
POST /v1/admin/extensions/sync, which answered 200 twice at
21:37:06. The body runs 10,221 bytes & lists all 7 extensions in the
profile: Google Translate, Clever Plus, TypingClub, HTML Editor, Read Aloud,
Camera, Coding HTML, each with id, name, enabled state & homepage
URL16. It runs at startup, before you touch
anything.
When a block page fires, the visit is booked:
blocked.goguardian.com/?ctx=… carries base64 with admin
filter id 9428988, policy id 78924, the org id, the original URL & which
rule fired; 4 such hits in the capture, all HTTP 20017.
The classifier's report carries the page text & gets a report id back,
4608d230-be5a-44c1-bdad-1147635c0e8d18.
The polls, for their part, never pause during any of it: 22 policy fetches by 22:07:05, 1 every 15 minutes7. The wipe, the restart, the deletions: none of it changed how often the extension asks.
What none of this gets you
The JWT's ggdi claim reads
self-generated:3a06132b-3b94-4166-967f-1e9992b5f799, which is
the no-native-host path: nothing signed for GoGuardian runs outside the
browser on this machine3. The Windows helper
from post one, lock screen, app kill, desktop photo, was never installed here
(registry probe, 3 of 3 native-host
keys absent), so everything above stays inside Chrome's sandbox.
Everything above also assumes nobody is watching in real time:
inquisition /v3/ext/livemode answered
{"liveSessions":[]} on all 1,068 polls, last at
22:07:0519, so the teacher-side surface,
screenshots, pusher frames, screen annotation, never activated & I have
nothing honest to say about it. Same capture file, next session, when a class
is running.
Sources
Every claim above links to a footnote. Captured quotes are verbatim from
/tmp/gg-capture.mitm (4,011 flows, frozen at 22:09:20); code
quotes are line-numbered from the beautified bundle in
work/4.1.246_0/; policy quotes are from the 76,772-byte body
saved as gg-policy.json.
1. The policy replay: one GET, no hash parameter, full body
/tmp/policy-replay.txt (replayed 2026-11-05 against production)
GET /v1/policy?clientTimezoneLocale=en-US (no previousPolicyHash)
Authorization: Bearer eyJhbGciOiJIUzUxMi... (captured from live traffic, unchanged)
-> HTTP 200, 76772 bytes, content-type: application/json
policyHash: 48e2f2ddcf551e4387f70b8a10bf17a4f0c3623422da3105340ce66eb22c9508
policyHasNotChanged: False
the extension's own polls keep sending the hash, 22x by 22:07:05, all:
GET /v1/policy?previousPolicyHash=48e2f2ddcf551e4387f70b8a10bf17a4f0c36234
22da3105340ce66eb22c9508&clientTimezoneLocale=en-US
-> 122 bytes: {"policy":null,"policyHash":"48e2f2ddcf551e4387f70b8a10bf17a
4f0c3623422da3105340ce66eb22c9508","policyHasNotChanged":true}
2. Root stores & decryption: no certificate errors
PS> Get-ChildItem Cert:\CurrentUser\Root -> 24 certificates
O=mitmproxy, CN=mitmproxy
PS> Get-ChildItem Cert:\LocalMachine\Root -> 24 certificates
O=mitmproxy, CN=mitmproxy
capture scan, GoGuardian hosts only:
flows total: 4,011 (22:09:20); GoGuardian: 1,949, bodies readable end to end
flows with transport error: 572
HTTP/2 connection closed (GOAWAY): 136 + 73 + ...
connect call failed (Errno 101/110/111): 129 + 68 + 55 + ...
errors whose message mentions cert/tls/ssl/handshake/verify: 0
3. The bearer token: 24-hour life, self-generated device id
decoded HS512 payload of the captured Authorization header
iss gg:ext-gateway
iat 1793932563 2026-11-05 21:36:03
exp 1794018963 2026-11-06 21:36:03 (+24h)
nbf 1793928963 2026-11-05 20:36:03 (-1h)
ggdi self-generated:3a06132b-3b94-4166-967f-1e9992b5f799
claims: exp ggai ggdi ggoi ggsai ggski iat iss jti nbf
4. The district's rulebook, values straight from the body
gg-policy.json (76,772 bytes on the wire, org hartford public schools)
adminSettings.orgSettings = {"blockDirectIp": false,
"blockSubFrames": false, "safeSearchEnabled": true, ...}
scheduling.enabled = false (MON-FRI utcDayIntervals defined, unused:
filtering runs 24/7)
adminSettings.categories: 43 entries, all isAllowAction: false
adminSettings.sites: 82 allow / 132 block
adminSettings.flaggedTerms: 417 entries, 133 at severity >= 2
overrides.78924.allowTeacherOverride = false
policyId 78924 ties categories, sites & video ageRestrictions together
5. The allow list: 82 entries, wildcards included
gg-policy.json -> policy.adminSettings.sites (82 allow / 132 block, excerpt)
"*discord*": {"isAllowAction": true, "priority": 1, ...}
"click.discord.com": {"isAllowAction": true, ...}
"canva.com": {"isAllowAction": true, "priority": 1,
"policyId": "78924", "filterId": "21665037"}
"chess.com": {"isAllowAction": true, ...}
"typing.com": {"isAllowAction": true, ...}
"blogger.com": {"isAllowAction": true, ...}
"bandlab.com": {"isAllowAction": true, ...}
"classic.minecraft.net": {"isAllowAction": true, ...}
"chrome://newtab": {"isAllowAction": true, ...}
block side (132 entries): "*instagram*", "*tiktok*", "tiktok.com",
"*roblox*", "roblox.com", "*porn*", "*unblockedgames*",
"crazygames.com", "sites.google.com/site/unblockedgamessms6969/", ...
6. The sub-frame gate: two lines decide it
background.js.pretty:47551 (request types: main_frame, sub_frame, object, :47637)
const c = l.safeMode?.enabled || !1,
h = l.orgSettings?.blockSubFrames || !1;
if ("sub_frame" === t && (c || !h)) return;
live test (post one): iframe to a known-blocked game site -> HTTP 200,
no GoGuardian redirect in frame or page.
7. Storage wipe, restart & the one full re-download
PS steps (2026-11-05, before the 21:36 restart)
Stop-Process -Name chrome -Force
dir C:\Users\******\...\Local Extension Settings\haldlgldplgnggkjaafhelgiaglafanh
-> 10 files copied to C:\gg-backup\LES-backup, then deleted
restart: Invoke-CimMethod Win32_Process Create, --profile-directory="Profile 2"
Secure Preferences after restart:
"path": "...\\Extensions\\haldlgldplgnggkjaafhelgiaglafanh\\4.1.247_0"
"location": 7, "disable_reasons": [], "has_started_service_worker": true
"update_url_data": "orgId=22159"
policy fetch timeline (capture):
20:55:53 previousPolicyHash=48e2f2dd... -> 122B not-changed
21:36:03 (no hash, storage gone) -> 76772B full body <- wipe worked
21:37:06 previousPolicyHash=48e2f2dd... -> 122B not-changed
21:52:05 previousPolicyHash=48e2f2dd... -> 122B not-changed
22:07:05 previousPolicyHash=48e2f2dd... -> 122B not-changed
fresh JWT iat: 21:36:03 (same second as restart)
8. The restore path on the wire: stable.xml, then the CRX
capture, Chrome startups:
21:37:02 GET clients2.google.com/... (10x in capture) -> 200
21:37:03 GET ext.goguardian.com/stable.xml (3x in capture) -> 200
21:37:03 GET shield.goguardian.com/release/v4.1.247.crx -> 200
response body starts "Cr24" (signed CRX package)
21:37:06 POST prod-ext-gateway.../v1/admin/extensions/sync -> 200 (2x)
9. The updater predicate: server names the version, Chrome acts
background.js.pretty:49845-49899
predicate: t.extSettings.latestShieldVersion !== previous latestShieldVersion
effect: const i = chrome.runtime.getManifest().version,
o = n.extSettings.latestShieldVersion;
if (i !== o) ... await chrome.runtime.requestUpdateCheck() // :49863
onUpdateAvailable: major bump -> warn("major update available, reloading")
same major -> defer while a teacher session runs
h = () => { setTimeout(() => { chrome.runtime.reload() }, 1e4) } // :49897
wire: GET /v1/ext-settings returns "latestShieldVersion": "4.1.247"
10. The 25-second health check & the "ESC key exploit" listener
background.js.pretty:53653-53671
c.I.seconds(25, (async () => {
const s = ["http://*/*", "https://*/*", "about:blank"];
await r && s.push("file://*");
const i = await chrome.tabs.query({status: "complete", url: s});
... "[lib/content-script-health-check] checking content script health (interval)"
m(e, t, i, n)
})).start(),
chrome.webNavigation.onErrorOccurred.addListener(async s => {
if ("outermost_frame" === s.frameType && ... "net::ERR_ABORTED" === s.error) {
... "checking content script health (ESC key exploit)"
m(e, t, [i], n, !0)
}
})
11. The bypass flow: server validates, rule expires
background.js.pretty:1095-1127 ("admin/bypass/getBypass")
if (!t().scheduling.filterEnabled) return;
if (!e.url || !e.pw) return;
const {body:a} = await r.ValidateBypassPassword({
url: e.url.slice(0, 131072), password: e.pw.slice(0, 1024)});
if (a.valid) {
... setBypass({hostname: r, bypassEnd: t + parseInt(a.durationMs)})
setTimeout(cleanup, parseInt(a.durationMs) + 1e3)
}
route background.js.pretty:45196
static ValidateBypassPassword -> GET /v1/admin/validate-bypass-password?...
cleanup every 300000 ms (:1140); reset on IdentifiersChanged | SessionCleared
wire: 1 request, 17:13:57 -> HTTP 200
prompt: goguardian-10.js.pretty:4245 sends bypassPassword/passwordAttempt
12. No bypass config: ext-settings is 270 bytes
GET /v1/ext-settings (32 captures, last body 270 bytes):
{"minLogLevel":"LOG_LEVEL_WARNING",
"featureFlags":{"smartAlertsImageFilteringEnabled":false,
"teacherWebpStreamEnabled":true,"adminCleanUrlParsingV2Enabled":true,
"teacherStateCompressionEnabled":true,"teacherStateDeltaFramesEnabled":true},
"latestShieldVersion":"4.1.247"}
grep over the full 76,772-byte policy body:
"password": 0 hits
"bypass": 1 hit (site rule "elgatobypasses.org/__cpi.php")
"sessionlock": 0 hits
"offtask": 0 hits
"whitelist": 0 hits
13. CATEGORY_PROXY: an ML label, not a system-proxy check
background.js.pretty:1864
e.CATEGORY_UNKNOWN = "CATEGORY_UNKNOWN", e.CATEGORY_EXPLICIT = "CATEGORY_EXPLICIT",
e.CATEGORY_PROXY = "CATEGORY_PROXY", e.CATEGORY_GAME = "CATEGORY_GAME", ...
background.js.pretty:2049-2055 (one loop judges all three)
const m = [{category: P.CATEGORY_EXPLICIT, resp: f.resp.explicitPrediction},
{category: P.CATEGORY_PROXY, resp: f.resp.proxyPrediction},
{category: P.CATEGORY_GAME, resp: f.resp.gamePrediction}],
grep -i "proxySettings|getProxy|system.proxy" across 31 beautified files: 0 hits
14. All 17 predict responses: what the classifier said
17 POST prod-go-x3-predictor.goguardian.com/v1/predict captured, 0 parse failures
explicitPrediction: 13 non-null, all CONFIDENCE_NONE,
rawProbability min 0.005521 max 0.064622
proxyPrediction: 1 non-null, CONFIDENCE_NONE,
rawProbability 0.011449873447418213
gamePrediction: 1 non-null, CONFIDENCE_VERY_HIGH, rawProbability 1
(on the block page, triggered the report in footnote 18)
15. The org's smart-alert triggers: 2, one for proxies
policy.smartAlertsSettings.blocks (2 entries):
{"triggerName":"Explicit","category":"CATEGORY_EXPLICIT",
"minConfidence":"CONFIDENCE_HIGH","blockTarget":"BLOCK_TARGET_PAGE",
"priority":0}
{"triggerName":"Default Smart Alerts for Proxies Trigger",
"category":"CATEGORY_PROXY","minConfidence":"CONFIDENCE_HIGH",
"blockTarget":"BLOCK_TARGET_PAGE","priority":0}
messages: [] allowlist: 104 entries (allowlistV2: 136)
16. The extension inventory upload: getAll -> /extensions/sync
background.js.pretty:1809
const n = await chrome.management.getAll(); t(B(n))
background.js.pretty:45188
static SyncInstalledExtensions(e, t) {
return r.zl("/v1/admin/extensions/sync", {...t, method: "POST",
body: JSON.stringify(e, r.$U)})}
wire: POST /v1/admin/extensions/sync -> 200, 2x, last 21:37:06
request body 10,221 bytes, {"extensions":[...7 entries...]}, each:
{"id":"aapbdbdomjkkjkaonfhkkikfgjllcleb","name":"Google Translate",
"infoJson":"{...\"enabled\":true,\"homepageUrl\":\"https://chromewebstore...\"}",
"description":..., "homepageUrl":..., "iconUrl":..., "isApp":false}
all 7: Google Translate, HTML Editor, Clever Plus, Read Aloud, Camera,
Coding HTML, TypingClub
17. The block page books who, what & which rule
GET blocked.goguardian.com/?ctx=...&sum=... (4 hits, all -> 200)
ctx base64 decodes to:
afi=9428988&api=78924&oi=27356770153
&ou=https%3A%2F%2Fwww.crazygames.com%2F
&rs=ADMIN_SITE_FILTER&st=shield&v=1
afi = admin filter id, api = policy id 78924, oi = org id,
ou = the original blocked URL, rs = which rule fired
18. The classifier's report, with the page text attached
POST prod-ext-gateway.../v1/admin/smart-alerts/reports (captured live)
Req: {"url":"data:text/html,<h1>iframe test</h1><iframe src=\"https://www.crazygames.com/\" ...",
"prediction":{"category":"CATEGORY_GAME",
"confidence":"CONFIDENCE_VERY_HIGH","rawProbability":1},
"textMetadata":{"content":"hartford public schools page blocked by school admin ..."}}
Res: {"reportId":"4608d230-be5a-44c1-bdad-1147635c0e8d"}
19. No live classroom session, 1,068 polls worth of proof
GET inquisition.goguardian.com/v3/ext/livemode (1,068 captures)
last, 22:07:05: {"liveSessions":[]}