Hunt Spring Boot Heapdump Exfiltration Asim


Hunts Spring Boot Actuator heap-dump exfiltration in ASIM Web Session data by matching the endpoint ID as a path segment (position-independent) instead of hardcoding `/actuator/heapdump`. Recovers the real base path from traffic and rank...

KQL Library  /  Hunting

 Hunting Web Tier hunt-spring-boot-heapdump-exfiltration-asim.kql

Hunts Spring Boot Actuator heap-dump exfiltration in ASIM Web Session data by matching the endpoint ID as a path segment (position-independent) instead of hardcoding `/actuator/heapdump`. Recovers the real base path from traffic and ranks by response-size verdict.

 Download .kql
// Author: Ian D. Hanley (DevSecOpsDad) | linkedin.com/in/ianhanley | devsecopsdad.com | devsecopsdadattack.com
// Hunts Spring Boot Actuator heap-dump exfiltration in ASIM Web Session data by matching the endpoint ID
// as a path segment (position-independent) instead of hardcoding /actuator/heapdump — recovers the real
// base path from traffic and ranks by response-size verdict, not by request volume.
// Source: KQL Detection of the Week: A Heap of Trouble (2026-08-03) — https://devsecopsdadattack.com/2026-08-03-KQL-Detection-of-the-Week_-A-Heap-of-Trouble-Detecting-Spring-Boot-Heapdump-Theft-When-the-Exfiltration-Is-a-GET-Request_/

let lookback = 7d;
// Endpoint IDs are fixed by Spring. The BASE PATH is not -- the ISC capture shows
// /admin-api/actuator/heapdump, and management.endpoints.web.base-path can move it
// anywhere. Anything that hardcodes "/actuator/heapdump" is matching a default,
// not a protocol.
let ActuatorEndpoints = dynamic([
    "heapdump","env","configprops","beans","threaddump","mappings",
    "loggers","httpexchanges","httptrace","auditevents","jolokia","dump","trace"
]);
// Endpoints that leak secrets directly rather than structure. Used for RANKING,
// not for filtering -- a 200 on /beans is still an exposed management surface.
let SecretBearing   = dynamic(["heapdump","env","configprops","jolokia","dump"]);
// DERIVED from the endpoint list, not maintained beside it. The prefilter has to be a
// SUPERSET of the authoritative test or it narrows the detection silently -- and a
// hand-kept parallel copy drifts, which is exactly what mine did (see below). The
// "actuator" term is added as free breadth, not because the path is assumed to
// contain it. See the encoding caveat below.
let PrefilterTerms  = array_concat(ActuatorEndpoints, dynamic(["actuator"]));
// A ranking hint, NOT a gate. Nothing is discarded for being under it.
let ServedFloorBytes = 1048576;
_Im_WebSession(starttime=ago(lookback), url_has_any=PrefilterTerms)
// --- Path normalization. Decode FIRST: an encoded slash is a slash to the server
// and is not a slash to split(). Then strip scheme+authority (HTTPSession events
// carry them, WebServerSession events don't), then query, fragment, matrix params.
| extend UrlLower = tolower(url_decode(tostring(Url)))
| extend PathOnly = trim_start(@"[a-z][a-z0-9+.\-]*://[^/]*", UrlLower)
| extend PathOnly = tostring(split(PathOnly, "?")[0])
| extend PathOnly = tostring(split(PathOnly, "#")[0])
| extend PathOnly = tostring(split(PathOnly, ";")[0])
// Collapse //, then drop trailing slashes, so /actuator//heapdump/ and
// /actuator/heapdump are the same request -- because to Spring, they are.
| extend NormPath = trim_end(@"/+", replace_regex(PathOnly, @"/{2,}", "/"))
| extend Segments = split(NormPath, "/")
// THE authoritative test: exact equality against a parsed path SEGMENT, at ANY
// position. Not `Url has "heapdump"` -- that is a question about a term appearing
// somewhere, which is a different question and answers yes to /docs/heapdump-howto.html.
// And not the LAST segment either: /env, /loggers, /metrics and /health all take a path
// selector, and jolokia is mounted at /**, so on those the endpoint ID is never final.
| extend Matched = set_intersect(Segments, ActuatorEndpoints)
| where array_length(Matched) > 0
// Matched is almost always a single element. When it isn't (a selector that happens to
// equal another endpoint ID -- rare, e.g. /actuator/env/loggers), the element order of
// set_intersect is not documented, so Matched[0] is arbitrary rather than "the first in
// the path". `Matched` stays in the output so you can see when that happened.
| extend Endpoint    = tostring(Matched[0])
| extend EndpointIdx = array_index_of(Segments, Endpoint)
// Everything BEFORE the endpoint ID is the management base path, recovered from the
// data rather than assumed. Everything AFTER it is the selector, and the selector is
// evidence: /env/spring.datasource.password is a targeted secret read, /env is a sweep.
| extend BasePath = iff(EndpointIdx <= 0, "",
                        strcat_array(array_slice(Segments, 0, EndpointIdx - 1), "/"))
| extend Selector = strcat_array(array_slice(Segments, EndpointIdx + 1, -1), "/")
| extend TargetedSelector = isnotempty(Selector)
// --- Result. EventResultDetails is the HTTP status code; HttpStatusCode is an alias.
// EventOriginalResultDetails holds the source's raw value when normalization couldn't
// map it, so it's the better second look before falling back to anything coarser.
| extend StatusRaw  = coalesce(tostring(EventResultDetails),
                               tostring(column_ifexists("EventOriginalResultDetails","")))
| extend StatusCode = toint(extract(@"(\d{3})", 1, StatusRaw))
// THREE states again, and for a sharper reason than the size field. ASIM defines
// EventResult "Success" as status < 400 -- that is NOT evidence of a 200. A 302 to a
// login page is Success, and a 302 to a login page is the single most common response
// a PROPERLY PROTECTED actuator endpoint gives. Collapsing it into "answered" would
// launder the most important negative in this query into a positive.
| extend AnswerVerdict = case(
      StatusCode == 200,                   "Served",
      StatusCode between (300 .. 399),     "Redirected",
      StatusCode in (401, 403),            "AuthWall",
      StatusCode == 404,                   "NotPresent",
      isnotnull(StatusCode),               "OtherStatus",
      tostring(EventResult) =~ "Success",  "MaybeServed",   // sub-400, code unknown
                                           "NotServed")
| extend
    Served      = AnswerVerdict == "Served",
    MaybeServed = AnswerVerdict == "MaybeServed"
// --- Size. DstBytes is defined as bytes from the destination to the source, so on a
// web session it is the RESPONSE. HttpResponseBodyBytes is more precise and arrived in
// schema 0.2.7, so it may not exist in your parser at all -- column_ifexists, not
// coalesce, because coalesce on an absent column is a semantic error, not a null.
| extend RespBodyBytes = column_ifexists("HttpResponseBodyBytes", long(null))
| extend RespDstBytes  = column_ifexists("DstBytes", long(null))
// Resolve zero-as-null HERE, not only in the verdict below. coalesce() takes the first
// non-null value, so a parser that writes 0 into the PRECISE column shadows a real
// measurement sitting in the coarse one, and the more accurate field silently makes
// the answer worse. iff() on a null predicate returns the else branch, so an absent
// column and a zero both fall through to DstBytes.
| extend RespBytes = coalesce(iff(RespBodyBytes > 0, RespBodyBytes, long(null)),
                              RespDstBytes)
// Three states, not two. "No byte count" is NOT "small response" -- see the bonus.
| extend SizeVerdict = case(
      isnull(RespBytes),                "Unknown",
      RespBytes == 0,                   "Unknown",     // most parsers write 0 for absent
      RespBytes >= ServedFloorBytes,    "LargeBody",
                                        "SmallBody")
// Resolve optional columns ONCE, up here. column_ifexists() inside a summarize
// aggregate is asking a schema question in a place that should only see values.
| extend
    Client      = tostring(SrcIpAddr),
    ForwardedFor= tostring(column_ifexists("HttpRequestXff", "")),
    UserAgent   = tostring(column_ifexists("HttpUserAgent", "")),
    Service     = coalesce(tostring(column_ifexists("HttpHost","")),
                           tostring(column_ifexists("DstFQDN","")),
                           tostring(DstIpAddr)),
    Secretive   = Endpoint in (SecretBearing)
// Entity = the CLIENT. The service is an attribute here; it becomes the entity in the
// honorable mention, because "who took it" and "what of mine answers" are two questions.
| summarize
    Requests        = count(),
    ServedHits      = countif(Served),
    MaybeServedHits = countif(MaybeServed),
    SecretHits      = countif(Served and Secretive),
    SecretMaybeHits = countif(MaybeServed and Secretive),
    // A selector means the caller named a specific property, logger or MBean rather
    // than sweeping the endpoint. Ranked, not gated -- a bare /heapdump has no selector
    // and is still the worst row in this table.
    TargetedHits    = countif(TargetedSelector),
    // A sub-400 with an unknown code and a multi-megabyte body is served in
    // everything but the paperwork, so this counter spans both verdicts.
    LargeBodyHits   = countif((Served or MaybeServed) and SizeVerdict == "LargeBody"),
    UnknownSizeHits = countif(Served and SizeVerdict == "Unknown"),
    MaxRespBytes    = max(RespBytes),
    Endpoints       = make_set(Endpoint, 15),
    MultiMatchPaths = make_set_if(NormPath, array_length(Matched) > 1, 5),
    Services        = make_set(Service, 10),
    Paths           = make_set(NormPath, 10),
    BasePaths       = make_set_if(BasePath, isnotempty(BasePath), 10),
    Selectors       = make_set_if(Selector, isnotempty(Selector), 10),
    Statuses        = make_set(StatusRaw, 10),
    Verdicts        = make_set(AnswerVerdict, 8),
    UserAgents      = make_set(UserAgent, 5),
    Methods         = make_set(HttpRequestMethod, 5),
    Xff             = make_set_if(ForwardedFor, isnotempty(ForwardedFor), 5),
    Sources         = make_set(EventProduct, 5),
    ActiveDays      = dcount(bin(TimeGenerated, 1d)),
    FirstSeen       = min(TimeGenerated),
    LastSeen        = max(TimeGenerated)
    by Client
| extend
    DistinctEndpoints = array_length(Endpoints),
    Enumerating       = array_length(Endpoints) > 2,
    // ipv4_is_private() returns null for a v6 literal, so an unguarded call leaves a
    // v6 client as neither internal nor external. Default to "not internal" -- same
    // direction as the honorable mention, for the same reason.
    ProbablyInternal  = coalesce(ipv4_is_private(Client), false),
    MaxRespReadable   = format_bytes(coalesce(MaxRespBytes, long(0)))
// Ranking, in order of what actually changes your afternoon:
//   1. a secret-bearing endpoint returned a CONFIRMED 200
//   2. it answered with a body big enough to be a real dump
//   3. it answered and we CANNOT TELL how big -- an unknown outranks a small body
//   4. a secret-bearing endpoint answered sub-400 with no status code -- unverified,
//      and unverified belongs above "nothing", never above "confirmed"
//   5. breadth of enumeration
// Raw request volume is deliberately last. One successful GET is the whole incident;
// ten thousand 404s are Tuesday.
| order by SecretHits desc, LargeBodyHits desc, UnknownSizeHits desc,
           SecretMaybeHits desc, TargetedHits desc, DistinctEndpoints desc,
           ServedHits desc