圖片木馬(Polyglot / Stego WebShell)利用圖片格式容錯特性,將 PHP/ASP/JSP 腳本注入圖片文件(如 JPEG 的 APP 段、PNG 的 tEXt/iTXt 塊、GIF 的註釋塊)。上傳後,若伺服器配置不當(如 AddHandler php-script .jpg 或解析漏洞),可直接執行;或通過 include、require、imagecreatefromjpeg 等函數觸發二次渲染執行。
| 層級 | 手段 | 原理 | 攔截率 | 誤報率 |
|---|---|---|---|---|
| 1. 文件頭驗證 | Magic Bytes 檢查 | 確認真實格式,防偽裝副檔名 | 高 | 低 |
| 2. 結構完整性 | GD/Imagick 讀取測試 | 損壞/畸形圖片無法正常解碼 | 中 | 低 |
| 3. 特徵掃描 | 十六進制模式匹配 | 檢測 <?php、<%、<script 等腳本標籤 | 高 | 中 |
| 4. 重繪凈化 | GD 重新繪製保存 | 剝離所有非圖像數據(元數據、隱寫、殘留代碼) | 極高 | 極低 |
| 5. 存儲隔離 | 非 Web 目錄、隨機文件名、禁用執行權限 | 即使漏網,無法直接訪問執行 | 根本 | 無 |
<?php
class SecureImageUpload {
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP];
const ALLOWED_EXT = ['gif', 'jpg', 'jpeg', 'png', 'webp'];
const SCAN_CHUNK = 8192; // 掃描塊大小
// 十六進制惡意模式: <% %> (不區分大小寫)
private static $hexPatterns = [
// ... ?> 3C 3F ... 3F 3E
'/3c3f.{0,100}?3f3e/is',
// <% ... %> 3C 25 ... 25 3E
'/3c25.{0,100}?253e/is',
// 3C 2F 53 43 52 49 50 54 3E
'/3c2f5343524950543e/is',
// eval( 65 76 61 6C 28
'/6576616c28/is',
// assert( 61 73 73 65 72 74 28
'/61737365727428/is',
// base64_decode( 62 61 73 65 36 34 5F 64 65 63 6F 64 65 28
'/6261736536345f6465636f646528/is',
];
public static function upload(array $file, string $saveDir): array {
// 1. 基礎驗證
$err = self::validateBase($file);
if ($err) return ['success' => false, 'msg' => $err];
// 2. 文件頭驗證
$type = self::detectTrueType($file['tmp_name']);
if ($type === false || !in_array($type, self::ALLOWED_TYPES)) {
return ['success' => false, 'msg' => '文件類型不合法或偽裝'];
}
// 3. GD 讀取測試(結構完整性)
$im = @self::createImageResource($file['tmp_name'], $type);
if (!$im) {
return ['success' => false, 'msg' => '圖片損壞或格式錯誤'];
}
imagedestroy($im);
// 4. 十六進制特徵掃描
if (self::scanHex($file['tmp_name'])) {
return ['success' => false, 'msg' => '檢測到疑似腳本代碼特徵'];
}
// 5. GD 重繪凈化(核心防禦)
$im = self::createImageResource($file['tmp_name'], $type);
$w = imagesx($im);
$h = imagesy($im);
$cleanIm = imagecreatetruecolor($w, $h);
// 保持透明度
if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_GIF || $type === IMAGETYPE_WEBP) {
imagealphablending($cleanIm, false);
imagesavealpha($cleanIm, true);
$transparent = imagecolorallocatealpha($cleanIm, 255, 255, 255, 127);
imagefill($cleanIm, 0, 0, $transparent);
}
imagecopyresampled($cleanIm, $im, 0, 0, 0, 0, $w, $h, $w, $h);
imagedestroy($im);
// 6. 安全保存
$ext = self::ALLOWED_EXT[array_search($type, self::ALLOWED_TYPES)];
$fileName = bin2hex(random_bytes(16)) . '.' . $ext;
$savePath = rtrim($saveDir, '/') . '/' . $fileName;
if (!is_dir($saveDir)) {
mkdir($saveDir, 0755, true);
}
$saved = self::saveImage($cleanIm, $savePath, $type);
imagedestroy($cleanIm);
if (!$saved) {
return ['success' => false, 'msg' => '保存失敗'];
}
// 7. 設置權限:僅讀,無執行
@chmod($savePath, 0644);
return ['success' => true, 'path' => $savePath, 'name' => $fileName];
}
private static function validateBase(array $file): ?string {
if ($file['error'] !== UPLOAD_ERR_OK) {
return '上傳錯誤代碼: ' . $file['error'];
}
if ($file['size'] > self::MAX_SIZE) {
return '文件過大';
}
if (!is_uploaded_file($file['tmp_name'])) {
return '非合法上傳文件';
}
return null;
}
private static function detectTrueType(string $path): ?int {
$info = @getimagesize($path);
return $info ? $info[2] : false;
}
private static function createImageResource(string $path, int $type) {
switch ($type) {
case IMAGETYPE_GIF: return @imagecreatefromgif($path);
case IMAGETYPE_JPEG: return @imagecreatefromjpeg($path);
case IMAGETYPE_PNG: return @imagecreatefrompng($path);
case IMAGETYPE_WEBP: return @imagecreatefromwebp($path);
default: return false;
}
}
private static function saveImage($im, string $path, int $type): bool {
switch ($type) {
case IMAGETYPE_GIF: return imagegif($im, $path);
case IMAGETYPE_JPEG: return imagejpeg($im, $path, 90);
case IMAGETYPE_PNG: return imagepng($im, $path, 6);
case IMAGETYPE_WEBP: return imagewebp($im, $path, 90);
default: return false;
}
}
/** 掃描文件頭尾各 8KB 十六進制特徵 */
private static function scanHex(string $path): bool {
$size = filesize($path);
if ($size === 0) return true; // 空文件視為可疑
$fp = fopen($path, 'rb');
$hex = '';
// 讀取頭部
$headSize = min(self::SCAN_CHUNK, $size);
$hex .= bin2hex(fread($fp, $headSize));
// 讀取尾部(若文件較大)
if ($size > self::SCAN_CHUNK * 2) {
fseek($fp, -self::SCAN_CHUNK, SEEK_END);
$hex .= bin2hex(fread($fp, self::SCAN_CHUNK));
} elseif ($size > self::SCAN_CHUNK) {
// 中等文件:讀取剩餘部分
$hex .= bin2hex(fread($fp, $size - $headSize));
}
fclose($fp);
// 多模式匹配
foreach (self::$hexPatterns as $pattern) {
if (preg_match($pattern, $hex)) {
error_log("[SecureUpload] Hex pattern matched: $pattern in $path");
return true; // 發現可疑
}
}
return false;
}
}
// 使用範例
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['image'])) {
$result = SecureImageUpload::upload($_FILES['image'], __DIR__ . '/uploads/images');
header('Content-Type: application/json');
echo json_encode($result);
}
?>
imagecreatefrom* 只解析圖像數據(像素),忽略所有非圖像塊(註釋、EXIF、隱寫數據、追加代碼)。imagecopyresampled 將純像素繪製到新畫布,image* 保存時僅寫入標準圖像數據。任何非像素內容全部丟失。
bin2hex 將二進制轉為十六進制字串,避免多位元組編碼干擾3c3f.{0,100}?3f3e 匹配 <? ... ?>{0,100})防止回溯災難| 攻擊手法 | 原理 | 對策 |
|---|---|---|
| 圖像數據域隱寫 (LSB) | 修改像素最低有效位藏匿代碼 | 重繪時重新量化像素,LSB 信息丟失 |
| PNG tEXt/iTXt/zTXt 塊注入 | 標準元數據塊存放腳本 | GD 讀取時忽略文本塊,重繪不保留 |
| JPEG APP 段注入 | APP0-APP15 區段藏代碼 | 同理,GD 僅解碼掃描數據 |
| 多格式 Polyglot | 同時是合法圖片和合法腳本 | 重繪破壞腳本結構;存儲隔離防執行 |
| 編碼混淆 (Base64, Rot13, XOR) | 腳本經編碼存放,運行時解碼 | 掃描常見編碼函數特徵;重繪徹底清除 |
/var/www/data/uploads 而非 /var/www/html/uploads)# Nginx
location /uploads/ {
location ~ \.(php|php5|phtml|asp|jsp)$ { deny all; return 403; }
}
# Apache .htaccess
Require all denied
bin2hex(random_bytes(16))),無副檔名執行風險0644,目錄 0755,無執行位單一檢測手段皆可被繞過。**GD 重繪凈化 + 十六進制特徵掃描 + 存儲隔離 + 執行權限禁用**構成纵深防禦體系。上述 SecureImageUpload 類整合了核心邏輯,可直接投入生產。記住:上傳安全的本質是「不信任任何客戶端數據」,在伺服端徹底中性化處理後再存儲。