The SANS HACKATHON-2026 datasets lived behind a public Egnyte share that would only hand over one multi-gigabyte file at a time. This is the log of turning that wall into a resumable, automated 225 GB harvest — twelve iterations where every dead end fed the next idea.
Files harvested
~38
Total volume
225 GB
Iterations
12
Per-file size
10–40 GB
Iteration timeline
AIteration Timeline
Twelve attempts, each one a clue.
The path from a brute-forced browser click to a clean three-phase harvester. Every entry records the angle taken, the clue the browser's DevTools surfaced, and what its shortfall taught the next attempt.
01
Brute-force the UI
Angle
Drive the real website with Playwright — filter to a row, tick its checkbox, click Download, and let the browser save the file.
DevTools clue
The download fires from a JavaScript event, not a plain href. expect_download() resolves only once the browser commits the transfer.
What it taught
save_as() blocks on the entire multi-gigabyte transfer, one file at a time. With ~38 files at 10–40 GB each, a serial save loop would run for days and die on the first dropped connection. Decouple finding the file from moving its bytes.
02
Decouple discovery from transfer
Angle
Let the browser do only what it is good at — minting signed links — then hand those links to a purpose-built downloader.
DevTools clue
Every real download request hits a URL containing 'download' or 'dd/'. Listening on page requests captures the signed link the moment the UI generates it.
What it taught
aria2c moves bytes far better than a headless browser: 16 parallel connections, segmented ranges, and -c to resume a half-finished file. The architecture splits cleanly — browser finds, aria2c fetches — but the signed links still expire fast, so the two halves must run close together.
03
Native doors — dead end
Angle
Skip the browser entirely and use Egnyte's official Python SDK to list and download the folder.
DevTools clue
Every method on EgnyteClient expects an access_token. A public, anonymous share link has no token and no account behind it.
What it taught
Native paths are built for authenticated tenants, not anonymous public links. There is no credential to supply, so the SDK can never authenticate. The way in is the same unauthenticated API the public web UI itself calls.
04
Find the data API
Angle
Open F12, watch the network tab while the page loads, and hit the REST endpoint the UI is quietly calling.
DevTools clue
The page renders from a JSON payload. The visible UI is just a skin over a REST API that returns the folder structure as data.
What it taught
First direct contact with the backend. Talking to the API instead of the rendered page removes the browser, the virtualized DOM, and the per-click overhead — but the API has its own rules about paths, methods, and headers that have to be learned one error at a time.
05
Tune the requests crawler
Angle
Walk the tree breadth-first: POST a path, read its children from contents.results, queue folders, record files.
DevTools clue
An empty or "/" path returns 403 FOLDER_ACCESS_FORBIDDEN — enumeration has to start at the named root, not the share root. A persistent Session and a polite delay survive the rate limiter.
What it taught
All 38 files get mapped successfully — but the listing payload carries no download URL. Knowing a file exists is not the same as being able to fetch it. A separate call must resolve each path into a downloadable link.
06
Hunt the download endpoint
Angle
Probe candidate endpoints to turn a known file path into a real download URL.
DevTools clue
/download and /download_info return 404 — they do not exist. fsi-content returns 500, not 404: the endpoint is real, but it is being called the wrong way.
What it taught
A 500 is a clue, not a wall. A 404 means 'wrong door'; a 500 means 'right door, wrong key.' fsi-content is the resolver — it just needs something the bare request is not sending yet.
07
Browser-mirror the headers
Angle
Copy the browser's headers — Referer, Origin, User-Agent, X-Requested-With — so the request looks like it came from the page.
DevTools clue
Still 500. Cosmetic headers are not what the server checks. It wants a per-session XSRF token that the page's JavaScript mints at runtime, plus the matching session cookie.
What it taught
Looking like the browser is not the same as holding the browser's session state. The token cannot be guessed or hardcoded — it only exists after a real browser has loaded the page. The browser has to be kept in the loop just long enough to mint it.
08
Trigger-and-abort
Angle
Let the browser start each download to mint the signed link, capture the URL, then immediately cancel the stream before any bytes move.
DevTools clue
page.on('download') hands over the signed URL at the instant the transfer begins; download.cancel() throws the bytes away. The link is the prize, not the file.
What it taught
Defeated by the UI itself: the virtualized DOM renders no stable rows or buttons to click, there is no right-click 'Copy Link Address', the nested SRL-2018 folder is missed entirely, and selecting two or more files triggers a batch-download error. Clicking through the UI does not scale — the API handshake has to be reproduced directly.
09
The harvest
Angle
Right-click the real request in DevTools → "Copy as cURL" and read the exact handshake the page performs.
DevTools clue
The cURL export reveals three chained calls and the two secrets that make them work: an x-egnyte-xsrftoken header and a JSESSIONID cookie. fsi-content returns each item's real link as _self.linkedResources.itemDownload.url.
What it taught
This is the breakthrough. The full sequence is now visible end to end — LIST a folder, RESOLVE each item into a /dd/?entryId= link, then DOWNLOAD with cookie auth following the redirect to the CDN. Everything after this is just automating what the browser was already doing.
10
Build the blocker table
Angle
Turn every dead end into structured intel — phase, blocker, the DevTools clue that exposed it, and why it happens.
DevTools clue
Each failure mode maps to a specific server behavior: hanging events, 500s, connection caps, expiring tokens, and the session handshake.
What it taught
Writing the blockers down as data, not prose, makes the final design fall out almost mechanically — every row points directly at a countermeasure. The full table is rendered in Section B below.
11
Synthesize
Angle
Distill every lesson into a single canonical header set, with the XSRF token minted live rather than guessed.
DevTools clue
The minimal working request needs exactly: a real User-Agent, the live x-egnyte-xsrftoken, the JSON content type, and the matching Origin/Referer for this specific link.
What it taught
The token is no longer a constant to hardcode — it is captured from a live browser session and injected into every API call. This is the bridge between 'the browser can do it' and 'a script can do it.'
12
Result — fastauto.py
Angle
A three-phase hybrid: a real browser bootstraps the session once, then plain HTTP does all the heavy lifting.
DevTools clue
Phase 1 mints JSESSIONID + x-egnyte-xsrftoken via Playwright. Phase 2 paginates the listing over httpx. Phase 3 streams each entryId serially with 1 MB chunks and resume support.
What it taught
The browser is used for exactly one thing — minting credentials — and then gets out of the way. Everything heavy runs over raw HTTP: paginated, serial, resumable. 225 GB harvested without tripping the bot defenses that killed every earlier attempt.
Blocker table
BThe Blocker Table
Every wall, decoded.
Each failure was logged as structured intel — the phase it struck in, the symptom, the DevTools clue that exposed it, and the underlying reason. Read together, the table is a blueprint for the final design.
PhaseBlockerDevTools clueWhy it happens
Phase
DiscoveryUI Blocking / Race Conditionsexpect_download hangsWaits on a JS-closure event, not an href.
Phase
DiscoveryVirtualized DOMNo stable <tr> / buttonsRows are recycled on scroll; selectors never resolve.
Phase
DiscoveryNested folder omittedSRL-2018 missing from resultsBFS root never descended into the sub-collection.
Phase
Execution500 Internal Server Error500 on fsi-contentNeeds JS-injected x-egnyte-xsrftoken.
Phase
ExecutionWrong endpoint404 on /download, /download_infoThose routes do not exist; resolver is fsi-content.
Phase
ExecutionConcurrent Connection LimitsHangs on the 5th file5+ concurrent downloads look like a bot.
Phase
ExecutionBatch-download errorError on 2+ selectionsMulti-select triggers a zip path the link can't serve.
Phase
Security / APITokenizationtoken= / session= in URLSigned URLs expire within seconds.
Security / APIForbidden root403 FOLDER_ACCESS_FORBIDDENEmpty / "/" path; must start at named root.
The intercepted handshake
CThe Intercepted Handshake
Three calls, end to end.
"Copy as cURL" laid the whole protocol bare: list a folder, resolve each item into a signed link, then download it. The two secrets that unlock all three are captured live from the browser session.
01
LISTlinks/info/<link-id>/contents
POST with x-egnyte-xsrftoken + JSESSIONID. Paginated (limit 48, offset n). Returns the folder's children as JSON.
02
RESOLVEfsi-content
Each item carries _self.linkedResources.itemDownload.url → a /dd/<link-id>/?entryId=<entryId> link.
03
DOWNLOADdd/<link-id>/?entryId=<entryId>
Cookie auth (JSESSIONID). Follows the redirect to the CDN and streams the bytes — resumable, one file at a time.
Carried on every callx-egnyte-xsrftoken: <captured-at-runtime>JSESSIONID: <captured-at-runtime>
Final architecture
DFinal Architecture · fastauto.py
Browser mints, HTTP harvests.
The working design uses a real browser for exactly one job — minting credentials — then lets plain HTTP do everything heavy. Three phases, each answering a blocker the earlier attempts ran into.
01Playwright (real browser, once)
Bootstrap
Load the share once with a real browser and capture the live JSESSIONID cookie and x-egnyte-xsrftoken header.
02httpx (plain HTTP)
Enumerate
Walk the tree over raw HTTP, paginating the listing endpoint until every one of the ~38 files is mapped.
03httpx streaming
Download
Resolve each entryId and stream it serially in 1 MB chunks with resume support — no concurrency to trip the bot caps.
Bootstrap→Enumerate→Download
Blocker → Solution
expect_download hangs on JS events→Skip the UI entirely; call the REST API directly.
500 on fsi-content (missing token)→Mint x-egnyte-xsrftoken live in the browser, then inject it.
Signed URLs expire in seconds→Resolve each entryId immediately before fetching it.
5+ concurrent downloads look like a bot→Fetch serially, one entryId at a time.
Dropped connections on multi-GB files→Stream in 1 MB chunks with resume (-c) support.
401 / 403 without a session→Carry JSESSIONID + XSRF on every request.
Post-mortem: resumable bulk download
EPost-mortem · download_aria2c()
The disk ran out, not the link.
The first full pass stopped 12 files in. The exit code blamed a keyboard interrupt — the aria2 results table told the truth: 167 GB landed, then 26 files died at once on No space left on device. Fixing it turned the downloader crash-safe and idempotent.
12 / 38Completed before halt
~167 GBTransferred
26Failed files
errNum 28Error code
Root cause · two layers
01
Disk exhaustion
The target volume ran out of space mid-run. The aria2 Download Results table — not the exit code — told the real story: every file after base-rd-01-cdrive.E01 failed with errNum=28, No space left on device.
02
Why it cascaded
aria2 defaults to --file-allocation=prealloc, reserving each file's full size before transferring. The next 16 GiB reservation filled the disk, so that file and all 25 after it failed instantly at the allocation step with 0 bytes moved (0B/0B). The interruption was a side effect of space, not a keyboard Ctrl+C.
Diagnosis · reusable pre-flight
A manifest-vs-disk check compares each file's expected size against what is on disk and whether a .aria2control file is present, then weighs the remainder against free space — an exact “X done / Y remaining / ~N GB needed / fits?” verdict before resuming.
Resolution · two flags in download_aria2c()
--file-allocation
prealloc→none
Only consume space as real bytes arrive, eliminating the preallocation ENOSPC cascade.
--allow-overwrite
true→false
With true, a completed file (no control file) is restarted from scratch — a naive resume would re-download all 12 finished files (~167 GB). With false + -c, aria2 skips completed files and resumes partials, making re-runs idempotent.
Resume procedure
01
Clear failed partials (optional)
Reclaim space. Safe because allocation-failed files hold ~0 real bytes, and only incomplete files carry a .aria2 sidecar.
02
Re-run the same command
It re-bootstraps a fresh session, skips done files, and continues the rest — no flags to change.
03
Repeat if space runs short
Free more space and re-run; progress is preserved across every interruption.
Takeaways
01
For large multi-GB pulls, always pre-flight free space against remaining bytes — and disable aria2 preallocation.
02
Resume is aria2-native via its .aria2 control files; never switch aria2-started partials to the httpx downloader, which tracks progress separately through .part files.
03
Net effect: the downloader is now interrupt-tolerant and re-runnable with zero wasted bandwidth.