Skip to content

Instantly share code, notes, and snippets.

@tubaxenor
Created July 31, 2026 04:36
Show Gist options
  • Select an option

  • Save tubaxenor/6a090585034c654855e57e7bd7fd9595 to your computer and use it in GitHub Desktop.

Select an option

Save tubaxenor/6a090585034c654855e57e7bd7fd9595 to your computer and use it in GitHub Desktop.
CVE-2026-66066 (Active Storage / libvips) — Phase 5 log analysis for scholarly-app, 2026-07-16 to 2026-07-29

CVE-2026-66066 log analysis: scholarly-app

Phase 5 of kr2s-was-i-exploited, run 2026-07-31 against Datadog.

Method

The first attempt at this pulled raw log lines and paginated. That was the wrong approach: the MCP search tool caps each response at roughly 136–222 records, so 6,000 lines would have taken about 44 calls and still produced samples rather than answers.

These logs carry full structured attributes — @http.status_code, @http.method, @http.url_details.path, @network.client.ip, @duration — so every figure below is a DDSQL aggregation over the complete population. No sampling, no pagination.

Range: 2026-07-16T00:00:00Z (oldest retained log) to 2026-07-29T22:00:00Z (just past the fix deploy at 21:02:13Z). Retention is ~15 days with no online archives.

Endpoint totals

Grouping all Active Storage traffic by endpoint, method and status returns three rows and no more, which is itself the answer to "were any requests rejected":

Endpoint Method Status Requests Distinct IPs
/rails/active_storage/blobs/* GET 302 19,418 312
/rails/active_storage/representations/* GET 302 4,874 345
/rails/active_storage/direct_uploads POST 200 1,014 82

Not one 4xx or 5xx anywhere in Active Storage traffic over the window.

The blobs endpoint is recorded for completeness and is not an attack vector: it redirects to S3 and serves stored bytes directly, with no variant processing, so it never reaches libvips or matload.

The five Phase 5 queries

1. Uploads. 1,014 across 82 IPs, all 200. Long-tailed distribution — busiest address 190, then 140, 78, 39, 37, 36 — mixed residential and campus IPv4/IPv6 behind Cloudflare. No single scripted client working a sequence.

2. Representations. 4,874 across 345 IPs, all 302 with 0 bytes. Daily volume 10–648 with dips on 07-18/19 and 07-25/26 (both weekends); peak service time stays within 82–317 ms every day. No burst, no outlier, no slow tail of the kind a large file read would produce.

Limit worth stating: a successful exploitation also returns 302. This rules out a noisy or failing attempt, not a quiet successful one.

3. Evidence-destruction paths — the highest-value query. Zero requests reached any of the six routes that resolve a blob from a client-supplied signed id, by any mutating method. Three candidates surfaced and all fall away:

  • 38 PATCH /api/v1/faculty_activities/:id from three AWS addresses (3.145.136.244 ×35, 3.135.197.103 ×2, 3.137.160.76 ×1), all 200. No /files segment, so this is the record update, not the files sub-resource. FacultyActivitySchema declares no attachment field — attachment appears only in mapped_import_schema.rb — so the action cannot attach or purge a blob.
  • 187 PUT and 26 PATCH on profile routes, none on the avatar, preferred_avatar or self_managed_cv_upload sub-paths. ProfileSchema also declares no attachment field.
  • 2 PATCH matching a %file_upload% filter resolved to /institution/faculty_evaluations/questions/multiple_file_uploads/<uuid>, an unrelated feature. Pattern false positive.

The attach-then-purge blind spot is closed for the period the logs cover.

4. Rejected requests. None. The sweep is structurally blind to rejected requests because they create no blob, so this query is the only place a second actor could appear — and it is empty.

5. Three-stage reconciliation. Does not descend, structurally rather than anomalously. Uploads (1,014) sit below representations (4,874) because most of the latter render avatars attached long before this window. The third term, surviving variant records for unattached blobs, is ~0 because the purge job removed them. The series cannot be formed on this application and its absence carries no signal.

Checksum join: not applicable. The skill's attribution path filters log lines by the checksums of crafted objects the sweep found. The sweep found none, so attribution has no starting point — not merely bounded by retention, but with nothing to anchor to.

Limits

These are Heroku router logs. They carry method, path, host, request id, client IP, dyno, service time, status and bytes. They do not carry a user agent, an authenticated account, or a request body, so attribution reaches the client address and stops there.

A clean 13-day window says nothing about the preceding 30 months.

Superseded raw capture

direct_uploads.tsv and representations.tsv are partial raw-line pulls from the first attempt (883 of 1,014 and 889 of 4,874 respectively). The aggregations above cover the full population and supersede them. They are kept only as a sample of the raw line format, which is what a future S3 sweep would join against on timestamp if it ever surfaces a crafted object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment