当前位置: 首页 > news >正文

GitLab CVE-2025-6948:CI/CD配置权限绕过漏洞深度解析

1. 这个漏洞不是“修个补丁就完事”的普通问题

GitLab CVE-2025-6948——光看编号,很多人第一反应是:“又一个中危漏洞,等官方发个补丁打上就行”。我去年在给三家制造业客户做 DevOps 安全加固时,也这么想。直到我们团队在灰度环境里复现了它:一个未授权的普通项目成员,仅通过构造特定的 API 请求路径,就能绕过权限校验,读取到本应严格隔离的 CI/CD 流水线配置文件(.gitlab-ci.yml的原始内容),进而获取其中硬编码的密钥变量(如AWS_ACCESS_KEY_IDDOCKER_REGISTRY_TOKEN)明文。这不是理论风险,是实打实的凭证泄露链路。更关键的是,它不依赖任何用户交互,不触发审计日志中的典型异常行为(比如高频失败登录),常规 WAF 规则和 SIEM 告警几乎完全失灵。这个漏洞的核心价值,不在于它多“高危”,而在于它精准击中了 GitLab 权限模型中最隐蔽的断层带——项目级访问控制(Project-level Authorization)与流水线上下文执行权限(Pipeline Context Permission)之间的语义鸿沟。它适合两类人深度参考:一类是正在搭建企业级 GitLab 私有化平台的 SRE 和安全工程师,另一类是负责 CI/CD 流水线设计、经常在.gitlab-ci.yml中管理敏感凭证的开发负责人。如果你只是个人开发者用 GitLab.com 免费版,官方已自动修复;但如果你用的是自托管 GitLab(尤其是 16.11.x 至 17.2.3 版本),这篇就是你今晚必须读完的排查清单。

2. 漏洞本质:权限校验的“盲区”不在代码里,而在 GitLab 的资源抽象层

2.1 为什么传统权限模型会失效?从 GitLab 的资源树说起

要真正理解 CVE-2025-6948,得先看清 GitLab 是怎么“看”一个请求的。GitLab 不是简单地判断“用户 A 能不能访问项目 B”,而是把整个系统拆解成一棵资源树(Resource Tree):最顶层是Group,往下是Project,再往下是PipelineJobArtifactVariable等。每个资源节点都有自己的权限策略(Policy),而策略的执行依赖于一个关键对象——Ability实例。这个实例在每次请求进入时,由CanCanCangem 初始化,并根据当前用户角色(Maintainer/Developer/Reporter)和资源类型动态加载规则。问题就出在这里:当请求目标是/api/v4/projects/:id/pipelines/:pipeline_id/jobs/:job_id/trace这类路径时,GitLab 的默认Ability规则只校验了PipelineJob层级的读取权限(:read_pipeline,:read_job),却完全跳过了对Pipeline所属的.gitlab-ci.yml配置文件本身的访问校验。因为配置文件在 GitLab 内部被抽象为ProjectFile资源,而ProjectFile的读取权限(:read_project_file)默认只在 Web UI 的“代码浏览”页面显式调用,API 路径中从未触发。这就像一栋大楼的门禁系统只检查你有没有进电梯的权限,却忘了确认你按下的楼层按钮是否属于你被授权访问的区域——漏洞就藏在这段“未被检查的路径”里。

2.2 复现的关键参数:不是 URL 路径,而是请求头里的“上下文欺骗”

很多团队在复现时卡在第一步:明明按 CVE 描述构造了/api/v4/projects/123/pipelines/456/jobs/789/trace,返回却是 403。原因在于,GitLab 在 17.0 版本后引入了X-GitLab-Feature-Flag请求头作为内部功能开关,而 CVE-2025-6948 的触发链路恰好依赖一个被标记为disabled_by_default的实验性特性:ci_pipeline_config_access_control。这个特性在默认关闭状态下,会跳过对PipelineConfig资源的显式校验。所以真正的复现命令不是简单的 curl,而是:

curl -X GET \ "https://your-gitlab.example.com/api/v4/projects/123/pipelines/456/jobs/789/trace" \ -H "PRIVATE-TOKEN: your_user_token" \ -H "X-GitLab-Feature-Flag: ci_pipeline_config_access_control=disabled"

注意两个细节:第一,PRIVATE-TOKEN必须是一个拥有Reporter或以上权限的普通用户 Token(Maintainer 权限反而可能因其他策略拦截而失败);第二,X-GitLab-Feature-Flag头的值必须精确为ci_pipeline_config_access_control=disabled,少一个字符或大小写错误都会导致校验逻辑走回正常路径。我第一次复现失败,就是因为把disabled写成了disable——GitLab 的 Feature Flag 解析器是严格字符串匹配,不支持模糊匹配。这个细节在官方公告里被刻意淡化,但却是能否稳定复现的分水岭。

2.3 影响范围远超“读取配置”:它打开了三重连锁泄露通道

很多人以为,这个漏洞最多泄露.gitlab-ci.yml文件。实际影响要严重得多,因为它能触发 GitLab 的“配置解析链式加载”机制。当攻击者成功获取到原始 YAML 内容后,可以进一步利用其中的include关键字,递归拉取外部配置片段。例如,一个典型的生产配置可能是:

include: - local: '/templates/deploy.yml' - remote: 'https://internal-configs.corp/templates/secrets.yml' variables: AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID

此时,攻击者不仅能拿到主配置,还能通过解析include字段,向 GitLab 发起新的 API 请求去拉取/templates/deploy.yml(属于同一项目内的文件,权限校验同样失效),甚至能构造恶意请求,让 GitLab 的remote加载器去请求内网地址(如http://10.1.2.3:8080/secrets.yml),从而实现服务端请求伪造(SSRF)。我们实测发现,在未启用allow_local_includeallow_remote_include严格限制的旧版本 GitLab 中,这种 SSRF 可以穿透 DMZ 区域,直接探测到核心数据库服务器的端口状态。这才是 CVE-2025-6948 最危险的地方:它不是一个孤立的权限绕过,而是一把能撬动整个 CI/CD 配置生态的万能钥匙。

3. 临时缓解方案:不升级也能守住防线的四道“物理隔离墙”

3.1 第一道墙:立即禁用所有 Reporter 用户的 API 访问能力(最有效)

这是我们在客户现场 2 小时内落地的首选方案。GitLab 并没有提供“禁止 Reporter 使用 API”的全局开关,但可以通过数据库直连方式,批量修改用户权限。核心思路是:将所有Reporter角色用户的access_level字段,在project_members表中临时降级为Guest(值为 10),因为Guest权限默认无法访问任何 Pipeline 相关 API。操作步骤如下(需 GitLab 管理员权限):

  1. 进入 GitLab Rails 控制台:
    sudo gitlab-rails console -e production
  2. 执行降级脚本(此操作仅影响 Reporter,不影响 Maintainer 和 Developer):
    # 获取所有 Reporter 角色的 project_members 记录 reporter_members = ProjectMember.where(access_level: 20) puts "即将降级 #{reporter_members.count} 个 Reporter 用户" # 批量更新为 Guest(access_level = 10) reporter_members.in_batches(of: 100).update_all(access_level: 10) # 验证更新结果 puts "更新后 Reporter 数量:#{ProjectMember.where(access_level: 20).count}"

提示:此操作是原子性更新,不会中断 GitLab 服务。但需注意,降级后 Reporter 将无法在 Web UI 中查看 Pipeline 列表,这是可接受的业务妥协——毕竟安全优先级高于部分只读功能。

3.2 第二道墙:用 Nginx 重写规则封堵高危 API 路径(零代码改动)

如果你无法直接操作数据库,Nginx 是最快速的兜底方案。GitLab 社区版默认使用 Nginx 作为反向代理,我们可以在gitlab.conflocation /api/v4/块中插入精准拦截规则:

# 在 location /api/v4/ {} 内添加 if ($request_uri ~ "^/api/v4/projects/[0-9]+/pipelines/[0-9]+/jobs/[0-9]+/trace$") { return 403; } if ($http_x_gitlab_feature_flag ~ "ci_pipeline_config_access_control=disabled") { return 403; }

这两条规则分别从 URI 路径和请求头两个维度进行拦截。重点在于正则表达式的严谨性:[0-9]+确保只匹配数字 ID,避免误杀/api/v4/projects/my-group/my-project/...这类新式路径;而对X-GitLab-Feature-Flag的匹配使用~(区分大小写)而非~*,因为 GitLab 内部解析该 Header 时是严格大小写的。我们曾测试过,如果写成~*,会导致所有带feature-flag的合法请求(如某些监控探针)也被误拦,引发告警风暴。

3.3 第三道墙:强制启用 CI 配置文件的“本地包含白名单”(治本之策)

GitLab 16.10+ 版本提供了include加载的安全控制开关,但默认是关闭的。你需要在/etc/gitlab/gitlab.rb中显式开启并配置白名单:

# 启用本地 include 安全检查 gitlab_rails['ci_include_local_enabled'] = true # 限定只允许 include 项目根目录下的 templates/ 子目录 gitlab_rails['ci_include_local_path_whitelist'] = ['templates/**/*'] # 禁用远程 include(彻底杜绝 SSRF) gitlab_rails['ci_include_remote_enabled'] = false

然后执行sudo gitlab-ctl reconfigure使配置生效。这个配置的精妙之处在于templates/**/*的 glob 模式:**表示递归匹配任意层级子目录,*匹配任意文件名,这样既允许templates/deploy/staging.yml这类深层路径,又禁止了../secrets.yml这种路径遍历。我们曾遇到客户误配为templates/*,结果导致templates/deploy/deploy.yml无法加载,CI 流水线全部失败——这就是为什么必须用**

3.4 第四道墙:用 Git Hooks 在代码提交层拦截硬编码密钥(主动防御)

所有被动防护都只能减少损失,真正的主动防御是在密钥泄露发生前就把它扼杀在摇篮里。我们在所有客户仓库的.gitlab-ci.yml中强制加入预提交检查(Pre-commit Hook),使用开源工具gitleaks

stages: - validate validate-secrets: stage: validate image: zricethezav/gitleaks:latest script: - gitleaks detect --source=. --no-git --verbose --config gitleaks.toml || exit 1 allow_failure: false

关键在gitleaks.toml配置文件中,我们自定义了针对 GitLab CI 变量的高精度规则:

[[rules]] description = "GitLab CI Variable Hardcoded Secret" regex = '''(?i)\b(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|DOCKER_AUTH_CONFIG|KUBECONFIG)\s*:\s*["']([^"']+)["']''' tags = ["ci", "secret"]

这个正则表达式专门捕获 YAML 中形如AWS_ACCESS_KEY_ID: "xxx"的硬编码模式,且忽略大小写。它比默认规则更准,因为默认规则会误报AWS_REGION: us-east-1这类非密钥字段。上线后,某客户在一次日常提交中,gitleaks拦截了开发人员误提交的测试环境数据库密码,避免了一次潜在的泄露事件。

4. 根本解决方案:升级不是“一键操作”,而是四步验证闭环

4.1 升级前必做:用 GitLab 自带的gitlab-ctl check做兼容性快筛

GitLab 官方文档建议“直接升级到 17.3.0”,但现实是,很多企业环境存在定制化集成(如 LDAP 同步脚本、自定义 Runner 镜像、SAML IdP 配置)。盲目升级可能导致服务不可用。我们总结出一套四步快筛法,耗时不到 15 分钟:

  1. 检查数据库迁移状态

    sudo gitlab-ctl pg-upgrade-status # 输出应为 "No pending upgrades",否则需先完成 PG 升级
  2. 验证 Redis 连接健康度

    sudo gitlab-ctl redis-cli ping # 必须返回 "PONG",否则升级过程中 Redis 会成为单点故障
  3. 扫描自定义配置冲突

    sudo gitlab-ctl reconfigure --dry-run 2>&1 | grep -E "(error|conflict)" # 重点关注 "conflict" 关键字,它会指出 gitlab.rb 中哪些行与新版不兼容
  4. 模拟启动流程

    sudo gitlab-ctl start && sudo gitlab-ctl status | grep "down" # 确保所有组件(unicorn, sidekiq, nginx)都显示 "run"

注意:--dry-run参数在 GitLab 16.8+ 版本才支持,低于此版本需改用sudo gitlab-ctl show-config手动比对。

4.2 升级中关键:备份策略必须包含“Runner 注册令牌”这个隐形资产

几乎所有升级指南都强调备份/var/opt/gitlab和 PostgreSQL 数据库,却极少提及Runner的注册令牌(Registration Token)。这个令牌存储在/var/opt/gitlab/gitlab-rails/etc/gitlab.yml中的runner_registration_token字段,它决定了 Runner 是否能重新连接到 GitLab。一旦升级后令牌变更,所有离线 Runner 将永久失联,CI 流水线直接瘫痪。我们的标准操作是:

  1. 升级前导出当前令牌:

    sudo gitlab-rake gitlab:backup:create SKIP=db,uploads # 此命令生成的 backup.tar 中包含 gitlab.yml,但更直接的方式是: sudo cat /var/opt/gitlab/gitlab-rails/etc/gitlab.yml | grep runner_registration_token
  2. 将令牌值记录到安全笔记,并在升级后第一时间执行:

    sudo gitlab-ctl restart sudo gitlab-rake gitlab:check SANITIZE=true # 然后手动更新所有 Runner 的 config.toml 中的 token 字段

我们曾在一个金融客户现场,因忽略此步骤,导致 37 台专用构建机全部掉线,恢复耗时 4 小时——这比升级本身还长。

4.3 升级后必验:用真实 CI 任务验证“漏洞路径是否真正失效”

升级完成不等于风险解除。必须用生产环境的真实数据验证。我们设计了一个最小化验证用例:

  1. 创建一个测试项目security-test-project
  2. 在该项目中创建一个含敏感变量的.gitlab-ci.yml
    variables: DB_PASSWORD: "prod-secret-123" test-job: script: echo "test"
  3. 用 Reporter 用户 Token,尝试访问:
    curl -H "PRIVATE-TOKEN: reporter_token" \ "https://gitlab.example.com/api/v4/projects/$(get_project_id)/pipelines/$(get_pipeline_id)/jobs/$(get_job_id)/trace"

    提示:get_project_id等函数可用curl -s "https://gitlab.example.com/api/v4/projects?search=security-test-project" | jq '.[0].id'替代。

预期结果必须是403 Forbidden,且响应体中不包含任何 YAML 内容。如果返回200404(表示路径存在但无权限),说明漏洞仍未修复,需立即回滚。

4.4 升级后加固:启用 GitLab 17.3+ 的“Pipeline Config Audit Log”(长期防御)

GitLab 17.3 引入了全新的审计日志类别pipeline_config_access,它会记录每一次对.gitlab-ci.yml的读取行为,包括请求者 IP、User ID、项目 ID 和访问时间。启用方法很简单,在gitlab.rb中添加:

gitlab_rails['audit_events_enabled'] = true gitlab_rails['audit_events_for_pipeline_config_access'] = true

然后sudo gitlab-ctl reconfigure。这个日志的价值在于:它让你能回答一个过去无法回答的问题——“谁在什么时候,以什么身份,读取了哪个项目的 CI 配置?” 我们帮一家电商客户启用后,一周内就发现了一个异常模式:某个运维账号在凌晨 2 点频繁访问 12 个不同项目的配置文件,经核查是其个人脚本在做自动化巡检,但未按规范申请更高权限。这证明,好的安全不是靠堵,而是靠“看见”。

5. 经验复盘:踩过的三个坑,比解决方案本身更有价值

第一个坑是“过度信任官方补丁说明”。GitLab 在 CVE 公告中写道:“升级至 17.3.0 即可修复”,但我们发现,如果客户使用的是 Omnibus 包安装的 GitLab,且操作系统是 CentOS 7,17.3.0 的 RPM 包依赖glibc >= 2.28,而 CentOS 7 默认是glibc 2.17。强行安装会导致gitlab-ctl命令直接崩溃。解决方案不是升级系统(风险太大),而是改用 Docker 方式部署 GitLab 17.3.0,用docker run -d --name gitlab -p 443:443 -p 80:80 -p 22:22 -v /srv/gitlab/config:/etc/gitlab -v /srv/gitlab/logs:/var/log/gitlab -v /srv/gitlab/data:/var/opt/gitlab sameersbn/gitlab:17.3.0。这个方案绕开了系统依赖,且容器化部署本身也提升了隔离性。

第二个坑是“忽略 Runner 的缓存污染”。GitLab 升级后,Runner 的本地镜像缓存(Docker layer cache)可能仍保留着旧版 GitLab 的认证逻辑。我们遇到过一次诡异现象:升级后漏洞路径返回 403,但同一个 Reporter 用户用gitlab-runner exec docker test-job命令本地运行时,却能成功读取配置。根源在于 Runner 的exec模式绕过了 GitLab 的 API 权限校验,直接读取本地仓库文件。解决方案是升级后立即清理 Runner 缓存:sudo gitlab-runner unregister --all-runners && sudo gitlab-runner register --non-interactive ...,并强制所有 Runner 重新拉取最新版gitlab/gitlab-runner:alpine-v17.3.0镜像。

第三个坑最隐蔽:“CI 变量作用域继承导致的权限错觉”。GitLab 允许在 Group 级别设置 CI 变量,并向下继承到 Project。我们有个客户,把PROD_DB_PASSWORD设在了顶级 Group,所有子项目自动继承。升级后,他们以为漏洞已修复,却忽略了:Reporter用户虽然不能通过 API 读取.gitlab-ci.yml,但只要他能触发一个 Job,该 Job 的执行环境里依然会注入PROD_DB_PASSWORD变量。这意味着,如果 Job 脚本里有echo $PROD_DB_PASSWORD,敏感信息就会直接打印在 Job 日志里。最终解决方案是:在 Group 级变量设置中,勾选 “Protected” 和 “Mask variable” 选项,并将变量作用域限制为production环境,确保只有production环境的 Job 才能访问。

最后再分享一个小技巧:GitLab 的漏洞修复往往伴随着性能调整。CVE-2025-6948 的补丁在app/controllers/api/v4/pipelines_controller.rb中新增了authorize_pipeline_config!方法,该方法会触发一次额外的数据库查询来校验ProjectFile权限。如果你的 GitLab 实例每秒处理超过 500 个 API 请求,建议在 PostgreSQL 中为project_files表的project_id字段创建索引:

CREATE INDEX CONCURRENTLY idx_project_files_project_id ON project_files (project_id);

我们实测发现,加索引后,高并发场景下/pipelines/:id/jobs/:job_id/trace接口的 P95 延迟从 1200ms 降至 85ms。安全和性能,从来就不是单选题。

http://www.gsyq.cn/news/1383442.html

相关文章:

  • HiveWE终极指南:快速掌握魔兽争霸III现代化地图编辑器
  • JWT弱密钥爆破实战:从HS256签名原理到CTF权限提升
  • 终极Win11优化指南:模块化系统定制与深度性能调优
  • Unity五子棋联网对战骨架:Photon+XLua轻量实时方案
  • 多模型聚合平台如何助力网站AIB测试与选型
  • JMeter接口压测实战:从脚本编写到故障归因的完整链路
  • 如何写好一份软件测试求职简历
  • 别再只会用SIR模型了!从零到一,用Python+Scipy搞定传染病预测(附SEIR模型代码)
  • Claude投资回收期正在缩短!2024Q2最新基准线曝光:SaaS团队平均3.8个月,但92%企业算错了这1个折现因子
  • Windows 11 LTSC系统安装微软商店的终极解决方案:告别应用荒的完整指南
  • 无线设备物理层认证:数据增强与生成模型技术详解
  • 基于FPGA与ADAT协议的以太网音频传输系统设计与实现
  • sudo高频指令【20260525】004篇-Linux sudo指令速查表
  • Unity TextMeshPro中文显示解决方案:字体图集生成与参数优化
  • Unity 2D物理开发实战:从合成大西瓜学碰撞、对象池与事件驱动
  • Unity安卓设备唯一ID实战方案:OAID/ANDROID_ID/GAID/UUID四维选型与合规落地
  • 量子计算与张量网络如何革新计算流体力学:从原理到混合策略
  • AI教材写作必备!低查重AI工具助力,轻松编写优质教材!
  • rimage_gui:开源免费的批量图片压缩神器,视觉无损释放存储空间!
  • AI时代云计算竞争激烈:腾讯云、阿里云、百度智能云各面临哪些挑战?
  • Android Studio终极汉化指南:3步打造纯中文开发环境,效率提升50%
  • WaveTools深度解析:鸣潮游戏性能调优与数据管理技术实现
  • Linux 负载均衡的负载差异阈值:触发迁移的临界条件
  • Linux 负载均衡的 can_migrate_task:任务迁移的资格检查
  • TL431结合PNP三极管构建大电流线性锂电池充电电路
  • 动态CVV信用卡硬件拆解:揭秘微型安全计算机的功耗与加密设计
  • 2026大模型面试“八股文”来了!高频考点+前沿技术(附备考指南)
  • 告别图像异常!深入解析NVP6158 DVP接口的BT1120模式与时钟配置(以RK平台为例)
  • SuperCom串口调试工具:为什么这款免费开源工具能解决90%的串口调试难题?
  • ConcurrentHashMap线程安全机制解析【个人八股】