并发漏洞 Race Condition 学习 | ChatGPT 就是nb!

发布时间 2023-04-10 16:20:29作者: Nestar

今天看了P牛师傅的文章,讲并发漏洞的,https://mp.weixin.qq.com/s/9f5Hxoyw5ne8IcYx4uwwvQ

但师傅用的是 Django 搭建的环境,我试了一下,发现 Django 我是真不会啊,所以先是想用 PHP 去实现

环境搭建

image
首先,我让 ChatGPT 给我写了个简单的程序来测试

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>取款</title>
</head>
<body>
    <?php
        // 定义数据库连接信息
        $servername = "localhost";
        $username = "root";
        $password = "";
        $dbname = "mybank";
        
        // 创建连接
        $conn = new mysqli($servername, $username, $password, $dbname);
        
        // 检查连接是否成功
        if ($conn->connect_error) {
            die("连接失败: " . $conn->connect_error);
        }
        
        // 处理表单
        if ($_SERVER["REQUEST_METHOD"] == "POST") {
            $username = $_POST["username"];
            $password = $_POST["password"];
            $amount = $_POST["amount"];
            
            // 查询用户数据
            $sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
            $result = $conn->query($sql);
            
            // 检查用户是否存在
            if ($result->num_rows > 0) {
                $row = $result->fetch_assoc();
                $balance = $row["balance"];
                
                // 检查余额是否充足
                if ($balance >= $amount) {
                    // 扣除余额并更新数据库
                    $new_balance = $balance - $amount;
                    $sql = "UPDATE users SET balance = '$new_balance' WHERE username = '$username'";
                    
                    if ($conn->query($sql) === TRUE) {
                        echo "取款成功!";
                        
                        // 写入日志文件
                        $timestamp = date("Y-m-d H:i:s");
                        $log_message = "$timestamp: $username 取出 $amount 元。\n";
                        $log_file = fopen("log.txt", "a");
                        fwrite($log_file, $log_message);
                        fclose($log_file);
                    } else {
                        echo "发生错误:" . $conn->error;
                    }
                } else {
                    echo "余额不足,无法完成取款。";
                }
            } else {
                echo "用户名或密码错误,请重新输入。";
            }
        }
        
        // 关闭连接
        $conn->close();
    ?>

    <h2>取款表单</h2>
    <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]);?>">
        用户名:<input type="text" name="username"><br><br>
        密码:<input type="password" name="password"><br><br>
        取款金额:<input type="number" name="amount"><br><br>
        <input type="submit" name="submit" value="提交">
    </form>
</body>
</html>

然后我发现不行啊,我还以为 Mysqli 是自带事物锁的,没有办法同时查询。。。。。(其实不是,是因为 PHP 是单线程的。。。。)
没办法,又让 AI 给我写了个不带事务锁的(我以为的),我不查数据库了,我查文件,哈哈哈。
image

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>取款</title>
</head>
<body>
    <?php
        // 定义文件路径
        $file_path = "user_info.txt";
        
        // 处理表单
        if ($_SERVER["REQUEST_METHOD"] == "POST") {
            // 获取表单数据
            $username = $_POST["username"];
            $password = $_POST["password"];
            $amount = $_POST["amount"];
            
            // 读取文件中的用户数据
            $user_info = file_get_contents($file_path);
            $user_info_array = explode(";", $user_info);
            $balance = "";
            foreach ($user_info_array as $user) {
                $user_info_detail = explode(",", $user);
                if ($user_info_detail[0] == $username && $user_info_detail[1] == $password) {
                    $balance = $user_info_detail[2];
                    break;
                }
            }
            
            // 检查用户名和密码是否正确
            if ($balance !== "") {
                // 将余额从字符串转化为数字
                $balance = intval($balance);
                // 检查余额是否充足
                if ($balance >= $amount) {
                    // 扣除余额并更新文件
                    $new_balance = $balance - $amount;
                    for ($i = 0; $i < count($user_info_array); $i++) {
                        $user_info_detail = explode(",", $user_info_array[$i]);
                        if ($user_info_detail[0] == $username && $user_info_detail[1] == $password) {
                            $user_info_array[$i] = "$username,$password,$new_balance";
                            break;
                        }
                    }
                    
                    // 将更新后的用户信息保存到文件中
                    $new_user_info = implode(";", $user_info_array);
                    file_put_contents($file_path, $new_user_info);
                    
                    echo "取款成功!";
                    
                    // 写入日志文件
                    $timestamp = date("Y-m-d H:i:s");
                    $log_message = "$timestamp: $username 取出 $amount 元。\n";
                    $log_file = fopen("log.txt", "a");
                    fwrite($log_file, $log_message);
                    fclose($log_file);
                } else {
                    echo "余额不足,无法完成取款。";
                }
            } else {
                echo "用户名或密码错误,请重新输入。";
            }
        }
    ?>

    <h2>取款表单</h2>
    <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]);?>">
        用户名:<input type="text" name="username"><br><br>
        密码:<input type="password" name="password"><br><br>
        取款金额:<input type="number" name="amount"><br><br>
        <input type="submit" name="submit" value="提交">
    </form>
</body>
</html>

不错不错,ChatGPT 完美实现了我的要求,但是我测试发现,咋回事呢,还是没法复习并发漏洞,而且我把 sleep 调高了之后发现,PHP 竟然是单线程执行的(我用 php -S 0.0.0.0:88989 开启的web服务器)
然后 ChatGPT 也回答了我的问题
image

啊,这。。。。
好吧,我换用 Python 的 Flask 去搭建环境吧(我以前学过 Flask ,而且 Flask 比 Django 简单的多)

image

image

app.py

# 导入 Flask 库
from datetime import datetime

from flask import Flask, render_template, request

# 创建 Flask 应用
app = Flask(__name__)

# 定义文件路径
file_path = "user_info.txt"


# 定义首页
@app.route('/')
def index():
    return render_template('index.html')


# 处理取款请求
@app.route('/withdraw', methods=['POST'])
def withdraw():
    # 获取表单数据
    username = request.form['username']
    password = request.form['password']
    amount = int(request.form['amount'])

    # 读取文件中的用户信息
    with open(file_path, 'r') as f:
        user_info = f.read()

    user_info_array = user_info.split(';')
    balance = -1

    # 查找指定用户的余额
    for user in user_info_array:
        user_info_detail = user.split(',')
        if user_info_detail[0] == username and user_info_detail[1] == password:
            balance = int(user_info_detail[2])
            break

    # 检查用户名和密码是否正确
    if balance != -1:
        # 检查余额是否充足
        if balance >= amount:
            # 更新用户余额
            for i in range(len(user_info_array)):
                user_info_detail = user_info_array[i].split(',')
                if user_info_detail[0] == username and user_info_detail[1] == password:
                    user_info_array[i] = f'{username},{password},{balance - amount}'
                    break

            # 将更新后的用户信息保存到文件中
            new_user_info = ';'.join(user_info_array)
            with open(file_path, 'w') as f:
                f.write(new_user_info)

            # 输出提示信息
            message = f'{username} 取款 {amount} 成功!'

            # 写入日志文件
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            log_message = f'{timestamp}: {username} 取出 {amount} 元。\n'
            with open('log.txt', 'a') as f:
                f.write(log_message)
        else:
            message = '余额不足,无法完成取款。'
    else:
        message = '用户名或密码错误,请重新输入。'

    return render_template('withdraw.html', message=message)


if __name__ == '__main__':
    app.run(host="192.168.0.66", debug=True)

index.html

<!DOCTYPE html>
<html>
<head>
	<title>取款系统</title>
	<meta charset="utf-8">
</head>
<body>
	<h1>取款系统</h1>
	<form action="{{ url_for('withdraw') }}" method="post">
		<label>用户名:</label>
		<input type="text" name="username"><br><br>
		<label>密码:</label>
		<input type="password" name="password"><br><br>
		<label>取款金额:</label>
		<input type="number" name="amount"><br><br>
		<input type="submit" value="提交">
	</form>
</body>
</html>

withdraw.html

<!DOCTYPE html>
<html>
<head>
	<title>取款结果</title>
	<meta charset="utf-8">
</head>
<body>
	<h1>{{ message }}</h1>
	<a href="{{ url_for('index') }}">返回</a>
</body>
</html>

文件结构:
image

ChatGPT 永远的神!!!太 NB!了,我要失业了。

并发测试

我们给用户设置 100 块的余额
image

然后 10 个线程同时开跑
image
然后发现取出来了 200 元
image

整体逻辑如下:
image

导致漏洞的原因就是我们访问文件的时候没有加锁,导致了第一个进程还没有修改完余额的时候,后面已经有进程又读取了文件,拿到了还没来得及修改的余额。

然后我再这里加了1秒的延迟
image

我们在这里加上 1 秒的延迟,模拟业务处理的比较慢,记得重启服务器。
可以发现一次性能取出来好多。基本上 100 块能稳定取出来很多很多了。
image

image

修复

TMD 还能一键修复
image
直接让 ChatGPT 给加了个锁,不过是个悲观锁,可能会影响性能。

from flask import Flask, render_template, request
from datetime import datetime
import threading
import time

app = Flask(__name__)
file_path = "user_info.txt"
lock = threading.Lock()


@app.route('/')
def index():
    return render_template('index.html')


@app.route('/withdraw', methods=['POST'])
def withdraw():
    username = request.form['username']
    password = request.form['password']
    amount = int(request.form['amount'])

    # 加锁
    lock.acquire()

    try:
        user_info = read_user_info()
        time.sleep(1)

        balance = -1
        for user in user_info:
            user_info_detail = user.split(',')
            if user_info_detail[0] == username and user_info_detail[1] == password:
                balance = int(user_info_detail[2])
                break

        if balance != -1:
            if balance >= amount:
                new_balance = balance - amount
                for i in range(len(user_info)):
                    user_info_detail = user_info[i].split(',')
                    if user_info_detail[0] == username and user_info_detail[1] == password:
                        user_info[i] = f'{username},{password},{new_balance}'
                        break

                write_user_info(user_info)

                message = f'{username} 取款 {amount} 成功!'

                timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                log_message = f'{timestamp}: {username} 取出 {amount} 元。\n'
                with open('log.txt', 'a') as f:
                    f.write(log_message)
            else:
                message = '余额不足,无法完成取款。'
        else:
            message = '用户名或密码错误,请重新输入。'
    finally:
        # 释放锁
        lock.release()

    return render_template('withdraw.html', message=message)


def read_user_info():
    with open(file_path, 'r') as f:
        user_info = f.read().split(';')
    return user_info


def write_user_info(user_info):
    new_user_info = ';'.join(user_info)
    with open(file_path, 'w') as f:
        f.write(new_user_info)


if __name__ == '__main__':
    app.run(debug=True)

其他

然后关于并发漏洞的测试还涉及到: 无锁无事务时的竞争攻击、无锁有事务时的竞争攻击、悲观锁加事务防御Race Condition、乐观锁加事务防御Race Condition 这些大家就去 P 牛的文章里去看吧。