hero-image

Finding a Public Object Storage Bucket Behind a University LMS


A short writeup on a recent finding from an authorized assessment of my faculty’s learning management system. The bug itself is not novel public S3-compatible buckets are an old class of misconfiguration but the chain of reasoning that led from a clean Wappalyzer fingerprint to nearly a thousand downloadable files is worth documenting, especially for students learning to do recon on real targets.

Some specifics in this writeup are redacted while remediation is in progress.

How this started

The original plan was much narrower. The application has a personal file-upload feature, and after seeing how loosely the multipart endpoint handled Content-Type it seemed worth poking at file upload bugs are a well-trodden path to RCE, stored XSS, or at minimum some interesting MIME-confusion behavior. So I started reading the upload pipeline carefully, mapping where files went after the API received them, and which host actually served them back to the browser.

That last question is what redirected the whole engagement. The HTML shell of the SPA loaded a favicon from a domain that had nothing to do with the API host, and the URL had a structure that screamed object storage. The file-upload bug never got tested. Following the favicon turned out to be a more productive use of the next two hours than any payload I had prepared.

The target

The application is a Vue.js single-page app, served from pixelpath.ccit-ftui.com, with a separate API host at pixelpath-api.ccit-ftui.com. Authentication is handled via Laravel Passport with RS256-signed JWTs, and role checks are server-side rather than encoded in the token. That’s a deliberately good design it closes off the usual lane of token-tampering attacks and forces the auditor to look elsewhere.

Recon

The frontend is heavily code-split. Browsing the asset directory revealed a familiar Vite layout: apiUser-*.js, apiClassroom-*.js, apiSetting-*.js, and so on. Beautifying these gives a near-complete map of the backend API surface without ever needing to walk the UI manually. The HTML shell also exposed something interesting in its favicon link:

<link rel="icon"
  href="https://is3.cloudhost.id/<redacted-bucket>/<tenant>/public/assets/image/<logo>.png">

That single line gave away the storage backend: IDCloudHost’s S3-compatible object storage, with what looked like a tenant-scoped prefix. From the API traffic, it was clear that the LMS uses this bucket for both static assets (lecturer photos, lesson images) and user-uploaded content (assignment submissions, profile pictures).

The mental model at this point: the LMS uploads files via an authenticated API endpoint on pixelpath-api.ccit-ftui.com, the API forwards them to the bucket, and the resulting URL goes back into the database. Files are then served back to users via direct bucket URLs. That last step is the weak link if access controls aren’t applied at the storage layer.

The misconfiguration

S3-compatible buckets typically expose a ListObjectsV2 operation. It returns the keys of every object in the bucket and is gated by bucket policy. A correctly configured bucket should reject anonymous calls to it. So the first probe is the simplest one possible:

curl "https://is3.cloudhost.id/<redacted-bucket>/?list-type=2"

The response was a long XML document containing <Contents> elements for every object in the bucket no authentication header, no AWS signature, no cookie. The bucket simply allows anonymous listing.

This is worth pausing on, because it is not the same thing as a CORS misconfiguration, and the two are often confused. CORS governs which web origins are allowed to read a response from a browser context; it has no effect on direct, server-to-server, or curl-based requests. The bucket here is open at the access-control layer of the storage service itself, which is a strictly more severe condition than a permissive CORS policy. A curl from any machine, anywhere, with no headers, gets the listing.

A quick tally of what was visible:

grep -c "<Key>" bucket.xml      # ~991
grep -oE "<Key><tenant>/[^/]+" bucket.xml | sort -u
# <tenant>/document
# <tenant>/image

The bucket contained roughly a thousand objects spanning May 2025 to January 2026, organized into logical prefixes for documents, lesson images, lecturer photos, profile images, and a cw-assignment-result-attachments directory holding student-uploaded coursework.

Confirming that the listing is also retrievable

image.png

Listing keys is one thing. The interesting question is whether the actual object content is also retrievable anonymously. So the next probe was a single GET on a key from the listing:

curl -I "https://is3.cloudhost.id/<redacted-bucket>/<tenant>/image/YYYY/MM/profile/<timestamp>.png"
# HTTP/2 200

Two hundred OK, no auth, no presigned-URL parameters in the request. Confirmed: not just listable but readable. To estimate the blast radius, I scripted a parallel pull of every key in the listing. The result was 946 files totaling 370 MB, retrieved with a student account’s worth of network access that is, none.

image.png

A small subset (45 files, all under the lecturers/ prefix) returned 403 Forbidden on direct GET, even though their keys appeared in the listing. That makes the misconfiguration more interesting, not less: someone configured per-object ACLs on lecturer photos but did not apply the same controls to student profile images or coursework. The capability was understood and selectively used. The omission elsewhere is therefore a policy gap, not a knowledge gap.

What was actually exposed

The bucket’s content fell into a few categories:

  • Coursework submissions drawings, design work, screenshots of student projects, scanned assignments
  • Lesson images uploaded by instructors
  • User profile images
  • Lecturer reference photos (mostly protected, see above) The keys themselves are also a finding: original filenames are preserved verbatim. So even without downloading anything, the listing reveals strings like tugas-<subject>-<student-fullname>.jpeg, <class-code>-<student-name>-<assignment>.png, and in some cases student ID numbers embedded in filenames. Under Indonesia’s UU PDP (Personal Data Protection Law, No. 27 of 2022), the combination of student names, class codes, and academic records constitutes processable personal data, and the bucket exposes it to anyone who knows the URL.

The most consequential single object was a screenshot uploaded as part of a coursework submission that contained plaintext credentials for an unrelated third-party administrative interface. The credentials were not the LMS’s they belonged to a system the student was documenting for their assignment but the bucket served as the leak vector. The credentials were verified as present. They were not used. Reporting them to the faculty so the affected student could rotate them was the only appropriate next step.


Reporting

The misconfiguration was documented and reported to the faculty along with a manifest of retrieved evidence (with SHA-256 hashes for integrity), redacted screenshots, and remediation guidance. The recommended fix is straightforward: deny anonymous s3:ListBucket, deny anonymous s3:GetObject at the bucket level, and migrate the LMS to issue presigned URLs for legitimate downloads. As a hardening step, user-supplied filenames should be replaced with UUIDs at the storage layer to prevent the listing itself from becoming a PII leak.

Takeaways

A few things worth internalizing if you do this kind of work.

The single most useful piece of information in this engagement came from a <link> tag in the HTML shell. Recon is mostly about reading carefully, not about clever tooling. The favicon URL gave away the storage backend in the first five minutes; everything afterward was confirmation. The lesson generalizes: the bug you sit down to find is rarely the bug you end up reporting. Stay open to whatever the target is actually showing you.

Be precise about what category of bug you are reporting. “CORS misconfig” and “public bucket” produce similar-looking symptoms in a browser but require different fixes and have different severities. Reviewers will notice the mislabel, and getting it right is part of the writeup being credible.

Finally: when an authorized test surfaces credentials belonging to someone outside the engagement scope, the right action is to stop, document, and report, not to verify usability. The legal and ethical line is at retrieval, not at authentication. Holding that line is not a constraint on the work; it is what makes the work legitimate in the first place.

Remediation

  • Deny anonymous s3:ListBucket and s3:GetObject at the bucket policy level.
  • Serve user-uploaded content through short-lived presigned URLs issued by the application, not direct bucket URLs.
  • Replace user-supplied filenames with UUIDs at the storage layer; keep the original name only as object metadata.
  • Apply consistent ACLs across all prefixes student data should be protected at the same level as lecturer data.
  • Add basic upload-time scanning (OCR + secret pattern matching) to catch credentials accidentally embedded in screenshots.