☰
DVWA——Brute Force
2026/10/14 1:15:19 网站建设 项目流程

https://blog.csdn.net/2503_91800161/article/details/160927774https://blog.csdn.net/2503_91800161/article/details/160927774

dvwa的操作步骤及原理_其实防守也摸鱼的博客-CSDN博客

先准备一个弱口令字典

新建一个文本文件,比如pass.txt,先放这些就行:

123456 password admin admin123 123 111111 666666 888888 root test

后面 Burp Intruder 会用它。


第一步:Low —— 手打 + Burp Intruder 爆破

Low 级别基本没有防护,适合先建立“登录接口就是攻击面”的感觉。

  1. 打开 DVWA,把安全级别设为Low。
  2. 进入Brute Force。
  3. 用户名输入admin,密码随便输,比如111,点 Login。
  4. 页面会提示登录失败,说明接口在工作。
  5. 打开 Burp Suite,确认浏览器代理已开启,并且Intercept is on。
  6. 再次提交输入用户名和密码,进行登录请求,Burp 会抓到包。
  7. 右键这个请求,选择Send to Intruder。
  8. 在 Intruder 的Positions里,把攻击类型设为Sniper。
  9. 只把password=111里的111选中,点Add §,让它变成$password$这样的标记。
  10. 切到Payloads,选择Simple list,然后加载刚才的pass.txt。
  11. 点Start attack(开始攻击)。
  12. 攻击结束后,在结果里点Length排序。长度明显不同的那一行,通常就是正确密码。

DVWA 默认账号是admin / password,所以你应该能看到password那行的响应长度和其他不一样。

然后可以去DVWA界面验证一下,输入用户名和密码

这一步你要记住:爆破不是看“提示成功”,而是看响应差异。


第二步:Medium —— 找绕过差异

把 DVWA 安全级别切到Medium,重复刚才的爆破流程。

你会发现:

  • Burp Intruder 仍然能跑,但速度变慢了,之前一秒钟能跑几十个包,现在可能几秒才出一个结果;
  • 如果你尝试用过 SQL 注入(比如用户名输admin' or '1'='1),会发现以前能进去的招数现在失效了。SQL 注入类的绕过大概率不行了。

打开 DVWA 的源码查看功能(或者去文件夹里找vulnerabilities/brute/source/medium.php)。

// Low 级别直接拼接 SQL,没有任何防护 $query = "SELECT * FROM `users` WHERE user='$user' AND password='$pass';";

原因是 Medium 加了两个东西:

  • 对输入做了mysqli_real_escape_string转义,SQL 注入被挡;
  • 登录失败后会sleep(2),每次失败多等 2 秒。
// Medium 级别加了转义 $user = ((isset($GET['username'])) ? $GET['username'] : null); $user = mysqli_real_escape_string($con, $user); // <--- 关键点1 $pass = ((isset($GET['password'])) ? $GET['password'] : null); $pass = mysqli_real_escape_string($con, $pass); // <--- 关键点2 $query = "SELECT * FROM `users` WHERE user='$user' AND password='$pass';"; // ... 中间省略 ... if( !mysqli_num_rows( $result ) ) { sleep( 2 ); // <--- 关键点3 $html .= "<pre><br />Username and/or password incorrect.</pre>"; }

转义(Escape)是什么意思

转义就是在特殊字符前面加反斜杠\,让它失去原有的特殊含义,变成普通字符。

比如用户输入' OR '1'='1,这个单引号''' 在 SQL 里有特殊作用(用来包裹字符串)。转义后变成\' OR \'1\'=\'1,数据库就不会把它当成 SQL 语法的一部分,而是当成普通文本。

三个关键点分析

关键点1 & 2:mysqli_real_escape_string()

这是 PHP 提供的转义函数,会自动处理这些危险字符:

  • \0(NULL)、\n(换行)、\r(回车)
  • \(反斜杠)、'(单引号)、"(双引号)
  • \x1a(EOF 字符)

它在这些字符前自动加\,让攻击者无法通过闭合引号来注入恶意 SQL。

关键点3:sleep(2)

这是防御盲注的手段。正常登录失败立即返回,攻击者可以通过响应时间判断注入是否成功(时间盲注)。加sleep(2)后,无论成功失败都延迟2秒,打乱时间判断。

这样真的安全吗?

说实话,还不够安全。mysqli_real_escape_string()有几个坑:

  1. 字符集问题:如果数据库字符集不是 UTF-8,某些宽字节字符可能绕过转义(比如 GBK 编码下的%df%27)
  2. 数字型注入无效:如果查询是WHERE id=$user(没有引号包裹),转义就完全没用
  3. 不是最佳实践

真正推荐的做法是用预编译语句(Prepared Statement):

$stmt = $con->prepare("SELECT * FROM users WHERE user=? AND password=?");

$stmt->bind_param("ss", $user, $pass);

$stmt->execute();

预编译把 SQL 结构和数据彻底分离,从根本上杜绝注入,比转义可靠得多。

深度解析:它防了什么?没防什么?
  • 它防了 SQL 注入:
    mysqli_real_escape_string这个函数会把特殊字符(如单引号')转义成\'。
    • 以前:输入' or '1'='1-> SQL 变成...password='' or '1'='1'(恒真,绕过验证)。
    • 现在:输入' or '1'='1-> SQL 变成...password='\' or \'1\'=\'1'(变成了普通字符串,无法闭合引号)。
    • 结论:想靠 SQL 注入绕过登录在 Medium 级别行不通了。
  • 它增加了爆破的时间成本:
    注意那个sleep( 2 );。
    • 这意味着每次你猜错密码,服务器都会故意“发呆”2秒钟再告诉你“错了”。
    • 后果:如果你的字典有 10,000 个密码,Low 级别可能几分钟就跑完了,Medium 级别需要 20,000 秒(约 5.5 小时)。
    • 本质:它没有阻止你爆破,只是让你觉得“太慢了,不想跑了”。对于只有 10 个词的字典,这根本不算防护。

所以 Medium 的绕过思路不是“换 payload”,而是理解它只是增加了时间成本,没有真正阻止爆破。

  • 漏洞原理:虽然加了转义防止注入,但登录接口依然允许无限次尝试,且没有账号锁定机制。
  • 利用方式:Burp Intruder 依然有效,只是速度变慢(受限于 sleep 函数)。
  • 防护手段:使用了mysqli_real_escape_string过滤特殊字符;使用了sleep()增加暴力破解的时间成本。

第三步:High —— 读源码,理解为什么多了 Token

切到High,再随便登录一次,用 Burp 抓包。

你会看到请求里多了一个参数,类似:

user_token=xxxxx

直接重复之前的步骤就会发现:跑出来的结果全错了,根本找不到 4800 长度的那一行,而且请求疯狂报错。

High 的核心变化是:每次登录都要带一个有效的user_token,而且每次页面返回的 token 会变化。

这会导致普通 Intruder 爆破失败,因为:

  • 你第一次抓到的 token 只能用一次;
  • 第二次请求如果还用旧 token,服务端会拒绝;
  • 每次失败后还可能有 0–3 秒随机延迟。

这就是 High 级别的核心防御逻辑:增加自动化攻击的状态同步难度。

这一关不要急着硬跑,先读源码,重点看三件事:

  1. user_token从哪里生成?
  2. 服务端是否校验了 token?
  3. 登录失败后是否有延迟?

如果你想继续练 High,可以用 Burp Intruder 的Pitchfork模式(这一步的重点不是背配置,而是理解:Token 让爆破从“无脑重放”变成了“必须同步状态”。):

  • Payload 1:标记password,加载字典;
  • Payload 2:标记user_token,类型选Recursive grep(递归抓取);
  • 把第一次页面里的 token 作为初始值;
  • Grep 配置(最关键的一步):
    • 点击 "Grep - Extract..."
    • 随便填一个请求(比如请求 0)作为种子。
    • 选中返回页面里的新 Token 字符串(注意不要选到空格)。
    • 点击 OK,Burp 会自动生成正则。

手把手教配置 Grep-Extract

  1. 打开全局选项:
    在 Burp Suite 顶部菜单栏,点击Settings(设置)(或者旧版本点击Options)。
  2. 找到 Sessions 页面:
    在左侧搜索栏输入grep或extract,或者直接找到Sessions(会话)这一项,展开它。
    注意:不是 Intruder 里的 Grep,而是全局的会话设置。
  3. 找到 Session Handling Rules:
    在 Sessions 下面找到Session Handling Rules(会话处理规则),点击右边的Add(添加)。
  4. 配置规则:
    • Rule name:随便写个名字,比如Get New Token。
    • 点击运行宏(Run a macro)
    • 在弹出的窗口里,点击添加(Add)添加一个宏
    • 选择种子请求:在弹出的请求列表里,选中你之前用浏览器抓到的、带有user_token的那个原始的 GET 登录页面请求,然后点OK。

下面引用的这一块是bp旧版本的,2.x版本没有下面这些选项对应的东西其实就是上面运行配置宏的部分,所以此引用部分可跳过

    • Scope (作用域):点击Add,选择Include all URLs(包含所有 URL)。
    • Rules (规则):点击Add添加一条规则,选择Set a parameter value(设置参数值)。
  1. 填写参数细节:
    • Parameter name:填user_token(大小写要和 DVWA 页面上的完全一样)。
    • Parameter type:选择URL query string或Request body parameter(DVWA 是 GET 传参,选前者即可)。
    • Parameter value:选择Grep - Extract并点击旁边的Configure(配置)。
  1. 最关键的 Grep 提取配置:
    弹出的窗口里:
    • Grep String (提取源):随便选择一个之前的请求(比如 Request 0),只要它的响应里有 Token 就行。
    • Extract from response (从响应中提取):
      你需要点击页面左侧的Select,然后去右侧的 HTML 响应代码里,用鼠标精准选中user_token后面的那一串 32 位的哈希字符串(只选中字符串,前后不要选到value="和引号)。
      (提示:DVWA 的 Token 通常长得像:6b36c8a61e5e8b202690452650827253)
    • 选好后,点击右下角的OK保存。
  1. 启用规则:
    一路OK保存所有设置。此时你的 Session Handling Rule 应该显示已启用。
  2. 回到 Intruder 准备爆破:
    现在回到 Intruder 页面,把 Payload 2 (Token) 的类型改成Null payloads,而不是之前的 Recursive grep。

为什么要这么复杂?
因为 Burp 需要知道在每次发送新请求之前,先去哪里拿最新的 Token,塞进请求里发出去。这就是高级工具的强大之处,但也增加了学习成本。

如果你觉得这个 Grep 配置太折磨人,High 级别其实还有一个更简单的“笨办法”绕过:直接禁用 JavaScript (用浏览器插件 NoScript),有时候 Token 验证只在前端,但 DVWA 的 High 是后端校验的。

建议:
如果你配置 Grep 实在觉得头大,可以先放一放 High 的实操。只要你能理解"Token 是为了防止自动化脚本重放旧请求"这个原理,这关的学习目的就达到了。

如果报错

这个错误的意思是:你给 Token 的位置(Payload 2)选了一个它无法完成的抓取指令。

我们换一个最直接、最稳的“笨办法”来绕过这个错误,保证马上能跑起来!

终极解决方案:手动填入一个 Token

既然自动抓取 Token 让你觉得头大,我们就不折腾它了。DVWA 的 Token 其实没那么复杂,我们用手动替换的方法:

第一步:获取一个最新的有效 Token
  1. 关掉报错窗口,回到 Burp 的主界面。
  2. 打开你的浏览器,去 DVWA 的Brute Force页面。
  3. 输入错误的用户名密码,点 Login。
  4. 在Proxy -> HTTP history里找到你刚发出的那个请求。
  5. 查看这个请求的响应,找到里面user_token="xxxxx..."那一长串字符,复制它。
第二步:回到 Intruder 重新设置
  1. 回到 Intruder 界面,点击Payloads选项卡。
  2. Payload set 1(密码): 保持不变,还是你的pass.txt字典。
  3. Payload set 2(Token):
    • 把Payload type改回Simple list。
    • 在Payload Options里,点Add,把刚才你复制的那长串 Token 粘贴进去。
    • 删掉原本可能存在的其他旧 Token。
第三步:开始攻击

点Start attack!

预期结果:
因为 Payload set 2 只有一个固定的 Token,所以这次爆破只有第一次请求是能成功登录的。在结果列表里,你依然可以通过观察Length (长度)找到那个唯一的、长度变长了的(比如 4800)结果,那就是password那一行!


第四步:Impossible —— 理解根本防护

切到Impossible,再试登录。

第一步:先试一次

切到 Impossible,随便输个账号密码,点登录,抓包,丢到 Intruder。

你会发现:

  • 请求里依然有user_token。
  • 但这次,就算你用刚才那套"宏 + 草叉"的方法配好了,跑出来的结果里,所有行的 Length 都一模一样,根本找不到 4800 那一行。

不是你的配置错了,是服务器根本不给你"成功"的机会。

第二步:看源码

在 DVWA 页面底部点"View Source",看看 Impossible 级别到底做了什么。核心就这几行:

// 1. 强制 POST 请求 if ($_SERVER['REQUEST_METHOD'] != 'POST') { ... } // 2. 严格的 Token 校验 if (!checkToken($_REQUEST['user_token'], $_SESSION['session_token'], 'index.php')) { ... } // 3. 登录失败延迟 + 锁定 if ($login_failed) { sleep(rand(2, 4)); // 随机延迟 2~4 秒 $failed_logins++; if ($failed_logins >= 3) { // 锁定账号 15 分钟 lock_account($user, 15); } }

你会发现它很难再用前面的方式爆破,因为通常至少会同时具备这些防护:

  • 改用POST提交,避免密码直接暴露在 URL;
  • 保留Token校验;
  • 加入登录失败次数限制,比如连续失败后锁定账户一段时间;
  • 可能还有验证码等额外防护。

所以 Impossible 想表达的是:

第三步:理解它为什么防得住

防御手段

作用

强制 POST

密码不再暴露在 URL 里,防止被浏览器历史/代理日志记录

Token 用完即焚

每次请求后销毁旧 Token,生成新的,重放攻击无效

随机延迟 sleep(2,4)

每次失败等 2~4 秒,爆破速度从每秒几千次降到每秒不到 1 次

失败 3 次锁定 15 分钟

就算你有 100 万密码的字典,试到第 3 个就被锁死,根本跑不完

一句话总结:Impossible 不是靠某一个"绝招",而是用Token + 延迟 + 锁定三重组合拳,让爆破在现实时间里完全失去可行性。

爆破不是靠一个功能防住,而是靠“限次 + 锁定 + Token + POST + 验证码”组合起来。

这一步你可以只读源码,不一定非要跑通。重点记录:如果让你设计登录接口,你会加哪些防护。

<?php if( isset( $_POST[ 'Login' ] ) && isset ($_POST['username']) && isset ($_POST['password']) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Sanitise username input $user = $_POST[ 'username' ]; $user = stripslashes( $user ); $user = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $user ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : "")); // Sanitise password input $pass = $_POST[ 'password' ]; $pass = stripslashes( $pass ); $pass = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : "")); $pass = md5( $pass ); // Default values $total_failed_login = 3; $lockout_time = 15; $account_locked = false; // Check the database (Check user information) $data = $db->prepare( 'SELECT failed_login, last_login FROM users WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute(); $row = $data->fetch(); // Check to see if the user has been locked out. if( ( $data->rowCount() == 1 ) && ( $row[ 'failed_login' ] >= $total_failed_login ) ) { // User locked out. Note, using this method would allow for user enumeration! //echo "<pre><br />This account has been locked due to too many incorrect logins.</pre>"; // Calculate when the user would be allowed to login again $last_login = strtotime( $row[ 'last_login' ] ); $timeout = $last_login + ($lockout_time * 60); $timenow = time(); /* print "The last login was: " . date ("h:i:s", $last_login) . "<br />"; print "The timenow is: " . date ("h:i:s", $timenow) . "<br />"; print "The timeout is: " . date ("h:i:s", $timeout) . "<br />"; */ // Check to see if enough time has passed, if it hasn't locked the account if( $timenow < $timeout ) { $account_locked = true; // print "The account is locked<br />"; } } // Check the database (if username matches the password) $data = $db->prepare( 'SELECT * FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR); $data->bindParam( ':password', $pass, PDO::PARAM_STR ); $data->execute(); $row = $data->fetch(); // If its a valid login... if( ( $data->rowCount() == 1 ) && ( $account_locked == false ) ) { // Get users details $avatar = $row[ 'avatar' ]; $failed_login = $row[ 'failed_login' ]; $last_login = $row[ 'last_login' ]; // Login successful echo "<p>Welcome to the password protected area <em>{$user}</em></p>"; echo "<img src=\"{$avatar}\" />"; // Had the account been locked out since last login? if( $failed_login >= $total_failed_login ) { echo "<p><em>Warning</em>: Someone might of been brute forcing your account.</p>"; echo "<p>Number of login attempts: <em>{$failed_login}</em>.<br />Last login attempt was at: <em>${last_login}</em>.</p>"; } // Reset bad login count $data = $db->prepare( 'UPDATE users SET failed_login = "0" WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute(); } else { // Login failed sleep( rand( 2, 4 ) ); // Give the user some feedback echo "<pre><br />Username and/or password incorrect.<br /><br/>Alternative, the account has been locked because of too many failed logins.<br />If this is the case, <em>please try again in {$lockout_time} minutes</em>.</pre>"; // Update bad login count $data = $db->prepare( 'UPDATE users SET failed_login = (failed_login + 1) WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute(); } // Set the last login time $data = $db->prepare( 'UPDATE users SET last_login = now() WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute(); } // Generate Anti-CSRF token generateSessionToken(); ?>

这段 Impossible 级别的 Brute Force 源码,可以拆成 7 块:

1. 先看入口:谁在触发逻辑?
if( isset( $_POST[ 'Login' ] ) && isset ($_POST['username']) && isset ($_POST['password']) )

意思是:只有用户点了登录,并且提交了用户名、密码,才进入后面这段处理。
审计时,第一眼看的就是$_POST/$_GET/$_REQUEST,因为它们代表用户可控输入。

2. 再看防护:有没有 Token?
checkToken( $_REQUEST[ 'user_token' ], $_SESSION['session_token'], 'index.php' );

这里做了 CSRF Token 校验。
也就是说,攻击者不能随便伪造一个登录请求,必须带上服务器当前会话里的有效 Token。

3. 看输入处理:用户名和密码有没有被净化?
$user = $_POST[ 'username' ]; $user = stripslashes( $user ); $user = mysqli_real_escape_string(...);
$pass = $_POST[ 'password' ]; $pass = stripslashes( $pass ); $pass = mysqli_real_escape_string(...); $pass = md5( $pass );

这里做了三件事:

  • stripslashes:去掉反斜杠
  • mysqli_real_escape_string:转义特殊字符,防 SQL 注入
  • md5:密码哈希存储

所以这里用户名和密码都不能直接带进 SQL,密码也不是明文比较。

4. 看账户状态:有没有锁定机制?
$total_failed_login = 3; $lockout_time = 15;
if( $row[ 'failed_login' ] >= $total_failed_login ) { if( $timenow < $timeout ) { $account_locked = true; } }

这里就是 Impossible 级别最关键的地方:
连续失败 3 次,账号锁定 15 分钟。

爆破最怕这个。因为你字典再大,试到第 3 次就被锁了,后面根本跑不动。

5. 看登录判断:怎么算登录成功?
SELECT * FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;

这里用了PDO 预处理 + 参数绑定:

$data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->bindParam( ':password', $pass, PDO::PARAM_STR );

这意味着它没有把用户输入直接拼进 SQL,而是用参数化查询。
所以这段代码基本堵死了 SQL 注入。

6. 看失败处理:失败后会发生什么?
sleep( rand( 2, 4 ) );

登录失败会随机延迟 2 到 4 秒。
这会让爆破速度大幅下降,从每秒几百次、几千次,变成每秒不到一次。

UPDATE users SET failed_login = (failed_login + 1) WHERE user = (:user) LIMIT 1;

失败次数还会累加,继续逼近锁定阈值。

7. 看结尾:有没有生成新 Token?
generateSessionToken();

每次请求结束后都会生成新的 Token。
旧 Token 失效后,重放攻击也打不通。


以后看到长代码,可以这样读

  1. 先找入口:$_GET、$_POST、$_REQUEST、路由、控制器。
  2. 再找危险点:SQL 查询、命令执行、文件操作、echo 输出。
  3. 追踪数据流:用户输入有没有被过滤、转义、校验。
  4. 看业务逻辑:登录、支付、密码重置、权限判断。
  5. 最后判断:有没有绕过可能?输入是否可控?防护是否完整?

这段代码之所以叫Impossible,不是因为它复杂,而是它把爆破需要的几个条件都拆掉了:

  • 不能重放旧请求,因为有 Token;
  • 不能快速尝试,因为有延迟;
  • 不能无限尝试,因为有锁定;
  • 不能 SQL 注入绕过,因为有参数化查询。

你可以把它当成一个“标准防御模板”来记。后面看 Command Injection 源码时,也可以用同样的方法:先找用户输入,再看有没有进入危险函数,最后看有没有过滤和白名单。


这一关的三行笔记

  • 漏洞原理:登录接口没有有效限制尝试次数,攻击者可用字典自动化枚举用户名和密码。
  • 利用方式:Burp Intruder 标记 password 参数,加载字典爆破,通过响应长度差异判断正确密码。
  • 防护手段:增加 Token、POST 提交、失败延时、登录失败锁定、验证码等组合防护。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询