Threat Overview
GitLab disclosed CVE-2026-90970 on October 2, 2026, a critical template-engine neutralization flaw in the GitLab AI Gateway. Under certain conditions, an authenticated user with GitLab Duo Agent Platform access can submit a specially crafted custom-flow configuration, escape the prompt-template sandbox, and execute arbitrary commands on the AI Gateway. GitLab rates the issue CVSS 9.9 (network reachable, low complexity, low privileges, no user interaction, changed scope).
This is a vendor-confirmed vulnerability, not a confirmed intrusion campaign. GitLab did not publish exploit syntax, IOCs, or a compromise-validation procedure, and public reporting did not establish exploitation in the wild. The hunt therefore emphasizes affected-version discovery and behavioral evidence of application-runtime processes spawning shells, writing executable content, or making unusual outbound connections. Those behaviors are suspicious but not unique to this CVE.
References
- GitLab, “GitLab AI Gateway Critical Patch Release: 19.2.4, 19.3.2, and 19.4.1,” published October 2, 2026: https://docs.gitlab.com/releases/patches/other-patches/patch-release-gitlab-ai-gateway-19-4-1-released/
- CVE Program record for CVE-2026-90970, published October 2, 2026: https://www.cve.org/CVERecord?id=CVE-2026-90970
- The Hacker News, “GitLab Patches Critical 9.9 AI Gateway Flaw Allowing Command Execution on Self-Hosted Servers,” published October 2, 2026: https://thehackernews.com/2026/10/gitlab-patches-critical-self-hosted-ai.html
Impacted Systems
- Vendor/product/component: GitLab Self-Hosted AI Gateway used with the GitLab Duo Agent Platform.
- Affected versions: 18.1.6 through versions before 19.2.4; 19.3 before 19.3.2; and 19.4 before 19.4.1.
- Fixed versions: 19.2.4, 19.3.2, and 19.4.1 or later on the corresponding maintained line.
- Deployment: Customer-operated AI Gateway deployed as a Docker image or Helm chart, commonly on Linux/container infrastructure.
- Prerequisite: The attacker must be authenticated and have Duo Agent Platform access; additional conditions required for the crafted custom-flow configuration are not public.
- Exposure: Network reachability from an eligible GitLab user to the self-hosted gateway workflow. Direct Internet exposure is not required.
- Explicitly unaffected/protected: GitLab.com, GitLab Dedicated, and GitLab Self-Managed instances using a GitLab-hosted AI Gateway; GitLab states its hosted gateways are already fixed.
Why this matters
Successful exploitation crosses the prompt-template boundary into operating-system command execution on a service that connects GitLab, AI models, and sensitive signing material. Even though exploitation is not confirmed, the severity, low required privilege, and potential access to gateway credentials justify rapid inventory and focused behavioral review for self-hosted deployments.
Exploitation Status
No confirmed exploitation in public reporting as of October 4, 2026. GitLab confirmed the vulnerability and issued targeted customer outreach before public disclosure. Neither GitLab nor the cited reporting published exploit IOCs or evidence of in-the-wild attacks.
What this hunt looks for
Self-hosted AI Gateway image versions, interactive pod access, suspicious gateway log strings, application-runtime child processes, executable or script creation, rare outbound destinations, and Linux audit/Syslog fallback activity.
Required logs
KubePodInventory for authoritative image and node inventory; KubeAudit or KubeAuditAdmin; ContainerLogV2; Microsoft Defender for Endpoint DeviceProcessEvents, DeviceFileEvents, and DeviceNetworkEvents on gateway nodes; and Linux Syslog or audit telemetry as a fallback.
Hunt 1 — First-pass inventory of self-hosted GitLab AI Gateway images
let lookback = 14d;
KubePodInventory
| where TimeGenerated >= ago(lookback)
| where ContainerImage has_any ("ai-gateway", "ai_gateway", "gitlab-org/modelops")
| summarize LastSeen=max(TimeGenerated), Clusters=make_set(ClusterName, 10), Namespaces=make_set(Namespace, 10), Pods=make_set(PodName, 20), Nodes=make_set(Computer, 20) by ContainerImage, ContainerName
| order by LastSeen desc
Hunt 2 — Gateway images whose tag does not match a published fixed release
let lookback = 14d;
KubePodInventory
| where TimeGenerated >= ago(lookback)
| where ContainerImage has_any ("ai-gateway", "ai_gateway", "gitlab-org/modelops")
| extend ImageTag=extract(@":([^/@]+)$", 1, ContainerImage)
| where isempty(ImageTag) or ImageTag !matches regex @"^(self-hosted-)?v?(19\.2\.(?:[4-9]|[1-9][0-9]+)|19\.3\.(?:[2-9]|[1-9][0-9]+)|19\.4\.(?:[1-9]|[1-9][0-9]+)|(?:19\.[5-9]|[2-9][0-9]+)\.)"
| summarize FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated), Pods=make_set(PodName, 20), Nodes=make_set(Computer, 20) by ClusterName, Namespace, ContainerImage, ImageTag
| order by LastSeen desc
Hunt 3 — Exec, attach, or port-forward activity against AI Gateway pods
let lookback = 30d;
union isfuzzy=true KubeAudit, KubeAuditAdmin
| where TimeGenerated >= ago(lookback)
| extend Uri=tostring(RequestUri), Actor=tostring(User.username), Sources=tostring(SourceIps), StatusCode=toint(ResponseStatus.code)
| where Uri has_any ("ai-gateway", "ai_gateway")
| where Uri has_any ("/exec", "/attach", "/portforward") or Verb in~ ("create", "connect")
| project TimeGenerated, ClusterName, Actor, Sources, Verb, Uri, StatusCode, UserAgent
| order by TimeGenerated desc
Hunt 4 — Suspicious command-execution strings in AI Gateway container logs
let lookback = 30d;
ContainerLogV2
| where TimeGenerated >= ago(lookback)
| where PodName has_any ("ai-gateway", "ai_gateway") or ContainerName has_any ("ai-gateway", "ai_gateway")
| extend Message=tostring(LogMessage)
| where Message has_any ("/bin/sh", "/bin/bash", "subprocess", "os.system", "Popen(", "curl ", "wget ", "base64 -d", "chmod +x", "template sandbox", "TemplateSyntaxError", "SecurityError")
| project TimeGenerated, Computer, PodName, ContainerName, LogSource, Message
| order by TimeGenerated desc
Hunt 5 — Shell or utility child processes on nodes hosting AI Gateway
let lookback = 30d;
let GatewayNodes = toscalar(KubePodInventory
| where TimeGenerated >= ago(7d)
| where ContainerImage has_any ("ai-gateway", "ai_gateway", "gitlab-org/modelops")
| summarize make_set(tolower(Computer)));
DeviceProcessEvents
| where Timestamp >= ago(lookback)
| where tolower(DeviceName) in~ (GatewayNodes)
| where FileName in~ ("sh", "bash", "dash", "zsh", "curl", "wget", "nc", "ncat", "socat", "python", "python3")
| where InitiatingProcessFileName has_any ("python", "uvicorn", "gunicorn") or InitiatingProcessCommandLine has_any ("ai-gateway", "ai_gateway")
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName, InitiatingProcessCommandLine, SHA256
| order by Timestamp desc
Hunt 6 — Executable or script creation by the gateway runtime
let lookback = 30d;
let GatewayNodes = toscalar(KubePodInventory
| where TimeGenerated >= ago(7d)
| where ContainerImage has_any ("ai-gateway", "ai_gateway", "gitlab-org/modelops")
| summarize make_set(tolower(Computer)));
DeviceFileEvents
| where Timestamp >= ago(lookback)
| where tolower(DeviceName) in~ (GatewayNodes)
| where InitiatingProcessFileName has_any ("python", "uvicorn", "gunicorn") or InitiatingProcessCommandLine has_any ("ai-gateway", "ai_gateway")
| where FileName matches regex @"(?i)\.(sh|py|pl|so|elf)$" or FolderPath has_any ("/tmp/", "/var/tmp/", "/dev/shm/")
| project Timestamp, DeviceName, ActionType, FolderPath, FileName, SHA256, InitiatingProcessFileName, InitiatingProcessCommandLine
| order by Timestamp desc
Hunt 7 — Rare outbound destinations from AI Gateway nodes
let baseline = 30d;
let recent = 2d;
let GatewayNodes = toscalar(KubePodInventory
| where TimeGenerated >= ago(7d)
| where ContainerImage has_any ("ai-gateway", "ai_gateway", "gitlab-org/modelops")
| summarize make_set(tolower(Computer)));
let Known = DeviceNetworkEvents
| where Timestamp between (ago(baseline) .. ago(recent))
| where tolower(DeviceName) in~ (GatewayNodes)
| summarize by RemoteUrl, RemoteIP;
DeviceNetworkEvents
| where Timestamp >= ago(recent)
| where tolower(DeviceName) in~ (GatewayNodes)
| where ActionType == "ConnectionSuccess"
| join kind=leftanti Known on RemoteUrl, RemoteIP
| project Timestamp, DeviceName, InitiatingProcessFileName, InitiatingProcessCommandLine, RemoteUrl, RemoteIP, RemotePort
| order by Timestamp desc
Hunt 8 — Linux Syslog or audit fallback for gateway command execution
let lookback = 30d;
Syslog
| where TimeGenerated >= ago(lookback)
| where ProcessName has_any ("audit", "auditd", "dockerd", "containerd", "kubelet") or SyslogMessage has_any ("ai-gateway", "ai_gateway")
| where SyslogMessage has_any ("/bin/sh", "/bin/bash", "curl ", "wget ", "chmod", "/tmp/", "/dev/shm/", "execve")
| project TimeGenerated, Computer, Facility, SeverityLevel, ProcessName, SyslogMessage
| order by TimeGenerated desc
Detection Notes
- Hunt 1 is the highest-value first pass because exposure is limited to self-hosted AI Gateway deployments. Validate tags against the actual image digest and GitLab release line; custom registries may obscure the upstream image name.
- Hunt 2 is intentionally conservative and may flag
latest, digest-only, private-registry, or nonstandard tags. Treat it as inventory triage, not proof of vulnerability. - Hunts 4–7 are post-exploitation behaviors, not CVE-specific signatures. AI and developer workloads can legitimately invoke Python, shells, package tools, and model endpoints; prioritize unexpected child processes, writable temporary paths, new destinations, and timing near custom-flow changes.
- Kubernetes audit logs show control-plane operations, not commands executed entirely inside the application. Container logs may omit malicious commands or be lost when a pod is replaced.
- Node-level Defender telemetry may identify the host but not preserve container or pod attribution. Confirm the node/pod mapping at event time.
- Missing
KubePodInventory,KubeAudit/KubeAuditAdmin,ContainerLogV2, or endpoint telemetry prevents the corresponding layers from being visible in Sentinel.