Protocol Sift · Acquisition LogFind Evil! · 2026

One locked share.

Twelve ways in.

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.

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.
Phase
Security / APIState / Session Dependency401 / 403Missing JSESSIONID + XSRF handshake.
Phase
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.

BootstrapEnumerateDownload
Blocker → Solution
expect_download hangs on JS eventsSkip 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 secondsResolve each entryId immediately before fetching it.
5+ concurrent downloads look like a botFetch serially, one entryId at a time.
Dropped connections on multi-GB filesStream in 1 MB chunks with resume (-c) support.
401 / 403 without a sessionCarry 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
preallocnone

Only consume space as real bytes arrive, eliminating the preallocation ENOSPC cascade.

--allow-overwrite
truefalse

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.