top of page

GitLab CVE-2026-97229 Exploited: Patch Ruby, Hunt Crashes

Writer: Inception Security
Inception Security
11 hours ago
15 min read
Inceptor and Glitch hunt GitLab CI regex crashes (GitLab CVE-2026-97229 hunt comic)

What this means for your clients: attackers went after a flaw in self-hosted GitLab code servers, and Alpine Linux stayed exposed after two upgrades, the second because the fix also depends on the Ruby that GitLab runs on.


Alpine Linux upgraded GitLab after the fix shipped and was still exposed. On October 20 and 21 a new account sent crafted CI configurations and crashed GitLab workers eight times, getting partway through the exploit in four. Alpine found no evidence that code ran, cannot rule it out, and rebuilt GitLab from scratch.


The bug, GitLab CVE-2026-97229, is a remote code execution flaw. GitLab says that under certain conditions an unauthenticated user could run code on the server through a crafted regular expression in CI/CD configuration. The flaw is an integer overflow in Onigmo, Ruby's built-in regex engine.


The fix is in Ruby, not GitLab


GitLab's patch release lists the affected versions of GitLab CE/EE:


  • 19.2 before 19.2.8

  • 19.3 before 19.3.5

  • 19.4 before 19.4.2


The bug fix notes list Ruby regexp patches, and Alpine says those patches are the fix and that GitLab applies them only in its official packages.


If you build from source or run your own image on standard Ruby, upgrading GitLab alone does not fix this CVE.


You also need a Ruby with the regexp patches. Ruby recommends 3.3.13, 3.4.12 or 4.0.9 or later, shipped October 23, which carry all four related fixes; its table lists 4.0.8 as the first 4.0 release with the fix for this CVE.


CERT-EU's advisory lists 19.3.4 as fixed, but GitLab and NVD list 19.3 before 19.3.5 as affected, and Alpine says 19.3.4 lacked the fix.


CISA added CVE-2026-97229 to its Known Exploited Vulnerabilities (KEV) catalog on October 29, 2026, with a November 1, 2026 due date for federal civilian agencies. CISA marks forensic triage as required, lists ransomware use as unknown, and classifies the flaw as CWE-190. The CVSS 10.0 score is GitLab's own, and NVD lists GitLab's score as the only one. No actor is named, Alpine is the only target named in the sources we read, and none gives a count of targets. CISA also notes that other products could be affected by the same Ruby issue.


That fits Alpine's warning that this is a Ruby bug, not only a GitLab one: any program that compiles regular expressions from untrusted input can be exposed. Ruby says the bugs are reached only that way.


For a separate GitLab flaw, see our earlier post on CVE-2026-85706.


What happened at Alpine


GitLab released the fix on October 12. On October 13 Alpine upgraded to 19.3.4 instead of 19.3.5 by mistake. The attacker registered on October 20; the October 21 attack brought crashes, 502s and failed CI jobs. The attacker also downloaded Alpine's GitLab container image, which Alpine says suggests the attacker was tuning the exploit to its exact build.


The October 22 move to 19.3.6 ran on standard Ruby and stayed vulnerable. The account was blocked on October 23, and return attempts on October 25 and 26 failed.


Alpine closed GitLab to everyone except developers and build servers on October 27, rebuilt it with every password reset and secret keys replaced, and reopened it on 19.3.7 with Ruby 3.3.13, on October 29 by Alpine's timeline (announced October 30).


Alpine warns that a successful exploit would leave no crash and no trace in the web logs. Some background job logs and the old container filesystem were gone, and outbound connections were never logged. Alpine found no tampering in the October 21 releases or in packages built October 20 to 24, in checks it is extending through October 27, and its signing keys were never stored on the GitLab server. It also saw automated login attempts with passwords leaked from other sites, which is a separate problem.


What was at risk and what to rotate


Had it worked, the attacker could have reached user emails, names and IPs, password hashes, 2FA secrets, CI/CD variables, runner tokens, webhook, integration and mirror credentials, personal access tokens (stored as hashes), private repositories, and confidential issues and merge requests.


Alpine reset passwords, 2FA, tokens, feed tokens and CI/CD variable values, gave the server new SSH host keys, and told owners to re-enter integration settings and replace webhook, mirror and OAuth secrets.


Use that as the rotation list for any instance exposed and unpatched after October 12 where you cannot rule out compromise.


Check exposure, then fix both layers


Read the GitLab version in the admin area or with gitlab-rake gitlab:env:info, then check the Ruby version that GitLab's own processes use, not just ruby -v in a shell.


Check who can register and who can call CI lint, through POST /api/v4/projects/:id/ci/lint or GraphQL ciLint.


Install a fixed official GitLab package or image, or upgrade GitLab and your separately supplied Ruby together and confirm the patches in the Ruby GitLab actually runs. Require admin approval for sign-ups (Alpine now does this for email and password registrations), and rotate if exposed.


Hunts


The table under the hunts, "Interpret before you escalate", says what is benign and what is not. Every hunt is ours, and a hit is a lead to review, not proof of an attempt. GitLab security published six detections for this bug, with Sigma rules, in its TLDR repository linked in Sources. Ours are independent hunts informed by those detections and GitLab's response guidance, for four SIEMs. They do not implement the full Sigma set, and their filters and thresholds differ. Hunts 1 to 4 cover 30 days and Hunt 5 starts on October 12, so set the CrowdStrike time picker to match.


Test query: what GitLab logs you actually have


If a source is missing, the hunt or branch that needs it has no coverage, and an empty result cannot clear activity that was never collected.


Splunk


index=gitlab earliest=-30d
| stats count as events by source host
| rename source as log_file
| sort 0 - events

Elastic


FROM logs-gitlab*
| WHERE @timestamp >= NOW() - 30 days
| EVAL log_file = TO_STRING(log.file.path)
| STATS events = COUNT(*) BY log_file, host.name
| SORT events DESC
| LIMIT 10000

CrowdStrike


#type=gitlab-json
| groupBy([@collect.file, @collect.host], limit=max, function=count(as=events))
| sort(events, order=desc, limit=max)

Microsoft KQL


GitLabLogs_CL
| where TimeGenerated >= ago(30d)
| summarize events = count() by LogFile, Computer
| order by events desc

All names in this test are assumed. Splunk expects a forwarder on the GitLab host into index=gitlab, with the file path in source. Elastic's GitLab integration runs on Elastic Agent with its own mapped fields and no Workhorse or Puma stderr datasets, so these searches assume custom ingest into logs-gitlab* with the field names shown. CrowdStrike expects LogScale Collector with a gitlab-json parser and the path in @collect.file. Microsoft expects a custom AMA table named GitLabLogs_CL. ES|QL stops with an unknown column error if a field such as uri or remote_ip is not mapped in your index, and this test lists only log files and hosts, so check sample events and rename fields before running the hunts. Check result and aggregation limits too: the Elastic examples stop at 10,000 rows, and if any search hits a limit, split it by host and rerun with the same lookback.


Hunt 1: Worker crashes and CI lint 502s by hour


A native Ruby [BUG] in puma_stderr.log, or a Workhorse 502 on a CI lint or GraphQL URI. As in GitLab's rules, one event is enough to investigate, though neither signal alone proves an attempt. GitLab's Workhorse rule also looks for POST and accepts a badgateway error line in place of the 502; ours does neither. The request that kills a worker never reaches api_json.log.


Splunk


index=gitlab earliest=-30d (source="*gitlab-workhorse*" OR source="*puma_stderr*")
| eval uri=lower(coalesce(uri, "-")), status=tostring(status)
| eval crash=if(match(lower(source), "puma_stderr") AND like(_raw, "%[BUG]%"), 1, 0)
| eval lint502=if(like(lower(source), "%gitlab-workhorse%") AND status="502" AND (like(uri, "%ci/lint%") OR like(uri, "%/api/graphql%")), 1, 0)
| where crash=1 OR lint502=1
| fillnull value="-" remote_ip
| bin _time span=1h
| stats sum(crash) as worker_crashes sum(lint502) as lint_502s values(remote_ip) as remote_ips by _time host

Elastic


FROM logs-gitlab*
| WHERE @timestamp >= NOW() - 30 days
| EVAL f = TO_LOWER(TO_STRING(log.file.path)), u = TO_LOWER(COALESCE(TO_STRING(uri), "-")), msg = COALESCE(TO_STRING(message), "-"), ip = COALESCE(TO_STRING(remote_ip), "-")
| EVAL crash = CASE(LOCATE(f, "puma_stderr") > 0 AND LOCATE(msg, "[BUG]") > 0, 1, 0)
| EVAL lint502 = CASE(LOCATE(f, "gitlab-workhorse") > 0 AND TO_STRING(status) == "502" AND (LOCATE(u, "ci/lint") > 0 OR LOCATE(u, "/api/graphql") > 0), 1, 0)
| WHERE crash == 1 OR lint502 == 1
| EVAL hour = DATE_TRUNC(1 hour, @timestamp)
| STATS worker_crashes = SUM(crash), lint_502s = SUM(lint502), remote_ips = VALUES(ip) BY hour, host.name
| LIMIT 10000

CrowdStrike


#type=gitlab-json
| case {
    @collect.file=/puma_stderr/i @rawstring=/\[BUG\]/ | crash := 1 | lint502 := 0 ;
    @collect.file=/gitlab-workhorse/i status=502 uri=/(ci\/lint|\/api\/graphql)/i | crash := 0 | lint502 := 1 ;
    * | crash := 0 | lint502 := 0 ;
  }
| test(crash + lint502 > 0)
| default(field=remote_ip, value="-")
| hour := @timestamp - (@timestamp % 3600000)
| groupBy([hour, @collect.host], limit=max, function=[sum(crash, as=worker_crashes), sum(lint502, as=lint_502s), collect(remote_ip)])

Microsoft KQL


GitLabLogs_CL
| where TimeGenerated >= ago(30d)
| where LogFile contains "puma_stderr" or LogFile contains "gitlab-workhorse"
| extend crash = iif(LogFile contains "puma_stderr" and RawData contains_cs "[BUG]", 1, 0),
         lint502 = iif(LogFile contains "gitlab-workhorse" and tostring(Status) == "502" and (tolower(Uri) contains "ci/lint" or tolower(Uri) contains "/api/graphql"), 1, 0)
| where crash == 1 or lint502 == 1
| extend RemoteIp = iif(isempty(RemoteIp), "-", RemoteIp)
| summarize worker_crashes = sum(crash), lint_502s = sum(lint502), remote_ips = make_set(RemoteIp) by bin(TimeGenerated, 1h), Computer

GitLab's logs page gives only the Workhorse file location, so the Workhorse fields uri, status and remote_ip come from GitLab security's Workhorse detection, which shows them in sample log lines. Your shipper may rename them. The crash match only needs the line containing [BUG]. To tie that marker to the backtrace frames GitLab's rule uses, keep the full report as one event or correlate its lines by host and time. GitLab's rule also requires a matching org-ruby or Onigmo frame, such as onig_compile, match_at or regexp_helper.rb; we match [BUG] alone, which is broader. Workhorse remote_ip can be spoofed.


Hunt 2: CI lint bursts with a later server error


Five or more CI lint requests per username in an hour, with at least one 5xx after the first request of the hour. Five is a starting threshold, so tune it to your environment. The query cannot see account age, and requests with no username all land under "-", so split those by source IP.


Splunk


index=gitlab earliest=-30d (source="*api_json.log*" OR source="*production_json.log*")
| eval p=lower(coalesce(path, "-")), status=tonumber(status)
| where like(p, "%ci/lint%")
| eval username=coalesce(nullif(username, ""), "-"), remote_ip=coalesce(nullif(remote_ip, ""), "-"), ua=coalesce(nullif(ua, ""), "-")
| eval is_err=if(status>=500 AND status<600, 1, 0), err_time=if(status>=500 AND status<600, _time, null())
| bin _time span=1h as hour
| stats count as lint_requests sum(is_err) as server_errors min(_time) as first_seen max(err_time) as last_5xx values(remote_ip) as remote_ips values(ua) as user_agents by hour host username
| where lint_requests>=5 AND server_errors>=1 AND last_5xx>first_seen

Elastic


FROM logs-gitlab*
| WHERE @timestamp >= NOW() - 30 days
| EVAL f = TO_LOWER(TO_STRING(log.file.path)), p = TO_LOWER(COALESCE(TO_STRING(path), "-"))
| WHERE (LOCATE(f, "api_json.log") > 0 OR LOCATE(f, "production_json.log") > 0) AND LOCATE(p, "ci/lint") > 0
| EVAL user = CASE(username IS NULL OR TO_STRING(username) == "", "-", TO_STRING(username)), ip = CASE(remote_ip IS NULL OR TO_STRING(remote_ip) == "", "-", TO_STRING(remote_ip)), agent = CASE(ua IS NULL OR TO_STRING(ua) == "", "-", TO_STRING(ua)), st = TO_INTEGER(status)
| EVAL is_err = CASE(st >= 500 AND st < 600, 1, 0), err_time = CASE(st >= 500 AND st < 600, @timestamp, NULL)
| EVAL hour = DATE_TRUNC(1 hour, @timestamp)
| STATS lint_requests = COUNT(*), server_errors = SUM(is_err), first_seen = MIN(@timestamp), last_5xx = MAX(err_time), remote_ips = VALUES(ip), user_agents = VALUES(agent) BY hour, host.name, user
| WHERE lint_requests >= 5 AND server_errors >= 1 AND last_5xx > first_seen
| LIMIT 10000

CrowdStrike


#type=gitlab-json @collect.file=/(api_json|production_json)\.log/i path=/ci\/lint/i
| default(field=[username, remote_ip, ua], value="-", replaceEmpty=true)
| case { test(status >= 500) | test(status < 600) | is_err := 1 | err_time := @timestamp ; * | is_err := 0 }
| hour := @timestamp - (@timestamp % 3600000)
| groupBy([hour, @collect.host, username], limit=max, function=[count(as=lint_requests), sum(is_err, as=server_errors), min(@timestamp, as=first_seen), max(err_time, as=last_5xx), collect([remote_ip, ua])])
| test(lint_requests >= 5)
| test(server_errors >= 1)
| test(last_5xx > first_seen)

Microsoft KQL


GitLabLogs_CL
| where TimeGenerated >= ago(30d)
| where LogFile contains "api_json.log" or LogFile contains "production_json.log"
| where tolower(Path) contains "ci/lint"
| extend Username = iif(isempty(Username), "-", Username), RemoteIp = iif(isempty(RemoteIp), "-", RemoteIp), Ua = iif(isempty(Ua), "-", Ua)
| extend st = toint(Status)
| extend is_err = iif(st >= 500 and st < 600, 1, 0), err_time = iif(st >= 500 and st < 600, TimeGenerated, datetime(null))
| summarize lint_requests = count(), server_errors = sum(is_err), first_seen = min(TimeGenerated), last_5xx = max(err_time), remote_ips = make_set(RemoteIp), user_agents = make_set(Ua) by bin(TimeGenerated, 1h), Computer, Username
| where lint_requests >= 5 and server_errors >= 1 and last_5xx > first_seen

GitLab documents path, status, username, remote_ip and ua for both Rails JSON logs, though not every production_json.log example includes ua, so the queries put "-" where it is missing. The KQL version assumes ingestion maps them to Path, Status, Username, RemoteIp and Ua, so match those to your table. CI content is filtered in api_json.log, and this path match does not cover the GraphQL ciLint route.


Hunt 3: Shells and downloaders under the GitLab service user


Shells, downloaders and interpreters running as git, with an immediate parent named ruby or bundle, on hosts named like "gitlab", from endpoint telemetry.


Splunk


index=linux sourcetype="sysmon:linux" EventCode=1 earliest=-30d host="*gitlab*"
| eval u=lower(User), pimg=lower(ParentImage), img=lower(Image)
| rex field=pimg "(?<parent>[^/]+)$"
| rex field=img "(?<proc>[^/]+)$"
| where u="git" AND in(parent, "ruby", "bundle") AND in(proc, "sh", "bash", "dash", "curl", "wget", "nc", "python3", "perl")
| fillnull value="-" CommandLine
| eval host=lower(host)
| stats count as events min(_time) as first_seen values(CommandLine) as cmdlines by host proc

Elastic


FROM logs-endpoint.events.process-*
| WHERE @timestamp >= NOW() - 30 days AND MV_CONTAINS(event.type, "start")
| EVAL h = TO_LOWER(TO_STRING(host.name)), u = TO_LOWER(TO_STRING(user.name)), parent = TO_LOWER(TO_STRING(process.parent.name)), proc = TO_LOWER(TO_STRING(process.name))
| WHERE LOCATE(h, "gitlab") > 0 AND u == "git" AND parent IN ("ruby", "bundle") AND proc IN ("sh", "bash", "dash", "curl", "wget", "nc", "python3", "perl")
| EVAL cmd = COALESCE(TO_STRING(process.command_line), "-")
| STATS events = COUNT(*), first_seen = MIN(@timestamp), cmdlines = VALUES(cmd) BY h, proc
| LIMIT 10000

CrowdStrike


#event_simpleName=ProcessRollup2 event_platform=Lin
| match(file="aid_master_main.csv", field=aid, include=ComputerName, strict=false)
| ComputerName=/gitlab/i
| UserName=/^git$/i
| ParentBaseFileName=/^(ruby|bundle)$/i
| FileName=/^(sh|bash|dash|curl|wget|nc|python3|perl)$/i
| ComputerName := lower(ComputerName)
| FileName := lower(FileName)
| groupBy([ComputerName, FileName], limit=max, function=[count(as=events), min(@timestamp, as=first_seen), collect(CommandLine)])

Microsoft KQL


DeviceProcessEvents
| where Timestamp >= ago(30d)
| where tolower(DeviceName) contains "gitlab"
| where tolower(AccountName) == "git"
| where tolower(InitiatingProcessFileName) in ("ruby", "bundle")
| where tolower(FileName) in ("sh", "bash", "dash", "curl", "wget", "nc", "python3", "perl")
| extend DeviceName = tolower(DeviceName), FileName = tolower(FileName)
| summarize events = count(), first_seen = min(Timestamp), cmdlines = make_set(ProcessCommandLine) by DeviceName, FileName

The hostname filter is a placeholder, so swap in your list of GitLab hosts. Splunk assumes Sysmon for Linux, which is thin if you only forward GitLab logs. Elastic assumes Elastic Defend. CrowdStrike uses Falcon ProcessRollup2, and its UserName field on Linux is assumed. Microsoft needs the host onboarded to Defender for Endpoint on Linux. The parent filter is the Ruby binary, so Puma and Sidekiq are not separated, and an exploit that stays inside the Ruby process will not show.


Hunt 4: First-seen outbound destinations from the GitLab host


Non-private IPv4 destinations first contacted in the last seven of 30 days. Alpine had no outbound connection logs.


Splunk


index=linux sourcetype="sysmon:linux" EventCode=3 Initiated="true" earliest=-30d host="*gitlab*"
| where NOT like(DestinationIp, "%:%") AND NOT (cidrmatch("10.0.0.0/8", DestinationIp) OR cidrmatch("172.16.0.0/12", DestinationIp) OR cidrmatch("192.168.0.0/16", DestinationIp) OR cidrmatch("127.0.0.0/8", DestinationIp))
| rex field=Image "(?<img>[^/]+)$"
| fillnull value="-" img
| eval host=lower(host)
| stats min(_time) as first_seen count as events values(img) as images by host DestinationIp DestinationPort
| where first_seen >= relative_time(now(), "-7d")

Elastic


FROM logs-endpoint.events.network-*
| WHERE @timestamp >= NOW() - 30 days AND network.direction == "egress"
| EVAL h = TO_LOWER(TO_STRING(host.name))
| WHERE LOCATE(h, "gitlab") > 0 AND LOCATE(TO_STRING(destination.ip), ":") == 0 AND NOT CIDR_MATCH(destination.ip, "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8")
| EVAL img = COALESCE(TO_STRING(process.name), "-")
| STATS first_seen = MIN(@timestamp), events = COUNT(*), images = VALUES(img) BY h, destination.ip, destination.port
| WHERE first_seen >= NOW() - 7 days
| LIMIT 10000

CrowdStrike


#event_simpleName=NetworkConnectIP4 event_platform=Lin
| match(file="aid_master_main.csv", field=aid, include=ComputerName, strict=false)
| ComputerName=/gitlab/i
| cidr(RemoteAddressIP4, subnet="0.0.0.0/0")
| !cidr(RemoteAddressIP4, subnet=["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"])
| default(field=ContextBaseFileName, value="-")
| ComputerName := lower(ComputerName)
| groupBy([ComputerName, RemoteAddressIP4, RemotePort], limit=max, function=[min(@timestamp, as=first_seen), count(as=events), collect(ContextBaseFileName)])
| test(first_seen >= now() - 604800000)

Microsoft KQL


DeviceNetworkEvents
| where Timestamp >= ago(30d)
| where tolower(DeviceName) contains "gitlab" and ActionType == "ConnectionSuccess"
| where not(ipv4_is_private(RemoteIP)) and not(ipv4_is_in_range(RemoteIP, "127.0.0.0/8"))
| extend InitiatingProcessFileName = iif(isempty(InitiatingProcessFileName), "-", InitiatingProcessFileName)
| extend DeviceName = tolower(DeviceName)
| summarize first_seen = min(Timestamp), events = count(), images = make_set(InitiatingProcessFileName) by DeviceName, RemoteIP, RemotePort
| where first_seen >= ago(7d)

Splunk and Elastic filter on direction (Initiated, network.direction). CrowdStrike NetworkConnectIP4 and Microsoft ConnectionSuccess are connection events we treat as outbound, so confirm that on a known connection. Process names are assumed and may be empty. IPv6 is not covered, other special-use IPv4 ranges are not excluded, and first seen means the first event for that host, IP and port within the 30 days.


Hunt 5: CI lint, token and runner activity since October 12


Per username since October 12: first matching request, CI lint calls, token endpoint requests and POSTs to runner paths. Counts are requests, not proof a token was created or a runner registered. Compare first_seen with creation dates. Registry pulls of your GitLab image are left out; check those by hand.


Splunk


index=gitlab earliest=1791763200 (source="*api_json.log*" OR source="*production_json.log*")
| eval p=lower(coalesce(path, "-")), m=upper(coalesce(method, "-"))
| eval lint=if(like(p, "%ci/lint%"), 1, 0), pat=if(match(p, "personal_access_tokens"), 1, 0), runner=if(m="POST" AND like(p, "%/runners%"), 1, 0)
| where lint=1 OR pat=1 OR runner=1
| eval username=coalesce(nullif(username, ""), "-"), remote_ip=coalesce(nullif(remote_ip, ""), "-")
| stats min(_time) as first_seen sum(lint) as ci_lint sum(pat) as pat_requests sum(runner) as runner_posts values(remote_ip) as remote_ips by host username
| sort 0 - first_seen

Elastic


FROM logs-gitlab*
| WHERE @timestamp >= TO_DATETIME("2026-10-12T00:00:00Z")
| EVAL f = TO_LOWER(TO_STRING(log.file.path)), p = TO_LOWER(COALESCE(TO_STRING(path), "-")), m = TO_UPPER(COALESCE(TO_STRING(method), "-"))
| WHERE LOCATE(f, "api_json.log") > 0 OR LOCATE(f, "production_json.log") > 0
| EVAL lint = CASE(LOCATE(p, "ci/lint") > 0, 1, 0), pat = CASE(LOCATE(p, "personal_access_tokens") > 0, 1, 0), runner = CASE(m == "POST" AND LOCATE(p, "/runners") > 0, 1, 0)
| WHERE lint == 1 OR pat == 1 OR runner == 1
| EVAL user = CASE(username IS NULL OR TO_STRING(username) == "", "-", TO_STRING(username)), ip = CASE(remote_ip IS NULL OR TO_STRING(remote_ip) == "", "-", TO_STRING(remote_ip))
| STATS first_seen = MIN(@timestamp), ci_lint = SUM(lint), pat_requests = SUM(pat), runner_posts = SUM(runner), remote_ips = VALUES(ip) BY host.name, user
| SORT first_seen DESC
| LIMIT 10000

CrowdStrike


#type=gitlab-json @collect.file=/(api_json|production_json)\.log/i
| test(@timestamp >= 1791763200000)
| case { path=/ci\/lint/i | lint := 1 ; * | lint := 0 }
| case { path=/personal_access_tokens/i | pat := 1 ; * | pat := 0 }
| case { method=/^post$/i path=/\/runners/i | runner := 1 ; * | runner := 0 }
| test(lint + pat + runner > 0)
| default(field=[username, remote_ip], value="-", replaceEmpty=true)
| groupBy([@collect.host, username], limit=max, function=[min(@timestamp, as=first_seen), sum(lint, as=ci_lint), sum(pat, as=pat_requests), sum(runner, as=runner_posts), collect(remote_ip)])
| sort(first_seen, order=desc, limit=max)

Microsoft KQL


GitLabLogs_CL
| where TimeGenerated >= datetime(2026-10-12)
| where LogFile contains "api_json.log" or LogFile contains "production_json.log"
| extend p = tolower(Path), m = toupper(Method)
| extend lint = iif(p contains "ci/lint", 1, 0), pat = iif(p contains "personal_access_tokens", 1, 0), runner = iif(m == "POST" and p contains "/runners", 1, 0)
| where lint == 1 or pat == 1 or runner == 1
| extend Username = iif(isempty(Username), "-", Username), RemoteIp = iif(isempty(RemoteIp), "-", RemoteIp)
| summarize first_seen = min(TimeGenerated), ci_lint = sum(lint), pat_requests = sum(pat), runner_posts = sum(runner), remote_ips = make_set(RemoteIp) by Computer, Username
| order by first_seen desc

GitLab documents method, path, username and remote_ip for both Rails JSON logs. The path fragments are ours and broad: the token count includes list requests, and the runner count includes any matching POST, not only registrations. The Splunk and CrowdStrike epochs are 2026-10-12 00:00 UTC, and 604800000 in Hunt 4 is seven days in milliseconds.


Interpret before you escalate


Signal

Usually benign

Escalate when

[BUG] in puma_stderr.log

A native crash from an unrelated Ruby defect, with no CI lint traffic near it

It shows Onigmo frames, or lines up with CI lint or GraphQL 502s from one source

CI lint 502 at Workhorse

An instance-wide restart where many routes 502 together

Only lint or GraphQL routes fail, or the correlation_id never appears in api_json.log or graphql_json.log

CI lint burst with 5xx

A bot account or glab user fixing a broken config

A raw scripted client, a new account, or a burst on an unpatched Ruby

git user spawning sh

GitLab shelling out for normal work, with a recognizable command line

curl, wget, nc or an interactive shell with network arguments

New outbound destination

A package mirror, SMTP relay or new integration someone set up

No owner, and it starts after a crash or a lint burst

New account with lint and token activity

A new hire setting up CI

Account created after October 12 with CI lint calls and any token or runner activity


What to alert on


Hunt 1 is the only one we would page on. A lint 502 on an otherwise healthy instance deserves a look, after you rule out a restart, deploy or resource problem. The queries do not join Hunt 1 to Hunt 2 by correlation_id; GitLab calls a Workhorse 502 whose correlation_id is missing from the Rails log its highest-confidence signal, but it still does not prove code ran.


A working exploit can leave no crash, so quiet crash logs do not rule out compromise. Hunts 3 and 4 can show follow-on activity, given endpoint telemetry, but on their own they do not tie it to this CVE. Without it, exposed and unpatched after October 12 means you cannot rule out compromise, so lean toward rotating.


What This Means for Your Team


Check Ruby, not just GitLab, on every self-managed instance, and treat custom images as unpatched until you confirm the Ruby GitLab runs on. Run the test query before trusting a quiet result, lock down self-registration, and rotate secrets where Ruby sat unpatched after October 12.


Sources



Inception Protection


Inception Protection is our managed detection and response service. Analysts watch endpoint, network, identity and cloud telemetry around the clock, and every alert gets investigated with context. If you want GitLab and host logs in that picture, talk to us about the telemetry it takes.


Inception Foresight


Our free Inception Foresight M365 Assessment is read-only and checks identity, email, data sharing, devices, and audit and governance settings, including whether logging and alerting are turned on. You get a prioritized remediation plan: https://www.inceptionsecurity.com/m365assessment.


bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page