GitLab AI Gateway CVE-2026-90970 Self-Hosted Command-Execution Hunt

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

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.