Open
iSulad-v2.1.6 代码安全审计问题 #133
fh966666Add on  Sep 14
fh966666
Sep 14 Create

发现 13 个问题(🔴 致命 2 · 🟡 重要 4 · 🟢 建议 5 · ⚠️ 疑似 2)

其中:

  • 2 个致命问题:其一是 gRPC 远程连接场景下的应用层鉴权绕过——服务端信任客户端自报的 tls_mode 头部决定是否执行授权检查,可被未授权远程攻击者(tls_verify=false 配置下)或持有合法客户端证书的低权限用户绕过,控制容器守护进程的全部 API;其二是 cgroup 路径转换函数中的指针算术错误,括号错位使 strlen 读取堆分配之前的内存(堆越界读),返回值不可预测,进而使后续 strcat 边界检查被 size_t 下溢绕过,向 result[PATH_MAX+1] 栈缓冲区越界写入,在 root 守护进程中可实现栈破坏 → RCE 或 DoS。
  • 4 个重要问题:镜像仓库令牌 URL 构造函数中的 1 字节堆溢出(off-by-one null,可被恶意镜像仓库或中间人触发)+ 同函数缺少 URL 编码(查询注入);AES-256 密钥实际熵仅 128 比特;敏感串清零使用 memset 可被编译器死存储优化移除;AES 密钥文件创建/读取缺少 O_NOFOLLOW 存在符号链接跟随风险。
  • 5 个建议:shim 地址拷贝栈 off-by-one、insecure-registry 配置加固、NRI 插件与 CNI 插件 execvp 路径校验不足、runtime 二进制经 PATH 查找未做绝对路径校验。
  • 2 个疑似:套接字 TOCTOU 符号链接竞态、嵌入式镜像签名弱。

优先修复顺序:致命 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 → 容器逃逸/特权提升/宿主文件读写

调用栈

grpc::ServerBuilder::BuildAndStart()                  [grpc_service.cc:108]   // 启动 gRPC 服务
  -> ContainerServiceImpl::Create() (等所有 RPC 方法) [grpc_containers_service.cc]
     -> ContainerServiceBase::Run()                   [container/service_base.h:46]
        -> ContainerServiceBase::Authenticate()       [container/service_base.h:73]
           -> GrpcServerTlsAuth::auth(context, action) [grpc_server_tls_auth.cc:30]
              -> context->client_metadata().find("tls_mode")  [grpc_server_tls_auth.cc:35]
              -> if (缺失 || "0") return Status::OK;          [grpc_server_tls_auth.cc:36-42]  // <-- 漏洞触发点

问题代码

// 文件: 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;
}

调用链分析

层次 函数 文件:行号 角色
1 grpc_server_init grpc_service.cc:226 启动 gRPC 服务,按 args->json_confs->tls 选择 TLS/Insecure 凭据
2 ListeningPort grpc_service.cc:139 tls_verify=false 时使用 GRPC_SSL_DONT_REQUEST_CLIENT_CERTIFICATE,传输层不做客户端认证
3 ContainerServiceImpl::Create (等) grpc_containers_service.cc:* 各 RPC 入口,调用 Authenticate()
4 ContainerServiceBase::Authenticate container/service_base.h:73 委托给 GrpcServerTlsAuth::auth
5 GrpcServerTlsAuth::auth grpc_server_tls_auth.cc:30 漏洞触发点:信任客户端 tls_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 spec linux.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 或崩溃

调用栈

daemon/executor/container_cb/execution.c:439  common_convert_cgroup_path(cgroups_path)   // 入口,cgroups_path 来自容器配置
  (另两入口: daemon/common/cgroup/cgroup_v1.c:1117 / cgroup_v2.c:487)
  -> daemon/common/cgroup/cgroup.c:142  common_convert_cgroup_path()
     -> daemon/common/cgroup/cgroup.c:148  util_string_split_n(cgroup_path, ':', 3)   // arr[0..2] 各自堆分配
     -> daemon/common/cgroup/cgroup.c:187  strlen(arr[0] - strlen(arr[1]) - strlen(arr[2]))   // <-- 堆越界读 + 边界检查绕过
        -> daemon/common/cgroup/cgroup.c:193-199  strcat(result, "/arr[0]/arr[1]-arr[2].scope")  // <-- 栈溢出

问题代码

// 文件: 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);
}

调用链分析

层次 函数 文件:行号 角色
1 common_convert_cgroup_path cgroup.c:141 入口,参数来自容器配置 cgroupsPath
2 util_string_split_n utils_string.c:375 分割字符串;util_array_append 中每个元素经 util_strdup_s 独立堆分配(见 utils_array.c:110-133)
3 边界校验表达式 cgroup.c:187 漏洞触发点:arr[0] - strlen(arr[1]) - strlen(arr[2]) 为指向 arr[0] 之前的指针
4 strlen libc 从该非法起点逐字节读取直至 null,读取 arr[0] 堆分配之前的内存,返回不可预测值
5 strcat 链 cgroup.c:193-199 边界检查被 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_url 1 字节 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 含 &/=/# 可注入额外查询参数或路径,欺骗认证服务器

调用栈

registry_request()                                    [registry_apiv2.c]
  -> auth_request()                                   [registry_apiv2.c]
     -> parse_auth() -> parse_challenges()            [registry_apiv2.c:88,157]   // realm/service 来自 HTTP 头
        -> challenge.realm = util_strdup_s(value)     [registry_apiv2.c:116]
        -> challenge.service = util_strdup_s(value)
  -> http_request_get_token(desc, c, &output)         [http_request.c:310]
     -> build_get_token_url(c, desc->username, desc->scope)  [http_request.c:658]
        -> util_common_calloc_s(url_len)              [http_request.c:594]        // <-- 分配 url_len 字节(漏 +1)
        -> strcat(url, c->realm)                      [http_request.c:600]        // <-- realm 未 URL 编码
        -> strcat(url, "?")                           [http_request.c:602]
        -> strcat(url, "account="); strcat(url, username); strcat(url, "&")  [http_request.c:606-610]  // <-- username 未编码
        -> strcat(url, "service="); strcat(url, c->service); strcat(url, "&") [http_request.c:614-618]  // <-- service 未编码
        -> strcat(url, "scope="); strcat(url, scope)  [http_request.c:622-623]   // <-- scope 未编码 + null 写入 url_len 位置越界

问题代码

// 文件: 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;
}

调用链分析

层次 函数 文件:行号 角色
1 parse_challenges registry_apiv2.c:88 从 WWW-Authenticate 头解析 realm/service(服务端可控)
2 http_request_get_token http_request.c:629 拼接令牌获取 URL
3 build_get_token_url http_request.c:570 漏洞触发点:url_len 漏算 1 字节 null;各分量未 URL 编码
4 util_common_calloc_s utils.c:286 calloc(1, url_len),分配 url_len 字节
5 strcat 链 http_request.c:600-623 写入 url_len 字符 + null → null 越界 1 字节;realm/username/service/scope 直接拼入查询串

触发条件:当 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)→ 弱化凭据机密性

调用栈

首次启动配置镜像仓库认证
  -> auths_set_dir() / aes_set_key_dir()         [auths.c:40 / aes.c:27]
  -> aes_decode() / aes_encode()                 [aes.c:45 / aes.c:67]
     -> util_aes_key(g_aeskey, ..., aeskey)      [aes.c:50]
        -> util_generate_random_str((char*)aeskey, 32)  [utils_aes.c:48]
           -> open("/dev/urandom")               [utils.c:1316]
           -> len = 32 / 2 = 16                  [utils.c:1314]
           -> for i in 0..15: read urandom -> num -> (num % 256) -> snprintf("%02x")  [utils.c:1318-1330]
              // 16 字节熵以 32 个十六进制字符写入 32 字节密钥槽

问题代码

// 文件: 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;
}

调用链分析

层次 函数 文件:行号 角色
1 aes_decode / aes_encode aes.c:45/67 加解密镜像仓库凭据
2 util_aes_key utils_aes.c:25 加载/生成 AES 密钥
3 util_generate_random_str utils.c:1308 漏洞点:len /= 2 后只产出 len/2 字节熵,以十六进制写入 len 字节缓冲
4 snprintf("%02x") utils.c:1327 1 字节熵编码为 2 字符,密钥字符集受限为 [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 越界读)可读到残留凭据

调用栈

oci_pull.c:decode_auth() / auths.c:decode_auth_aes()
  -> util_free_sensitive_string((char *)decoded)   [oci_pull.c:84 / auths.c:118]
     -> memset(str, 0, strlen(str))                [utils.c:1153]   // <-- 可被 DCE 优化移除
     -> free(str)                                   [utils.c:1157]

问题代码

// 文件: 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 也可被消除)
}

调用链分析

层次 函数 文件:行号 角色
1 decode_auth / decode_auth_aes oci_pull.c:36 / auths.c:60 解出明文凭据
2 util_free_sensitive_string utils.c:1147 释放含凭据的缓冲
3 memset utils.c:1153 漏洞点:标准 memset 可能被优化移除
4 free utils.c:1157 释放堆块,凭据可能残留

项目 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 密钥 → 宿主系统破坏;或读取场景跟随链接读取任意文件内容作为密钥

调用栈

auths_set_dir(dir) / aes_set_key_dir(dir)        [auths.c:40 / aes.c:27]
  -> util_aes_key(g_aeskey, create, aeskey)      [aes.c:50]
     -> util_file_exists(key_file)               [utils_aes.c:46]   // 走 access()/stat(),跟随链接
     -> util_write_file(key_file, aeskey, 32, 0600)  [utils_aes.c:62]
        -> util_open(fname, O_WRONLY|O_CREAT|O_TRUNC, mode)  [utils_file.c:1117]
           -> util_clean_path(fname, rpath, ...)            [utils_file.c:642]  // 词法规范化,不解析链接
           -> open(rpath, flags | O_CLOEXEC, mode)          [utils_file.c:645]  // <-- 跟随符号链接

问题代码

// 文件: 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);
    }
}

调用链分析

层次 函数 文件:行号 角色
1 util_aes_key utils_aes.c:25 创建/读取 AES 密钥
2 util_write_file utils_file.c:1106 写密钥文件
3 util_open utils_file.c:638 漏洞点:open 不带 O_NOFOLLOW,跟随符号链接
4 util_clean_path path.c:93 仅词法规范化,不解析符号链接

util_clean_path(src/utils/cutils/path.c:93-145)实现类似 docker cleanpath,逐段处理 ./..,但不调用 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
描述: 两处插件执行路径存在同类问题:

  • NRI: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)。
  • CNI: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:89 execvp(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)。
  • 校验插件目录本身属主为 root 且权限 ≤ 0755。
  • 对 name/pargs->path 做白名单字符校验(仅允许 [A-Za-z0-9._/-] 且禁止 ..),防止路径穿越。
  • 在文档中强调 NRI/CNI 插件目录必须为 root 独占可写。

🟢 [建议] 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)

修复建议

  • daemon 启动期校验 default-runtime 与各 --runtime 配置项为绝对路径,且 stat 确认为常规文件、属主 root、对 group/other 不可写。
  • daemon 启动期将自身 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)

修复建议

  • devmapper 辅助命令改用绝对路径(如 /usr/sbin/blkid),或在调用前 stat 校验二进制属主与权限。
  • daemon 启动期收敛 PATH(同上一条建议)。

置信度: 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:1304 realpath(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 比对,不一致则拒绝加载。
  • 在文档中明确嵌入式镜像的完整性保证机制,避免误用为安全签名。
likedislike
openeuler-ci-botopeneuler-ci-botCollaborators
Sep 14 Assign jingxiaolu、Yekelu、haomintsai、chantsztung、fh966666 as responsible person
openeuler-ci-botopeneuler-ci-botCollaborators
Sep 14 Added label: sig/iSulad
openeuler-ci-bot
openeuler-ci-botCollaborators
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 ,

likedislike