PHP 驱动的自动化安全审计:利用 LLM 自动扫描 PHP 逻辑中的越权访问漏洞

各位老铁,大家下午好。坐得端正一点,别把那杯咖啡洒在键盘上,刚才那位穿格子衬衫的大哥,把你脚从桌子上拿下来,你的脚趾头都快戳到主机箱散热孔了。

今天我们不聊什么“PHP 是世界上最好的语言”这种毫无营养的口水话,我们来聊点硬核的。聊聊怎么让你的代码不变成黑客的提款机。我们的话题是:利用大模型(LLM)给 PHP 代码做全身 CT 扫描,专门找那些藏在逻辑里的“越权”漏洞。

你知道越权漏洞有多烦人吗?这是安全界的“幽灵”。它不像 SQL 注入那么露骨,不像 XSS 那么狂野,它就像是电影里的刺客,悄无声息,把你后台里那个叫“王总”的数据偷走,你还以为他在前台逛商品呢。

传统的安全扫描器,说白了就是个只会读死书的复读机。你给它看代码,它只会喊:“哎呀,这里有个参数没过滤!”“哎呀,这里有个 SQL 拼接!”至于这个参数会不会影响别人的数据?它会吗?它不会。它只有眼睛,没有脑子。

而今天我们要讲的主角——LLM,它有脑子,而且是大脑皮层很发达的那种。我们要把它变成一个“逻辑越权审计员”。这就好比以前你要把代码找遍,还要手动分析数据流,现在你把代码往 LLM 面前一扔,让它去思考,去猜,去发现那些“隐秘的角落”。

好,废话不多说,我们直接上代码,上干货。如果你听不懂,那是我的锅,不是你的锅。

第一部分:为什么 PHP 里的越权像是在玩“大家来找茬”

首先,咱们得搞清楚,PHP 的代码逻辑有多乱。很多老项目,那简直就是意大利面,面条 spaghetti。

举个例子,你要查订单。代码大概是这个样子:

// order.php
$orderId = $_GET['id'];
$user = getUserFromSession(); // 假设这是当前登录用户

// 获取订单详情
$order = $db->query("SELECT * FROM orders WHERE id = $orderId")->fetch();

// 输出结果
echo json_encode($order);

这时候,传统的扫描器跑一遍,拍拍屁股走了:“这里有个 $_GET['id'],参数不安全,打钩。” 然后它不管,反正它没发现 SQL 注入,就算过了。

但如果你是个黑客,你看到了什么?你看到了两个变量:$orderId$user。这个 $user 在这行代码里根本没被用到!这就是漏洞。这就是逻辑越权(IDOR)。我可以随便改 $_GET['id'],比如改成 99999,后台数据库里根本没这个订单,或者是个超级管理员的数据,但我能查到。

所以,我们的 LLM 审计系统要做的事情,就是盯着这个 $user,然后问它:“嘿,哥们,你拿着用户 ID 99 去查订单,但你查出来的订单里明明有用户 ID 1 的余额,你知不知道?” LLM 会回答:“哦,它没比对,它确实不知道。”

这就需要 AST(抽象语法树)和语义分析。但我们要用 LLM 来做这件事,怎么用?

第二部分:搭建“LLM 警探”的工作台

我们要写一个 PHP 脚本,这个脚本的作用就是:把 PHP 代码“翻译”给 LLM 听,然后把 LLM 的回答“翻译”回可执行的漏洞报告。

为了让 LLM 听懂,我们不能把整个几十万行的项目扔给它,太贵了,而且它也容易糊涂。我们得搞“切片”。

假设我们要审计 api/orders.php 文件。我们的核心流程是这样的:

  1. 代码提取:把相关逻辑段的代码抠出来。
  2. 上下文注入:告诉 LLM 这是一个“获取订单”的 API。
  3. Prompt 编写:用一种诱导性的语言问它。
  4. 结果解析:看它到底发现了什么。

来,看代码。我们用 Python 脚本来调用 LLM,毕竟 PHP 做网络请求处理起来稍微有点重,而且 Python 的 LLM 库多。

import openai

def audit_php_code(code_snippet, context):
    # 构造 Prompt
    system_prompt = """
    你是一名资深的 PHP 安全审计专家。你的任务是根据提供的 PHP 代码片段,分析是否存在逻辑越权漏洞。
    请特别关注:
    1. 输入参数是否直接用于查询或操作数据库,而未经过身份验证或权限检查。
    2. 查询结果是否包含当前用户无权访问的数据(如其他用户的信息)。
    3. 代码中是否存在依赖 Session 或 Cookie 的变量,却未与请求参数进行关联性检查。
    如果发现漏洞,请用 JSON 格式输出,包含漏洞类型、严重程度和利用思路。
    """

    user_prompt = f"""
    代码上下文:{context}
    待审计代码:
    ```php
    {code_snippet}
请分析是否存在 IDOR(不安全的直接对象引用)或其他逻辑越权风险。
"""

# 调用 LLM API (这里以 OpenAI 为例,你可以换成国内的 Claude 或者本地模型)
response = openai.ChatCompletion.create(
    model="gpt-4-turbo-preview",
    messages=[
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt}
    ]
)

return response.choices[0].message.content

模拟一段“坏”代码

bad_code = “””
$userId = $_GET[‘uid’]; // 用户传来的 ID
$query = “SELECT * FROM users WHERE id = $userId”;
$result = $db->query($query);
echo json_encode($result);
“””

audit_result = audit_php_code(bad_code, “这是一个查看用户详情的接口”)
print(audit_result)


你看,这行代码一跑,LLM 看到了 `$userId = $_GET['uid']`。它会想:“哎?这个 `$userId` 没有验证它是否属于当前登录用户啊!这就像你给了小偷一把钥匙,让他去偷邻居家的东西。” 它会返回类似这样的 JSON:
```json
{
  "vulnerability": "IDOR (Insecure Direct Object Reference)",
  "severity": "High",
  "exploit_logic": "攻击者可以通过修改 uid 参数查看任意用户信息,无需登录或无需验证当前用户身份。"
}

这时候,你的扫描器就活了。

第三部分:从“能看”到“能懂”——处理复杂的业务逻辑

但是,光查个 ID 查不到什么大不了的东西。真正的越权,往往藏在复杂的业务逻辑里。比如,转账限制。

假设你有这样一个函数,处理转账:

public function transferMoney($fromId, $toId, $amount) {
    $fromUser = $this->getUserById($fromId);
    $toUser = $this->getUserById($toId);

    // 只有管理员能操作
    if (!$this->isAdmin()) {
        die("Access Denied");
    }

    if ($fromUser->balance < $amount) {
        die("Insufficient Funds");
    }

    $fromUser->balance -= $amount;
    $toUser->balance += $amount;
    $this->db->save($fromUser);
    $this->db->save($toUser);
}

如果有人绕过了 isAdmin() 的检查呢?或者 isAdmin() 检查在某个地方失效了?

这时候,单纯的正则匹配就废了。我们得让 LLM 读懂业务逻辑。我们的脚本需要更智能。

我们要给 LLM 提供“调用栈”信息。比如,这个函数是被哪个 Controller 调用的,Controller 是否做了权限校验。

修改一下我们的 Prompt:

def audit_complex_logic(code, controller_check):
    system_prompt = "你是一个逻辑漏洞挖掘机。"
    user_prompt = f"""
    业务逻辑代码:
    {code}

    前置控制器中的权限检查代码:
    {controller_check}

    请分析:
    1. 如果前端传来的 $fromId 或 $toId 没有被控制器验证为当前登录用户,是否会导致越权转账?
    2. 如果控制器里的 $this->isAdmin() 检查存在拼写错误(例如写成了 $this->isAdmins),攻击者是否可以绕过?
    """
    # ... API 调用 ...

这就很厉害了。LLM 能看到 $this->isAdmin()。虽然它不会真的去改你的代码拼写错误,但它能敏锐地捕捉到:“嘿,这行代码如果少个 ‘s’,或者如果这里的条件永远为真,那就完蛋了。”

这就是深度语义分析。它不像静态分析工具(比如 SonarQube)那样死板地报错,它能产生“猜想”。

第四部分:实战演练——构建自动化审计链

好了,理论讲完了,咱们来搭个架子。假设我们要做一个 PHP 代码库的自动化审计。

我们要解决两个问题:

  1. 数据流追踪:怎么知道 $userId 是从哪里来的?
  2. 幻觉防御:LLM 偶尔会胡说八道(幻觉),我们不能把它的话当圣旨。

步骤 1:AST 解析器
我们用 PHPStan 或者类似的工具来生成 AST,或者直接用 PHP 内置的 token_get_all

<?php
// parse_code.php
$code = file_get_contents('vulnerable_code.php');

$tokens = token_get_all($code);

$dataFlow = [];
$contextStack = [];

foreach ($tokens as $token) {
    if (is_array($token)) {
        // 检测函数调用,如 $_GET, $_POST
        if ($token[0] == T_VARIABLE && $token[1] == '$_GET') {
            $contextStack[] = 'HTTP_PARAM';
        }

        // 简单的变量赋值模拟数据流
        if ($token[0] == T_VARIABLE && $contextStack) {
            $lastContext = array_pop($contextStack);
            $dataFlow[] = [
                'source' => $lastContext,
                'variable' => $token[1],
                'line' => $token[2]
            ];
        }
    }
}

// 打印数据流
echo "Data Flow Trace:n";
foreach ($dataFlow as $flow) {
    echo "Line {$flow['line']}: {$flow['source']} -> {$flow['variable']}n";
}

这段代码会把 $_GET['id'] 捕捉到变量 $id 上。我们把这个数据流信息作为 Prompt 的一部分发给 LLM。

步骤 2:Prompt 优化与上下文注入
我们把 AST 追踪的结果和源代码拼在一起,发给 LLM。

def generate_audit_prompt(ast_trace, source_code):
    prompt = f"""
    你正在审计一个 PHP Web 应用。

    数据流追踪记录:
    {ast_trace}

    核心源代码:
    {source_code}

    问题:
    1. 请检查上述数据流中的变量(如 $id, $uid)是否被用于数据库查询或对象访问。
    2. 请检查代码中是否存在用户可以直接控制该变量的入口点(如 URL 参数)。
    3. 如果用户可以控制该变量,请判断攻击者是否可以通过修改该变量访问其他用户的数据。

    请输出分析结果,如果存在漏洞,请详细描述攻击步骤。
    """
    return prompt

步骤 3:自动化执行
我们写一个循环,遍历项目中的 PHP 文件。

import os
import subprocess

def scan_project(directory):
    for root, dirs, files in os.walk(directory):
        for file in files:
            if file.endswith('.php'):
                filepath = os.path.join(root, file)
                # 1. 获取代码
                with open(filepath, 'r', encoding='utf-8') as f:
                    code = f.read()

                # 2. 解析 AST (这里简化处理,实际可用 PHP-Parser 库)
                # 假设我们有个函数叫 parse_ast,返回数据流
                ast_trace = parse_ast(code) 

                # 3. 生成 Prompt
                prompt = generate_audit_prompt(ast_trace, code)

                # 4. 发给 LLM
                result = call_llm_api(prompt)

                # 5. 处理结果
                if "漏洞" in result or "Vulnerability" in result:
                    print(f"发现潜在风险: {filepath}")
                    print(result)
                    print("-" * 50)

scan_project('./my_php_project')

第五部分:处理“高级”越权——水平与垂直越权

光查查用户信息不够,我们得学会区分“水平越权”(Level 1 能看 Level 2 的)和“垂直越权”(Level 1 管理员能看 Level 2 的)。

LLM 需要一点“身份”上下文。我们要告诉它,当前的请求是什么角色发的。

def audit_with_roles(code, current_role, target_roles):
    prompt = f"""
    当前请求的 User Role: {current_role}
    目标系统中存在的角色: {target_roles}

    代码片段:
    {code}

    分析:
    1. 如果 $userId 被传递到代码中,代码是否会根据 current_role 拦截 $userId != current_user_id 的情况?
    2. 对于 {current_role},是否被允许访问 {target_roles} 的数据?
    """
    # ... API call ...

举个例子,如果是“普通用户”角色去访问“管理员”的数据,LLM 会盯着权限检查那几行代码看。如果发现只有一行注释说“TODO: 加权限检查”,那 LLM 就会高兴地报警:“嘿!这里少了个 if 语句!”

第六部分:LLM 的局限性——我们得留一手

别把 LLM 神化了。它不是神,它也是个容易犯错的实习生。

  1. 幻觉:它有时候会一本正经地胡说八道。比如它可能会说“第 50 行有个漏洞”,但实际上根本没那行。所以,我们的脚本必须有“置信度评分”机制。
  2. 代码缩进和格式:LLM 很讨厌缩进不对的代码。如果你的代码全是制表符和空格混用,LLM 会晕头转向。所以在喂给 LLM 之前,必须用 phpcbf(PHP Code Beautifier)标准化一下格式。
  3. Token 限制:如果文件太大,LLM 会截断。这时候就要切文件,或者用 RAG(检索增强生成),只把相关的片段喂给它。

第七部分:模拟攻击——让 LLM 变成攻击者

除了审计,我们还能让 LLM 变成攻击者。这就是“对抗性生成”。

如果我们发现了一个 IDOR 漏洞,我们能不能让 LLM 自动构造 POC(概念验证)脚本?

def generate_exploit_payload(code_snippet):
    prompt = f"""
    代码片段显示了以下逻辑漏洞:通过修改 URL 参数可以获取任意用户信息。

    请用 Python 编写一个简单的脚本,利用 Python requests 库,向目标 API 发送请求,获取当前登录用户的 ID,然后尝试修改参数获取管理员 ID 的数据。
    """
    # 获取脚本
    exploit_script = call_llm_api(prompt)

    # 执行脚本 (注意:在实际环境中要小心,最好在沙箱里跑)
    # exec(exploit_script) 
    return exploit_script

这就从“被动审计”变成了“主动攻击模拟”。如果你能自动生成脚本并跑通,那这个漏洞基本就实锤了。

第八部分:部署与监控

光有个脚本还不行,得部署起来。你可以把它集成到 CI/CD 流水线里。

每当开发人员提交代码,git commit 一触发,Jenkins 就跑一下我们的 LLM 审计脚本。如果 LLM 报了错,代码就别想合并。

这就叫“安全左移”。别等到上线被黑了才哭爹喊娘。

结束语:代码是写给人看的,漏洞是写给黑客看的

最后,我想说点掏心窝子的话。

PHP 代码为什么容易有漏洞?因为它是胶水语言,它是为了快为了方便。很多老程序员,为了赶进度,eval() 一把梭,include 一把梭,extract($_GET) 一把梭。

用 LLM 来审计,其实就是利用 AI 的通用性,去填补人类对复杂业务逻辑理解的盲区。人类程序员容易疲劳,容易想当然,容易以为“这个检查肯定在前面了”。但 LLM 可以不知疲倦地盯着每一行代码。

当然,工具只是工具。最安全的防线,永远是那个坐在显示器前,手里拿着咖啡,眼神犀利,正在检查代码的你。LLM 只能告诉你哪里有坑,能不能跳过去,还得你自己决定跳不跳。

好了,今天的讲座就到这里。大家回去记得把代码格式化一下,别让 LLM 看不懂你的代码而发火。如果你们项目里还有那种“用户 ID 直接传给后台”的接口,赶紧改了。散会!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注