Add js_unpacker module for detecting and unpacking obfuscated JS - #3335
Add js_unpacker module for detecting and unpacking obfuscated JS#3335liquidsec wants to merge 9 commits into
Conversation
Five unpackers: source-map, Dean Edwards, obfuscator.io, Next.js, and webpack. Emits unpacked HTTP_RESPONSE for excavate to re-process, and FINDING for exposed source maps with sourcesContent.
…s are distinct HTTP_RESPONSE._data_id now includes the source module name, preventing js_unpacker's unpacked responses from being deduplicated against the original http module response. Adds integration test for the js_unpacker -> badsecrets chain.
- FINDING for exposed source map was missing required 'name' field - unpacked HTTP_RESPONSE re-emit reused the original body_md5/sha256, causing excavate's content-dedup to drop the unpacked body
📊 Performance Benchmark Report
📈 Detailed Results (All Benchmarks)
🎯 Performance Summary! 5 regressions ⚠️
26 unchanged ✅🔍 Significant Changes (>10%)
🐍 Python Version 3.11.16 |
Original fix stuffed str(self.module) into _data_id to keep js_unpacker's unpacked HTTP_RESPONSE distinct from http's original. That broke the pydantic JSON round-trip (module is a runtime object, not serialized). Reverts _data_id to method|url by default. Modules re-emitting an HTTP_RESPONSE for the same (method, url) with transformed content set data["_reemit_source"] to force distinct event.id. js_unpacker does so.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dev #3335 +/- ##
======================================
- Coverage 90% 90% -0%
======================================
Files 454 456 +2
Lines 47081 47382 +301
======================================
+ Hits 42324 42593 +269
- Misses 4757 4789 +32 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
singlerider
left a comment
There was a problem hiding this comment.
Tests pass, coverage is real: all 5 unpackers detected, end-to-end excavate extraction asserted, and the badsecrets chain test validates the _reemit_source dedup fix.
_data_id change is appropriately surgical (only diverges when _reemit_source is set), so normal http responses are unaffected.
Two nits:
- _reemit_source is stored in event.data and sanitize_data does not strip it, so it leaks into --json output. Suggest filtering underscore-prefixed keys in serialization.
- yara runs in run_in_executor_cpu (correct), but the per-unpacker DOTALL regexes run on the event loop on large minified JS. Consider offloading the heavy regex too.
LGTM.
|
Left Fixed the regex point: each unpacker's CPU work now runs off the event loop via Also fixed a pre-existing flake in the test, ~10% of runs on |
|
@singlerider need re-review |
singlerider
left a comment
There was a problem hiding this comment.
Re-reviewed at 6c28802. Both nits from the dismissed review are addressed, and the regex fix goes further than what I asked for. js_unpacker suite passes, 2 tests, three consecutive runs green.
🟢 Regex offloading
import re to import regex as re is the load-bearing half and I had not thought it through. A thread pool alone offloads nothing when stdlib re holds the GIL for the whole match, so wrapping the old module in run_in_executor_cpu would have moved the stall without fixing it. The split between helpers.re for single patterns and run_in_executor_cpu around the multi-step _decode / _deobfuscate / _extract_strings / _parse_source_map bodies is the right granularity: those do several passes each and batching them into one hop beats four round trips. RESOLVED ✅
🟢 Flaky dean-edwards assertion
Good catch, and the comment explains why the assertion moved rather than just moving it. Asserting on the unpacked body while leaving excavate's consumption covered by TestJsUnpackerBadsecretsChain keeps the coverage without depending on internal-event promotion. RESOLVED ✅
🔴 Blocking, _reemit_source still lands in --json output
You called this cosmetic and left it, but it is not confined to output rendering. HTTP_RESPONSE.sanitize_data does not strip it, so the key round-trips through event.json() and lands in the JSON/NDJSON output modules, verified locally. Anything reconstructing events from that output gets a key that only exists to steer _data_id, and the dedup identity of a reconstructed event now depends on a field that was never meant to be part of the event's public data. Strip underscore-prefixed keys in sanitize_data, or move the marker off data entirely and onto the event object.
The test hardcoded 127.0.0.1:8888, so under pytest-xdist any worker but gw0 aimed its scan at another worker's server and got no matching handler.
|
@singlerider I disagree that this is a problem. Having the value in the JSON is an advantage, not a disadvantage: it says more about how the event was discovered, and more data in the output is better than less. Also worth noting |
| # Modules that re-emit an HTTP_RESPONSE for the same (method, url) but with a | ||
| # transformed body (e.g. js_unpacker's unpacked response) set data["_reemit_source"] | ||
| # so the two events don't collide on event.id and get dropped by per-module dedup. | ||
| reemit = self.data.get("_reemit_source") |
There was a problem hiding this comment.
Stripping the key there takes it off the event itself, and
event.idhashesmethod|url|_reemit_source
Third option: keep it off data, hang it on the event, surface it top-level in json() and restore it in event_from_json, same as archive_url at base.py:958; ran it end to end and ids, hashes, and round-trip identity all hold with data_json clean.
🔴 Blocking: please do it that way, the existing archive_url precedent settles it.
|
@singlerider I think the fundamental disagreement here is that there is some disadvantage to it being in json output itself. I prefer it there, its important context. When retracing steps using JSON data, it is very important to know that the source was a re-emittal.
i consider this a clear positive, not a negative |
Summary
Adds a new
js_unpackermodule that detects and unpacks common JavaScriptpacking/obfuscation formats using YARA rules, then re-emits the unpacked
body as an
HTTP_RESPONSEsoexcavate(and everything downstream: badsecrets,secret scanners, etc.) can extract URLs, credentials, JWTs, and API endpoints
that would otherwise be invisible inside the packed source.
What it handles
.mapfile, decodessourcesContent,emits a
FINDINGwhen a source map with embedded sources is exposedeval(function(p,a,c,k,e,d){…})packer__BUILD_MANIFESTand__NEXT_DATA__webpackJsonpbundlesEmits
URL_UNVERIFIEDfor Next.js routes directly; everything else flowsthrough
excavateon the unpacked body.Other changes
HTTP_RESPONSE._data_idnow includes the source module name. Without this,js_unpacker's unpacked re-emits (source-map, Dean Edwards, obfuscator.io,webpack - 4 of the 5 unpackers) dedupe against the original
httpresponseand never reach downstream modules.
js_unpackerto thejs-auditpreset.