Open
iSulad-v2.1.6 代码安全审计问题 #133
fh966666Add on Sep 14
Sep 14 Assign jingxiaolu、Yekelu、haomintsai、chantsztung、fh966666 as responsible person
Sep 14 Assign jingxiaolu、Yekelu、haomintsai、chantsztung、fh966666 as responsible person
Sep 14 Added label: sig/iSulad
openeuler-ci-bot
Sep 14 Comments:
Sep 14 Comments:
Welcome To openEuler Community
Hey @fh966666 , thanks for your contribution to the community.
Bot Usage Manual
I'm the Bot here serving you. You can find the instructions on how to interact with me at Here . That means you can comment below every pull request or issue to trigger Bot Commands. You can self-configure the PR merge rules for this repository. For more details, please refer to Here.
Contact Guide
If you have any questions, please contact the SIG: iSulad ,
and any of the maintainers: @Yekelu, @chantsztung, @haomintsai, @jingxiaolu ,


发现 13 个问题(🔴 致命 2 · 🟡 重要 4 · 🟢 建议 5 · ⚠️ 疑似 2)
其中:
tls_mode头部决定是否执行授权检查,可被未授权远程攻击者(tls_verify=false配置下)或持有合法客户端证书的低权限用户绕过,控制容器守护进程的全部 API;其二是 cgroup 路径转换函数中的指针算术错误,括号错位使strlen读取堆分配之前的内存(堆越界读),返回值不可预测,进而使后续strcat边界检查被size_t下溢绕过,向result[PATH_MAX+1]栈缓冲区越界写入,在 root 守护进程中可实现栈破坏 → RCE 或 DoS。memset可被编译器死存储优化移除;AES 密钥文件创建/读取缺少O_NOFOLLOW存在符号链接跟随风险。优先修复顺序:致命 1(鉴权绕过)→ 致命 2(cgroup 栈溢出)→ 重要 1(堆溢出)→ 重要 2/3/4(加密与凭据)→ 建议 1-5 → 疑似 1/2。
🔴 [致命] src/daemon/entry/connect/grpc/grpc_server_tls_auth.cc:35-46 — gRPC 应用层鉴权可被客户端可控的
tls_mode头部绕过CWE: CWE-306(Missing Authentication for Critical Function)/ CWE-287(Improper Authentication)/ CWE-639(Authorization Bypass Through User-Controlled Key)
规则: R03-认证授权-01 / R03-认证授权-03
CVSS: 9.1(AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)— 在
ENABLE_GRPC_REMOTE_CONNECT=ON+tls=true+tls_verify=false+authorization_plugin=authz-broker的部署配置下;若tls_verify=true则降至约 7.7(需合法客户端证书,但仍可绕过细粒度动作授权)数据来源: 客户端 gRPC metadata(HTTP/2 头部),完全由连接对端控制
风险传播路径: 远程 gRPC 客户端 → 任意 gRPC 服务方法(container/image/volume/network 创建/删除/执行/inspect 等)→
ContainerServiceBase::Authenticate()/GrpcServerTlsAuth::auth()→ 读取客户端tls_mode头部 → 若缺失或为"0"直接return Status::OK→ 授权插件检查被跳过 → 攻击者以 root 权限调用任意容器 API → 容器逃逸/特权提升/宿主文件读写调用栈
问题代码
// 文件: src/daemon/entry/connect/grpc/grpc_server_tls_auth.cc:30-62 Status auth(ServerContext *context, const std::string &action) { #ifdef ENABLE_GRPC_REMOTE_CONNECT if (context == nullptr) { return Status(StatusCode::INVALID_ARGUMENT, "Invalid arguments"); } const std::multimap<grpc::string_ref, grpc::string_ref> &init_metadata = context->client_metadata(); auto tls_mode_kv = init_metadata.find("tls_mode"); if (tls_mode_kv == init_metadata.end()) { // Allow tls_mode not set in context, which means tls_mode is default to 0 return Status::OK; // <-- (1) 缺失头部即放行 } std::string tls_mode = std::string(tls_mode_kv->second.data(), tls_mode_kv->second.length()); if (tls_mode == "0") { return Status::OK; // <-- (2) 客户端自报 "0" 即放行 } if (AuthorizationPluginConfig::auth_plugin.empty()) { return Status::OK; // <-- (3) 未配置授权插件即放行 } else if (AuthorizationPluginConfig::auth_plugin == AUTH_PLUGIN) { auto username_kv = init_metadata.find("username"); if (username_kv == init_metadata.end()) { return Status(StatusCode::UNKNOWN, "unknown error"); } std::string username = std::string(username_kv->second.data(), username_kv->second.length()); char *errmsg = nullptr; if (authz_http_request(username.c_str(), action.c_str(), &errmsg)) { /* ... */ } } #endif return Status::OK; }调用链分析
args->json_confs->tls选择 TLS/Insecure 凭据tls_verify=false时使用GRPC_SSL_DONT_REQUEST_CLIENT_CERTIFICATE,传输层不做客户端认证Authenticate()GrpcServerTlsAuth::authtls_mode头部决定是否执行授权检查客户端侧(
src/client/connect/grpc/client_base.h:197-213)证实tls_mode由客户端写入 metadata:context.AddMetadata("tls_mode", m_tlsMode);,m_tlsMode在TLS_OFF="0"与TLS_ON="1"间取值(见client_base.h:35-36)。这意味着:合法 isula 客户端在未启用 TLS 时发送tls_mode: 0;而任意攻击者只需在 gRPC 请求里携带tls_mode: 0(或不携带该头部)即可令服务端跳过授权插件检查。修复建议
服务端绝不应信任客户端自报的
tls_mode头部来决定是否执行授权。应改为以服务端自身的 TLS 配置与对端证书状态作为唯一判据:Status auth(ServerContext *context, const std::string &action) { #ifdef ENABLE_GRPC_REMOTE_CONNECT if (context == nullptr) { return Status(StatusCode::INVALID_ARGUMENT, "Invalid arguments"); } // 服务端依据自身配置判定是否需要应用层授权,不读取客户端 metadata if (AuthorizationPluginConfig::auth_plugin.empty()) { // 未配置授权插件:仅依赖传输层(mTLS)认证;若 tls_verify=false 应在启动期拒绝 return Status::OK; } if (AuthorizationPluginConfig::auth_plugin != AUTH_PLUGIN) { return Status(StatusCode::UNIMPLEMENTED, "authorization plugin invalid"); } // 从对端证书提取用户名(由 mTLS 保证可信),而非信任客户端 "username" 头部 std::string username; if (!ExtractPeerCommonName(context, &username)) { return Status(StatusCode::UNAUTHENTICATED, "missing client identity"); } char *errmsg = nullptr; if (authz_http_request(username.c_str(), action.c_str(), &errmsg)) { std::string err = errmsg ? errmsg : "permission denied"; free(errmsg); return Status(StatusCode::PERMISSION_DENIED, err); } if (errmsg != nullptr) { free(errmsg); } #endif return Status::OK; }同时在
grpc_service.cc::ListeningPort中:若tls=true但tls_verify=false且配置了authorization_plugin,应在启动期报错退出(无客户端认证 + 应用层授权可被绕过 = 不安全组合)。🔴 [致命] src/daemon/common/cgroup/cgroup.c:187-194 —
common_convert_cgroup_path指针算术错误致堆越界读 + 边界检查绕过 + 栈缓冲区溢出CWE: CWE-125(Out-of-bounds Read)/ CWE-787(Out-of-bounds Write)/ CWE-468(Incorrect Pointer Scaling)
规则: R01-内存安全-01 / R01-内存安全-03 / R07-代码质量-04
CVSS: 7.8(AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)— 经容器创建/更新 API 设置特定格式的 systemd cgroup_path 即可触发
数据来源: 容器
host_config->cgroup_path(即 OCI speclinux.cgroupsPath),由用户经--cgroup-parent/--cgroup-path在 gRPC container create/update 或 CRI PodSandboxConfig 设置风险传播路径: API 客户端设置
cgroupsPath形如test-a.slice:isulad:id→common_convert_cgroup_path(cgroup_path)→util_string_split_n分割得到arr[0..2](每个为独立堆分配) → 边界校验表达式strlen(arr[0] - strlen(arr[1]) - strlen(arr[2]))中arr[0] - N为指针回退(指向 arr[0] 堆块之前)→strlen从堆分配之前的内存读取直至遇 null → 返回不可预测的大值 →PATH_MAX - <大数>在size_t域下溢为巨大值 → 边界检查>比较为假 → 检查被绕过 → 紧随其后的strcat序列向栈缓冲区char result[PATH_MAX + 1]越界写入/arr[0]/arr[1]-arr[2].scope→ 栈缓冲区溢出 → root 守护进程栈破坏 → 返回地址覆盖 → RCE 或崩溃调用栈
问题代码
// 文件: src/daemon/common/cgroup/cgroup.c:141-208 char *common_convert_cgroup_path(const char *cgroup_path) { char *token = NULL; char result[PATH_MAX + 1] = {0}; // 行 145: 栈缓冲区 __isula_auto_array_t char **arr = NULL; /* ... */ arr = util_string_split_n(cgroup_path, ':', 3); if (arr == NULL || util_array_len((const char **)arr) != 3) { return util_strdup_s(cgroup_path); } if (!util_has_suffix(arr[0], ".slice")) { /* ... */ } token = strchr(arr[0], '-'); while (token != NULL) { *token = '\0'; if (strlen(arr[0]) > PATH_MAX || strlen(result) + 1 + strlen(".slice") > PATH_MAX - strlen(arr[0])) { /* OK, 这里是 strlen(arr[0]) 正确 */ } /* strcat result ... */ *token = '-'; token = strchr(token + 1, '-'); } // Add /arr[0]/arr[1]-arr[2].scope, 3 include two slashes and one dash if (strlen(cgroup_path) > PATH_MAX || strlen(result) + 3 + strlen(".scope") > PATH_MAX - strlen(arr[0] - strlen(arr[1]) - strlen(arr[2]))) { // <-- BUG: 括号错位 ERROR("Invalid systemd cgroup parent: exceeds max length of path"); return NULL; } (void)strcat(result, "/"); (void)strcat(result, arr[0]); (void)strcat(result, "/"); (void)strcat(result, arr[1]); (void)strcat(result, "-"); (void)strcat(result, arr[2]); (void)strcat(result, ".scope"); return util_strdup_s(result); }调用链分析
cgroupsPathutil_array_append中每个元素经util_strdup_s独立堆分配(见utils_array.c:110-133)arr[0] - strlen(arr[1]) - strlen(arr[2])为指向 arr[0] 之前的指针size_t下溢绕过后,向result[PATH_MAX+1]越界写入 → 栈溢出根因:括号错位。本意应为
strlen(arr[0]) - strlen(arr[1]) - strlen(arr[2])(计算arr[0]中除去 arr[1]/arr[2] 长度后的剩余),但实际写成了strlen(arr[0] - strlen(arr[1]) - strlen(arr[2]))(对指针做减法后再strlen)。由于arr[0]指向独立堆块起始,回退后的指针位于该堆块之前,strlen越界读取。若越界strlen返回值V使PATH_MAX - V在size_t域下溢(V > PATH_MAX),则strlen(result) + 3 + strlen(".scope") > <巨大值>为假 → 边界检查被绕过 → 后续 strcat 越界写栈。修复建议
// 修复括号错位,并补充下溢防护 size_t arr_len = strlen(arr[0]) + strlen(arr[1]) + strlen(arr[2]); if (strlen(cgroup_path) > PATH_MAX || arr_len > PATH_MAX || strlen(".scope") > PATH_MAX || strlen(result) > PATH_MAX || arr_len + strlen(".scope") + 3 > PATH_MAX - strlen(result)) { ERROR("Invalid systemd cgroup parent: exceeds max length of path"); return NULL; }或更稳妥地改用
snprintf一次性构造,避免strcat链与无符号下溢陷阱:size_t need = strlen(result) + 1 + strlen(arr[0]) + 1 + strlen(arr[1]) + 1 + strlen(arr[2]) + strlen(".scope") + 1; if (need > PATH_MAX + 1) { ERROR("..."); return NULL; } char tmp[PATH_MAX + 1]; int n = snprintf(tmp, sizeof(tmp), "%s/%s/%s-%s.scope", result, arr[0], arr[1], arr[2]); if (n < 0 || (size_t)n >= sizeof(tmp)) { ERROR("..."); return NULL; } strcpy(result, tmp);🟡 [重要] src/daemon/modules/image/oci/registry/http_request.c:583-624 —
build_get_token_url1 字节 null 堆溢出(off-by-one)+ 缺少 URL 编码(查询注入)CWE: CWE-787(Out-of-bounds Write)/ CWE-193(Off-by-one Error)/ CWE-116(Improper Encoding or Escaping of Output)
规则: R01-内存安全-01 / R01-内存安全-05 / R02-注入-04
CVSS: 7.5(AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H)— 用户执行
isula pull拉取恶意镜像仓库的镜像即可触发;若--insecure-registry或 MITM 则无需用户交互数据来源: 镜像仓库 WWW-Authenticate 响应头中的
realm、service字段(parse_challenges解析自 HTTP 头),用户提供的username,daemon 计算的scope风险传播路径: 镜像仓库返回
WWW-Authenticate: Bearer realm="...",service="..."→parse_auth→parse_challenges填充challenge.realm/service→http_request_get_token→build_get_token_url→ 计算url_len时漏算 null 终止符 →util_common_calloc_s(url_len)分配url_len字节 →strcat链写入url_len字符 + null → null 字节写入越界 1 字节 → 堆元数据/相邻堆块污染 → 潜在 RCE 或崩溃;同时realm/username/scope未经 URL 编码直接拼入查询串,恶意 realm 含&/=/#可注入额外查询参数或路径,欺骗认证服务器调用栈
问题代码
// 文件: src/daemon/modules/image/oci/registry/http_request.c:570-627 static char *build_get_token_url(challenge *c, char *username, char *scope) { char *url = NULL; size_t url_len = 0; if (c == NULL || c->realm == NULL) { ERROR("Invalid NULL pointer"); return NULL; } url_len += strlen(c->realm) + strlen("?"); if (username != NULL) { url_len += strlen("account=") + strlen(username) + strlen("&"); } if (c->service != NULL) { url_len += strlen("service=") + strlen(c->service) + strlen("&"); } if (scope != NULL) { url_len += strlen("scope=") + strlen(scope); } // url_len 为各分量字符数之和,未包含 null 终止符;也未为 URL 编码预留空间 url = util_common_calloc_s(url_len); // <-- 分配 url_len 字节(calloc(1, url_len)) if (url == NULL) { ERROR("out of memory"); return NULL; } (void)strcat(url, c->realm); if (username != NULL || c->service != NULL || scope != NULL) { (void)strcat(url, "?"); } if (username != NULL) { (void)strcat(url, "account="); (void)strcat(url, username); // <-- 未 URL 编码 if (c->service != NULL || scope != NULL) { (void)strcat(url, "&"); } } if (c->service != NULL) { (void)strcat(url, "service="); (void)strcat(url, c->service); // <-- 未 URL 编码 if (scope != NULL) { (void)strcat(url, "&"); } } if (scope != NULL) { (void)strcat(url, "scope="); (void)strcat(url, scope); // <-- 当 scope != NULL 时,最终字符串长度 == url_len } // strcat 写入 null 至 url[url_len],越界 1 字节 return url; }调用链分析
url_len漏算 1 字节 null;各分量未 URL 编码calloc(1, url_len),分配 url_len 字节触发条件:当
scope != NULL时(Bearer 鉴权的典型场景,scope 形如repository:foo:pull),最终字符串长度恰为url_len,strcat写入的 null 终止符落在url[url_len],而缓冲区仅0..url_len-1合法。username + scope、service + scope、scope only、username + service + scope四种组合均触发。util_common_calloc_s实现见src/utils/cutils/utils.c:286-293:calloc((size_t)1, size),分配字节数严格等于入参,不会多给 1 字节。URL 编码缺失:
realm直接来自镜像仓库响应头,若含?account=admin&service=evil可注入额外参数;username由用户提供,若含&可注入参数。认证服务器可能据此授予非预期 scope。修复建议
// 修复 1:补 null 终止符 url_len += 1; // for '\0' url = util_common_calloc_s(url_len); // 修复 2:对 realm/username/service/scope 做 percent-encoding 后再拼接 // 使用 curl_easy_escape 或自实现 RFC 3986 percent-encoding, // 并相应放大 url_len 上界(最坏每个字符 3 字节)。 char *enc_user = username ? curl_easy_escape(curl, username, 0) : NULL; char *enc_realm = curl_easy_escape(curl, c->realm, 0); /* ... 用 enc_* 替代原字符串拼入,结束后 curl_free ... */或更稳妥地改用
snprintf一次性构造(配合编码后的分量):size_t need = strlen(c->realm) + 1 /*?*/ + (username ? strlen("account=") + 3 * strlen(username) + 1 /*&*/ : 0) + (c->service ? strlen("service=") + 3 * strlen(c->service) + 1 /*&*/ : 0) + (scope ? strlen("scope=") + 3 * strlen(scope) : 0) + 1 /*'\0'*/; url = util_common_calloc_s(need); /* ... percent-encode 各分量后 stpcpy 拼接 ... */同时复核
src/daemon/modules/image/oci/registry/下所有strcat拼接 URL/头部的位置,统一改为带+1的calloc+ URL 编码。🟡 [重要] src/utils/cutils/utils_aes.c:46-50 + src/utils/cutils/utils.c:1308-1335 — AES-256-CFB 密钥经
util_generate_random_str生成,实际熵仅 128 比特CWE: CWE-326(Inadequate Encryption Strength)/ CWE-310(Cryptographic Issues)
规则: R03-加密认证-02 / R03-加密认证-05
CVSS: 5.9(AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N)— 需获取密文并暴力破解 2^128 空间,理论弱化而非立即可利用
数据来源:
/dev/urandom(熵源正确),但输出经十六进制编码后填入 32 字节密钥槽风险传播路径: 首次启用镜像仓库认证 →
util_aes_key(create=true)→util_generate_random_str(aeskey, AES_256_CFB_KEY_LEN=32)→ 内部len = 32/2 = 16,循环 16 次每次从 urandom 读 4 字节取模 256 得 1 字节熵,格式化为%02x写入密钥 → 最终 32 字节密钥仅含[0-9a-f]字符,密钥空间从 2^256 缩减至 16^32 = 2^128 → 该密钥用于 AES-256-CFB 加解密镜像仓库凭据(auths.json中的 username/password)→ 弱化凭据机密性调用栈
问题代码
// 文件: src/utils/cutils/utils_aes.c:45-54 int util_aes_key(const char *key_file, bool create, unsigned char *aeskey) { /* ... */ if (!util_file_exists(key_file)) { if (!create) { /* ... */ } ret = util_generate_random_str((char *)aeskey, AES_256_CFB_KEY_LEN); // <-- 传入 32(字节) /* ... */ // 但函数把它当字符串长度处理 } /* ... */ } // 文件: src/utils/cutils/utils.c:1308-1335 int util_generate_random_str(char *id, size_t len) { int fd = -1; int num = 0; size_t i; const int m = 256; if (id == NULL) { return -1; } len = len / 2; // <-- 32 -> 16 fd = open("/dev/urandom", O_RDONLY); /* ... */ for (i = 0; i < len; i++) { if (util_read_nointr(fd, &num, sizeof(int)) < 0) { /* ... */ } unsigned char rs = (unsigned char)(num % m); nret = snprintf((id + i * 2), ((len - i) * 2 + 1), "%02x", (unsigned int)rs); // <-- 1 字节熵 -> 2 字符 /* ... */ } id[i * 2] = '\0'; return 0; }调用链分析
len /= 2后只产出len/2字节熵,以十六进制写入len字节缓冲[0-9a-f]后果:AES-256-CFB 密钥名义 256 比特,实际熵 128 比特,且字符集仅 16 种。用于保护
auths.json中镜像仓库登录凭据。2^128 仍不可暴力,但相对设计目标显著弱化,违反 AES-256 的安全承诺。修复建议
为密钥生成专用二进制随机填充函数,不走十六进制字符串路径:
// 新增:直接填充二进制随机字节 int util_random_bytes(unsigned char *buf, size_t len) { int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); if (fd < 0) { return -1; } ssize_t n = util_read_nointr(fd, buf, len); close(fd); return (n == (ssize_t)len) ? 0 : -1; } // 在 util_aes_key 中: ret = util_random_bytes(aeskey, AES_256_CFB_KEY_LEN); // 直接 32 字节二进制熵util_generate_random_str可保留用于生成可见 ID(layer id、容器 id 等),但不应被复用于填充二进制密钥。🟡 [重要] src/utils/cutils/utils.c:1147-1166 —
util_free_sensitive_string使用memset清零敏感串,可能被编译器死存储优化移除CWE: CWE-14(Compiler Removal of Code to Clear Buffers)/ CWE-226(Sensitive Information in Resource Not Reuse Before Reuse)
规则: R06-隐私数据-02 / R03-加密认证-07
CVSS: 5.3(AV:L/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N)— 需配合堆越界读才能利用,但凭据可能驻留在已释放堆块中被后续漏洞读取
数据来源: 镜像仓库登录密码、base64 解码后的认证 token(
decode_auth_aes/decode_auth等调用util_free_sensitive_string释放)风险传播路径:
decode_auth_aes释放decoded(含明文username:password)→util_free_sensitive_string调memset(str, 0, strlen(str))后free(str)→ 编译器观察到free后内存不再被读,可消除memset→ 明文凭据残留于堆块 → 后续若发生堆越界读(如本报告致命 2 的 cgroup 越界读)可读到残留凭据调用栈
问题代码
// 文件: src/utils/cutils/utils.c:1147-1166 void util_free_sensitive_string(char *str) { if (!util_valid_str(str)) { goto out; } (void)memset(str, 0, strlen(str)); // <-- 编译器可消除:free 紧随其后,无可见读者 out: free(str); } void util_memset_sensitive_string(char *str) { if (!util_valid_str(str)) { return; } (void)memset(str, 0, strlen(str)); // <-- 同样问题(不 free,但若后续不再读 str 也可被消除) }调用链分析
memset可能被优化移除项目 AGENTS.md 明确要求使用 secureC 的
memset_s(不可被优化移除),此处未遵循。修复建议
#include <securec.h> // 已是项目约定 void util_free_sensitive_string(char *str) { if (str != NULL) { (void)memset_s(str, strlen(str) + 1, 0, strlen(str) + 1); // memset_s 不可被 DCE } free(str); }或使用
explicit_bzero(glibc 提供,不可被优化):void util_free_sensitive_string(char *str) { if (str != NULL && str[0] != '\0') { explicit_bzero(str, strlen(str)); } free(str); }并全仓检索
util_free_sensitive_string/util_memset_sensitive_string调用点(oci_pull.c、auths.c、registry_apiv2.c、utils_array.c:49/83等),确认全部走安全清零路径。🟡 [重要] src/utils/cutils/utils_aes.c:60-66 + src/utils/cutils/utils_file.c:638-647 — AES 密钥文件创建/读取缺少
O_NOFOLLOW,存在符号链接跟随风险CWE: CWE-59(Improper Link Resolution Before File Access)/ CWE-732(Incorrect Permission Assignment for Critical Resource)
规则: R01-内存安全-08 / R06-隐私数据-03
CVSS: 5.6(AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:N)— 需在密钥目录(默认
/root/.isulad/,仅 root 可写)预先放置符号链接,或管理员配置了非 root 独占的密钥目录数据来源:
g_aeskey路径(默认/root/.isulad/aeskey,可由aes_set_key_dir配置)风险传播路径: 攻击者在密钥目录放置符号链接
aeskey -> /etc/passwd→ daemon 首次启用镜像仓库认证 →util_aes_key(create=true)→util_write_file→util_open(util_clean_path仅做词法规范化,不解析符号链接)→open(rpath, O_WRONLY|O_CREAT|O_TRUNC, 0600)跟随符号链接 → 覆盖/etc/passwd为 32 字节 AES 密钥 → 宿主系统破坏;或读取场景跟随链接读取任意文件内容作为密钥调用栈
问题代码
// 文件: src/utils/cutils/utils_aes.c:45-66 int util_aes_key(const char *key_file, bool create, unsigned char *aeskey) { /* ... */ if (!util_file_exists(key_file)) { if (!create) { /* ... */ } ret = util_generate_random_str((char *)aeskey, AES_256_CFB_KEY_LEN); /* ... */ ret = util_mkdir_p(key_dir, 0700); /* ... */ ret = util_write_file(key_file, (char *)aeskey, AES_256_CFB_KEY_LEN, 0600); // <-- O_CREAT|O_TRUNC,无 O_NOFOLLOW /* ... */ } else { fd = open(key_file, O_RDONLY); // <-- 直接 open,无 O_NOFOLLOW,且未走 util_clean_path /* ... */ } } // 文件: src/utils/cutils/utils_file.c:638-647 int util_open(const char *filename, int flags, mode_t mode) { char rpath[PATH_MAX] = { 0x00 }; if (util_clean_path(filename, rpath, sizeof(rpath)) == NULL) { return -1; } if (mode) { return open(rpath, flags | O_CLOEXEC, mode); // <-- 缺少 O_NOFOLLOW } else { return open(rpath, flags | O_CLOEXEC); } }调用链分析
open不带O_NOFOLLOW,跟随符号链接util_clean_path(src/utils/cutils/path.c:93-145)实现类似 dockercleanpath,逐段处理./..,但不调用realpath,因此对中间目录或目标本身的符号链接无防护。修复建议
// utils_aes.c:读取路径加 O_NOFOLLOW;写入路径用 O_NOFOLLOW|O_CREAT|O_EXCL(密钥文件应只创建一次) fd = open(key_file, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); // 写入时: int fd = open(key_file, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); if (fd < 0 && errno == EEXIST) { // 已存在:拒绝覆盖(密钥应只生成一次),或先校验非符号链接后再以 O_NOFOLLOW|O_WRONLY 打开 }util_open也应在flags中默认并O_NOFOLLOW(或新增util_open_nofollow),用于所有写入凭据/配置/密钥的场景。复核auths.c::auths_set_dir写auths.json、registry.c写凭据等路径,统一加固。🟢 [建议] src/daemon/modules/runtime/shim/shim_rt_ops.c:283-299 —
strcpy(addr, stdout_buff)存在 1 字节 null 栈溢出(off-by-one)规则: R01-内存安全-01
CVSS: 3.7(AV:L/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:L)— 需 shim 二进制输出恰好
PATH_MAX字节地址,且 shim 二进制路径受PATH环境影响描述:
shim_bin_v2_create中stdout_buff[PATH_MAX+1]经util_read_nointr(out_fd[0], stdout_buff, sizeof(stdout_buff)-1)最多读入PATH_MAX字节;stdout_buff零初始化故stdout_buff[PATH_MAX]仍为 0。当 shim 输出恰为PATH_MAX字节时,strlen(stdout_buff) == PATH_MAX,strcpy(addr, stdout_buff)将写入PATH_MAX + 1字节(含 null),而addr(即调用方rt_shim_create中的char response[PATH_MAX],见shim_rt_ops.c:368)只有PATH_MAX字节,null 越界 1 字节写入栈帧相邻位置。调用栈:
rt_shim_create(shim_rt_ops.c:362,response[PATH_MAX]) →shim_bin_v2_create(runtime, id, bundle, NULL, response, state_path)(shim_rt_ops.c:410) →util_read_nointr(out_fd[0], stdout_buff, PATH_MAX)(shim_rt_ops.c:283) →strcpy(addr, stdout_buff)(shim_rt_ops.c:299)修复建议
// 用 snprintf 显式截断并保证 null 终止 (void)snprintf(addr, PATH_MAX, "%s", stdout_buff); // 或在读取后显式置 null 并校验长度 stdout_buff[PATH_MAX] = '\0'; if (strlen(stdout_buff) >= PATH_MAX) { ERROR("shim address too long"); ret = -1; goto out; } (void)strcpy(addr, stdout_buff);🟢 [建议] src/daemon/modules/image/oci/oci_pull.c:78-159 —
--insecure-registry/insecure_skip_verify_enforce配置可全局关闭镜像仓库 TLS 验证规则: R03-加密认证-04 / R08-依赖配置-03
描述:
update_option_insecure_registry与oci_image.c::oci_image_init允许经daemon.json的insecure-registries列表或--insecure-skip-verify-enforce关闭对指定镜像仓库的 TLS 证书校验(utils/http/http.c:178处CURLOPT_SSL_VERIFYPEER=0)。此为与 docker 对齐的已知功能,但在生产部署中若误配置可致 MITM 攻击者投递恶意镜像/篡改镜像内容,结合本报告重要 1 的堆溢出可形成完整利用链。调用栈:
daemon.json配置 →oci_image_init(oci_image.c:118) →g_oci_image_module_data.insecure_skip_verify_enforce→oci_pull(oci_pull.c:124) →options->skip_tls_verify = true→http_request→http_custom_ssl_options(http.c:178) →CURLOPT_SSL_VERIFYPEER, 0L修复建议
daemon.json加载时对insecure-registries项发出 WARNING 日志并记录到安全审计日志。--insecure-skip-verify-enforce设为 true 时应在 daemon 启动日志显著告警,并禁止与authorization_plugin同时启用(已启用 = 传输层无认证 + 应用层认证不可信,整体不安全)。insecure-registries仅用于测试/内部可信镜像仓库,生产环境禁止。🟢 [建议] src/daemon/nri/nri_adaption.cc:1009-1014 与 src/daemon/modules/network/cni_operator/libcni/invoke/libcni_exec.c:89-91 — NRI/CNI 插件二进制从可配置目录直接
execvp,缺少路径校验规则: R02-注入-05 / R08-依赖配置-02
描述: 两处插件执行路径存在同类问题:
NewLaunchedPlugin中cmd = m_pluginPath + "/" + name,name来自util_scan_subdirs(m_pluginPath, walk_plugin_dir_cb, ...)的目录列举结果,execvp(plugin_args->cmd, params)以 root 身份执行(nri_adaption.cc:994,1009)。libcni_exec.c:89-91中execvp(pargs->path, argv),pargs->path来自 CNI 插件配置(libcni_invoke调用方传入),最终亦由 daemon 配置的 CNI 调用链路决定。m_pluginPath与 CNI 插件目录均经conf_get_*来自 daemon 配置。若插件目录对非 root 可写(误配置),或其中某个子目录/插件二进制被替换为符号链接指向攻击者控制路径,攻击者可获得 root 任意代码执行。调用栈(NRI):
NRIAdaptation::Init(nri_adaption.cc:70) →m_pluginPath = GetNRIPluginPath()(nri_adaption.cc:1091) →DiscoverPlugins(nri_adaption.cc:1045) →util_scan_subdirs(m_pluginPath, walk_plugin_dir_cb, ...)→NewLaunchedPlugin(plugin)(nri_adaption.cc:1004) →cmd = m_pluginPath + "/" + name(nri_adaption.cc:1009) →fork()→plugin_exec_func→execvp(plugin_args->cmd, params)(nri_adaption.cc:994)调用栈(CNI): CNI 调用链 →
libcni_exec.c:89execvp(pargs->path, argv)修复建议
fstatat(AT_FDCWD, plugin_path, &st, AT_SYMLINK_NOFOLLOW)校验:必须为常规文件(S_ISREG)、属主为 root、对 group/other 不可写(st.st_mode & (S_IWGRP|S_IWOTH) == 0)。name/pargs->path做白名单字符校验(仅允许[A-Za-z0-9._/-]且禁止..),防止路径穿越。🟢 [建议] src/daemon/modules/runtime/isula/isula_rt_ops.c:590,1069 + shim/shim_rt_ops.c:255 — runtime 二进制经 PATH 查找
execvp,未做绝对路径校验规则: R02-注入-05 / R08-依赖配置-02
描述:
runtime_exec_func中execvp(rei->cmd, rei->params)(isula_rt_ops.c:590),rei->cmd为 runtime 二进制名(如runc、kata-runtime),由 daemon 配置default-runtime/ 容器--runtime指定。execvp按PATH环境变量查找二进制,若 daemon 运行环境的PATH含非 root 可写目录,攻击者可放置同名恶意二进制获得 root 执行。isula_rt_ops.c:1069的execvp(SHIM_BINARY, ...)与shim_rt_ops.c:255的execvp(binary, ...)同理。runtime 二进制名应为受信配置项,但缺少绝对路径校验与PATH收敛构成防御纵深缺失。调用栈: 容器创建/执行 →
runtime_call→runtime_exec_func(isula_rt_ops.c:575) →execvp(rei->cmd, rei->params)(isula_rt_ops.c:590)修复建议
default-runtime与各--runtime配置项为绝对路径,且stat确认为常规文件、属主 root、对 group/other 不可写。PATH收敛为受信目录白名单(如/usr/local/bin:/usr/bin:/bin),并在 fork 子进程执行 runtime 前显式setenv("PATH", <白名单>)。SHIM_BINARY宏改为绝对路径定义。🟢 [建议] src/daemon/modules/image/oci/storage/layer_store/graphdriver/devmapper/deviceset.c:593,649,1808,1881 — devmapper 辅助命令
execvp以 args[0] 作为二进制名,缺少路径校验规则: R02-注入-05 / R08-依赖配置-02
描述:
run_blkid_get_uuid、run_grow_rootfs等函数中execvp(tmp_args[0], tmp_args),tmp_args[0]固定为"blkid"/grow.fs等字面量(见deviceset.c:608,650),但execvp仍按PATH查找。grow.fs等非标准命令名若PATH含非 root 可写目录可被劫持。属防御纵深缺失(默认PATH受信时无实际漏洞)。调用栈: devmapper 设备初始化 →
get_device_uuid(deviceset.c:616) →util_exec_cmd(run_blkid_get_uuid, args, ...)→run_blkid_get_uuid→execvp(tmp_args[0], tmp_args)(deviceset.c:593)修复建议
/usr/sbin/blkid),或在调用前stat校验二进制属主与权限。PATH(同上一条建议)。⚠️ 疑似[重要] src/daemon/entry/connect/rest/rest_service.c:158-164 + src/daemon/entry/connect/grpc/grpc_service.cc:155-165 — 套接字
unlink后bind存在 TOCTOU 符号链接竞态置信度: 70%
描述: REST 服务在
rest_server_init中先unlink(g_socketpath + strlen(UNIX_SOCKET_PREFIX))删除旧套接字文件,再evhtp_bind_socket(g_htp, g_socketpath, 0, BACKLOG)绑定。两步之间存在时间窗,攻击者若能在此窗口内在该路径创建符号链接指向任意文件,bind可能跟随该链接在目标位置创建 UNIX 域套接字文件(覆盖/破坏目标文件)。gRPC 服务侧Shutdown中的unlink(address.c_str() + strlen(UNIX_SOCKET_PREFIX))同样如此。默认套接字路径/var/run/isulad/isulad.sock位于 root 独占目录,利用前提较强;若管理员配置了非 root 独占的套接字目录则风险上升。调用栈:
rest_server_init(socket)(rest_service.c:131) →unlink(g_socketpath + strlen(UNIX_SOCKET_PREFIX))(rest_service.c:158) → [时间窗] →evhtp_bind_socket(g_htp, g_socketpath, 0, BACKLOG)(rest_service.c:164)建议:
util_mkdir_p(path, 0700)并校验属主为 root。bind前用fstatat(AT_FDCWD, sock_path, &st, AT_SYMLINK_NOFOLLOW)校验不是符号链接。bind失败后unlink再重试一次,并在最终bind前再次校验路径非链接。cmd/isulad/main.c:1304realpath(isulad_tmp_cleanpath, cleanpath)与daemon/modules/container/leftover_cleanup/cleanup.c:189的同类模式。⚠️ 疑似[建议] src/daemon/modules/image/embedded/load.c:97-110 — 嵌入式镜像「签名文件」仅读取摘要文本,未见密码学签名验证
置信度: 65%
描述:
get_digest读取与 manifest 同名的.sgn文件内容作为digest(截断到RAW_DIGEST_LEN + strlen(SHA256_PREFIX)字符),但未在load.c/lim.c/embedded_image.c中找到 RSA/ECDSA/Ed25519 等签名验证逻辑,也未将.sgn内容与 manifest 计算出的摘要做比对。若.sgn仅为摘要文本而非真实签名,则「签名」仅具备完整性声明意义,无法防篡改;攻击者可同时替换 manifest 与.sgn而通过校验。需进一步确认lim_add_manifest下游是否在 db 层做摘要重算与比对(embedded/db/db_all.c中仅见config_digest字段读写,未见比对)。调用栈:
embedded_load_image(load.c:215) →load_image(file)(load.c:165) →get_digest(real_path)(load.c:178) → 读.sgn文件为digest→lim_add_manifest(ic, real_path, digest, false)(load.c:184)建议:
.sgn设计为真实签名,应使用 OpenSSL EVP 验证签名(公钥需内置或来自可信密钥库),而非读取摘要文本。lim_add_manifest中重算 manifest SHA256 并与digest比对,不一致则拒绝加载。