上周排查一个线上事故时,我发现攻击者通过构造$_GET['callback']()这样的调用,直接执行了系统命令。这让我重新审视PHP可变函数这个"特性"——它就像房间里的大象,每个PHPer都知道存在,却很少真正重视其危险性。
可变函数(Variable functions)允许通过字符串变量名动态调用函数,比如$func = 'system'; $func('whoami');。这种灵活性在框架路由、回调处理等场景很常见,但同时也打开了潘多拉魔盒。根据Snyk 2022年的报告,超过63%的PHP应用漏洞与动态代码执行相关,其中可变函数滥用占比高达37%。
最典型的漏洞模式如下:
php复制$action = $_GET['action'];
$action(); // 危险!
攻击者只需传入?action=system&argv=rm+-rf+/就能实现RCE。去年爆出的某流行CMS漏洞(CVE-2021-42342)正是这种模式。
很多PHP函数接受回调参数:
php复制array_map($_GET['func'], $data);
usort($array, 'system');
当回调参数未过滤时,攻击者可以注入任意函数。
__call()、__callStatic()等魔术方法配合可变函数,会产生链式反应。我曾遇到一个案例:
php复制class Logger {
public function __call($name, $args) {
$this->$name = $args; // 可变属性赋值
}
}
$logger->{$_POST['method']}($_POST['args']);
攻击者通过精心构造的POST数据最终实现了文件包含。
建议采用"允许名单"而非"禁止名单":
php复制$allowed = ['saveData', 'loadConfig'];
if (in_array($_GET['action'], $allowed, true)) {
$action = $_GET['action'];
$action();
} else {
throw new InvalidArgumentException('非法操作');
}
注意:
strict=true进行严格比较htmlspecialchars()处理通过反射机制验证函数合法性:
php复制function safeCall(callable $func, array $args) {
$ref = new ReflectionFunction($func);
if ($ref->isInternal()) {
throw new RuntimeException('禁止调用内部函数');
}
// 检查参数数量/类型
return $func(...$args);
}
在php.ini中配置:
ini复制disable_functions = "system,exec,passthru,shell_exec"
但要注意:
dl()等函数绕过推荐使用Symfony的Process组件:
php复制use Symfony\Component\Process\Process;
$process = new Process(['/usr/bin/php', 'safe_script.php']);
$process->run();
这种方案:
php复制if (is_callable($_GET['func'])) { // 不安全!
$_GET['func']();
}
实际上is_callable()会返回true给:
php复制namespace App;
$func = '\system'; // 完全合法调用
$func('whoami');
解决方案:
php复制if (strpos($func, '\\') !== false) {
throw new SecurityException('禁止跨命名空间调用');
}
php复制// 以下两种形式都危险
$callback = ['System', 'exec'];
$callback = [$object, 'method'];
应用检查:
php复制if (is_array($callback) &&
(is_string($callback[0]) || !$callback[0] instanceof SafeObject)) {
throw new SecurityException('非法回调');
}
php复制class User {
public static function deleteAll() {
// 清空数据库...
}
}
$class = 'User';
$method = 'deleteAll';
$class::$method(); // 灾难性后果
php复制// unserialize可以触发__wakeup中的可变函数调用
$data = unserialize($_COOKIE['data']);
防护措施:
php复制ini_set('unserialize_callback_func', '');
建议实现调用追踪:
php复制function __call($name, $args) {
$log = sprintf(
"[%s] %s::%s(%s)",
date('Y-m-d H:i:s'),
__CLASS__,
$name,
json_encode($args)
);
file_put_contents('/var/log/php_calls.log', $log, FILE_APPEND);
// ...后续逻辑
}
日志分析要点:
对于FPM模式可以:
nginx复制location ~ \.php$ {
fastcgi_param PHP_ADMIN_VALUE "disable_functions=exec,passthru";
}
CLI环境下建议:
bash复制php -d disable_functions=system -f script.php
通过PHP扩展实现:
c复制PHP_FUNCTION(safe_call) {
zend_string *func_name;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "S", &func_name) == FAILURE) {
RETURN_NULL();
}
if (zend_hash_exists(&dangerous_functions, func_name)) {
zend_error(E_WARNING, "危险函数调用被阻止");
RETURN_NULL();
}
// ...执行安全调用
}
去年在开发支付网关时,我实现了动态插件加载:
php复制$plugin = $_GET['module'].'Plugin';
$plugin::process($request);
结果遭遇了这样的攻击:
code复制?module=../../vendor/phar//恶意phar文件
教训是:
preg_match('/^[a-z]+$/i', $module)ini_set('phar.readonly', 1)另一个案例是使用array_filter时:
php复制array_filter($data, $_GET['filter']);
攻击者传入filter=assert就实现了代码执行。现在我强制使用闭包:
php复制array_filter($data, function($v) { return $v > 0; });
最隐蔽的一个漏洞是在JSONP回调中:
php复制header('Content-Type: application/javascript');
echo $_GET['callback'].'('.json_encode($data).')';
攻击者传入callback=eval;alert(1);//就实现了XSS。正确的做法:
php复制$callback = preg_replace('/[^a-z0-9_]/i', '', $_GET['callback']);
配置规则示例:
neon复制parameters:
dynamicFunctionCall: true
checkFunctionNameCase: true
excludeFunctions: ['defined']
使用PHP-Parser检测危险模式:
php复制$finder = new NodeFinder();
$dangerous = $finder->find($ast, function(Node $node) {
return $node instanceof FuncCall &&
$node->name instanceof Variable;
});
通过register_tick_function实现:
php复制declare(ticks=1);
function check_call() {
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
if (isset($backtrace[2]['function']) &&
in_array($backtrace[2]['function'], $blacklist)) {
throw new RuntimeException('危险调用');
}
}
register_tick_function('check_call');
在电商系统中,我们最终采用的方案是:
php复制private static $actions = [
'checkout' => [self::class, 'safeCheckout'],
'refund' => '\Secure\Payment::processRefund'
];
php复制$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse("<?php $func();");
if ($ast[0] instanceof Expression &&
$ast[0]->expr instanceof FuncCall &&
$ast[0]->expr->name instanceof Variable) {
throw new SecurityException('动态调用必须审核');
}
ini复制opcache.restrict_api=/var/www/safe_path
opcache.validate_permission=1