深入理解 `wp_check_filetype()` 函数的源码,它如何通过文件头信息而不是扩展名来判断文件类型?

早啊,各位!今天咱们聊聊 WordPress 里的一个幕后英雄:wp_check_filetype()。 别看它名字平平无奇,但它可是确保你上传的文件没耍花招的关键先生。

一、 为什么单靠扩展名不靠谱?

在深入源码之前,先来聊聊为什么 WordPress 不仅仅依靠文件扩展名来判断文件类型。 想象一下,你把一个 .exe 文件改成 .jpg 扩展名,如果 WordPress 仅仅看扩展名,那岂不是直接让你上传并运行恶意代码了? 这简直是给黑客开了方便之门!

所以,扩展名这种东西太容易伪造了,简直就是个“伪君子”。我们需要更靠谱的依据,那就是文件内容本身。

二、 文件头(Magic Numbers):真正的身份证明

每个文件类型,在文件的开头部分,都有一些特定的字节序列,这就是所谓的“Magic Numbers”或者“文件头”。 它们就像文件的指纹,是文件类型的真正身份证明。

举个例子:

  • JPEG 文件通常以 FF D8 FF 开头
  • PNG 文件通常以 89 50 4E 47 0D 0A 1A 0A 开头
  • GIF 文件通常以 GIF87aGIF89a 开头

wp_check_filetype() 的核心思想就是:检查文件的开头部分,看看它是否符合已知文件类型的 Magic Numbers。

三、 wp_check_filetype() 源码剖析

接下来,咱们深入到 wp-includes/functions.php 文件中,看看 wp_check_filetype() 的源码:

function wp_check_filetype( $filename, $mimes = null ) {
    if ( empty( $mimes ) ) {
        $mimes = get_allowed_mime_types();
    }

    $file = wp_check_filetype_and_ext( $filename, null, $mimes );

    if ( $file['ext'] === false ) {
        return apply_filters( 'wp_check_filetype', array( 'ext' => false, 'type' => false, 'proper_filename' => false ), $filename, $mimes );
    }

    return apply_filters( 'wp_check_filetype', $file, $filename, $mimes );
}

乍一看,wp_check_filetype() 自己并没有做什么特别复杂的事情,它实际上是调用了 wp_check_filetype_and_ext() 函数。

所以,关键在于 wp_check_filetype_and_ext() 函数。继续深入:

function wp_check_filetype_and_ext( $filename, $file = null, $mimes = null ) {
    $proper_filename = false;

    if ( empty( $mimes ) ) {
        $mimes = get_allowed_mime_types();
    }

    $type = false;
    $ext  = false;

    // Get file extension from filename.
    $original_ext = pathinfo( $filename, PATHINFO_EXTENSION );

    if ( $original_ext ) {
        $original_ext = strtolower( $original_ext );
    }

    // Check for file extension overrides.
    $ext = apply_filters( 'wp_check_filetype_and_ext', $ext, $filename, $file, $mimes, $original_ext );

    // If extension overrides are provided, short-circuit the rest of the tests.
    if ( ! empty( $ext ) ) {
        $type = $mimes[ $ext ];
        return compact( 'ext', 'type', 'proper_filename' );
    }

    if ( ! empty( $file ) ) {
        $imgsize = @getimagesize( $file );
        if ( ! empty( $imgsize['mime'] ) ) {
            $type = $imgsize['mime'];
        }
    }

    if ( false === $type && function_exists( 'finfo_open' ) ) {
        $finfo = finfo_open( FILEINFO_MIME_TYPE );
        if ( $finfo ) {
            $type = finfo_file( $finfo, $file );
            finfo_close( $finfo );
        }
    }

    // Check MIME type against allowed types.
    $allowed = false;
    foreach ( $mimes as $ext_pattern => $mime_type ) {
        $pattern = '/.' . preg_quote( $ext_pattern, '/' ) . '$/i';
        if ( preg_match( $pattern, $filename ) && $mime_type === $type ) {
            $allowed = true;
            break;
        }
    }

    if ( ! $allowed ) {
        $type = false;
        $ext  = false;
    }

    // Look for proper extension based on MIME type.
    if ( $type ) {
        foreach ( $mimes as $ext_pattern => $mime_type ) {
            if ( $mime_type === $type ) {
                $ext = $ext_pattern;
                break;
            }
        }
    }

    return compact( 'ext', 'type', 'proper_filename' );
}

咱们把这个函数拆解成几个关键步骤:

  1. 获取允许的 MIME 类型:

    if ( empty( $mimes ) ) {
        $mimes = get_allowed_mime_types();
    }

    首先,它会获取 WordPress 允许上传的所有 MIME 类型。 这些类型存储在一个关联数组 $mimes 中,其中键是文件扩展名,值是 MIME 类型。 例如:

    array(
        'jpg|jpeg|jpe' => 'image/jpeg',
        'png'          => 'image/png',
        'gif'          => 'image/gif',
        'pdf'          => 'application/pdf',
        // ... 更多类型
    );

    get_allowed_mime_types() 函数定义了这些允许的 MIME 类型。 你可以通过 upload_mimes 过滤器来修改允许的 MIME 类型列表。

  2. 提取原始扩展名:

    $original_ext = pathinfo( $filename, PATHINFO_EXTENSION );
    if ( $original_ext ) {
        $original_ext = strtolower( $original_ext );
    }

    从文件名中提取扩展名,并转换为小写。 记住,这只是一个参考,不能完全信任。

  3. 检查扩展名覆盖:

    $ext = apply_filters( 'wp_check_filetype_and_ext', $ext, $filename, $file, $mimes, $original_ext );
    
    // If extension overrides are provided, short-circuit the rest of the tests.
    if ( ! empty( $ext ) ) {
        $type = $mimes[ $ext ];
        return compact( 'ext', 'type', 'proper_filename' );
    }

    这里使用了一个过滤器 wp_check_filetype_and_ext,允许开发者通过插件或主题来覆盖文件类型检查逻辑。 如果过滤器返回了扩展名,则直接使用该扩展名,并跳过后续的检查。

  4. 使用 getimagesize() 获取图像类型:

    if ( ! empty( $file ) ) {
        $imgsize = @getimagesize( $file );
        if ( ! empty( $imgsize['mime'] ) ) {
            $type = $imgsize['mime'];
        }
    }

    如果上传的是图像文件,并且提供了文件路径,那么 getimagesize() 函数可以读取图像的文件头信息,并返回图像的 MIME 类型。 这是一个快速且常用的方法来判断图像类型。 注意 @ 符号用于抑制可能出现的错误。

  5. 使用 finfo_open() 获取文件类型:

    if ( false === $type && function_exists( 'finfo_open' ) ) {
        $finfo = finfo_open( FILEINFO_MIME_TYPE );
        if ( $finfo ) {
            $type = finfo_file( $finfo, $file );
            finfo_close( $finfo );
        }
    }

    如果 getimagesize() 无法确定文件类型,并且服务器支持 finfo 扩展,那么 finfo_open() 函数会尝试读取文件的 Magic Numbers,并返回文件的 MIME 类型。 finfo 扩展提供了更强大的文件类型检测能力,可以识别更多类型的文件。

  6. 验证 MIME 类型是否允许:

    $allowed = false;
    foreach ( $mimes as $ext_pattern => $mime_type ) {
        $pattern = '/.' . preg_quote( $ext_pattern, '/' ) . '$/i';
        if ( preg_match( $pattern, $filename ) && $mime_type === $type ) {
            $allowed = true;
            break;
        }
    }
    
    if ( ! $allowed ) {
        $type = false;
        $ext  = false;
    }

    将通过 getimagesize()finfo_open() 获取的 MIME 类型与允许的 MIME 类型列表进行比较。 如果找到匹配的 MIME 类型,并且文件名也符合扩展名模式,那么就认为该文件类型是允许的。

  7. 查找正确的扩展名:

    if ( $type ) {
        foreach ( $mimes as $ext_pattern => $mime_type ) {
            if ( $mime_type === $type ) {
                $ext = $ext_pattern;
                break;
            }
        }
    }

    根据确定的 MIME 类型,查找对应的扩展名。

四、 get_allowed_mime_types() 做了什么?

现在我们来看看get_allowed_mime_types()这个函数,它定义了WordPress默认允许上传的文件类型:

function get_allowed_mime_types( $user = null ) {
    $t = array(
        'jpg|jpeg|jpe'                 => 'image/jpeg',
        'gif'                          => 'image/gif',
        'png'                          => 'image/png',
        'bmp'                          => 'image/bmp',
        'tiff|tif'                     => 'image/tiff',
        'ico'                          => 'image/x-icon',
        'asf|asx|wax|wmv|wmx'          => 'video/asf',
        'avi'                          => 'video/avi',
        'divx'                         => 'video/divx',
        'mov|qt'                       => 'video/quicktime',
        'mpeg|mpg|mpe'                 => 'video/mpeg',
        'mp4|m4v'                      => 'video/mp4',
        'ogv'                          => 'video/ogg',
        'webm'                         => 'video/webm',
        'mkv'                          => 'video/x-matroska',
        '3gp|3gpp'                     => 'video/3gpp', // Can also be audio
        '3g2|3gp2'                     => 'video/3gpp2', // Can also be audio
        'txt|asc|c|cc|h'               => 'text/plain',
        'csv'                          => 'text/csv',
        'rtx'                          => 'text/richtext',
        'css'                          => 'text/css',
        'htm|html'                     => 'text/html',
        'vtt'                          => 'text/vtt',
        'dfxp'                         => 'application/ttaf+xml',
        'mp3|m4a|m4b'                  => 'audio/mpeg',
        'aac'                          => 'audio/aac',
        'ra|ram'                       => 'audio/x-realaudio',
        'wav'                          => 'audio/wav',
        'ogg|oga'                      => 'audio/ogg',
        'flac'                         => 'audio/flac',
        'mid|midi'                     => 'audio/midi',
        'wma'                          => 'audio/x-ms-wma',
        'wax'                          => 'audio/x-ms-wax',
        'mka'                          => 'audio/x-matroska',
        'rtf'                          => 'application/rtf',
        'js'                           => 'application/javascript',
        'pdf'                          => 'application/pdf',
        'odt'                          => 'application/vnd.oasis.opendocument.text',
        'pptx'                         => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
        'docx'                         => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        'xlsx'                         => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        'key|keynote'                  => 'application/vnd.apple.keynote',
        'pps|ppt'                      => 'application/vnd.ms-powerpoint',
        'odp'                          => 'application/vnd.oasis.opendocument.presentation',
        'ods'                          => 'application/vnd.oasis.opendocument.spreadsheet',
        'odg'                          => 'application/vnd.oasis.opendocument.graphics',
        'wri'                          => 'application/x-mswrite',
        'eml|msg'                      => 'message/rfc822',
        'exe|com|bin'                  => 'application/x-msdownload',
        'zip|gz|tar|rar|7z'            => 'application/zip',
        'psd'                          => 'image/vnd.adobe.photoshop',
        'ai'                           => 'application/postscript',
        'mpkg'                         => 'application/vnd.apple.installer+xml',
        'epub'                         => 'application/epub+zip',
        'woff|woff2'                   => 'font/woff',
        'eot'                          => 'application/vnd.ms-fontobject',
        'svg'                          => 'image/svg+xml',
        'jar|war'                      => 'application/java-archive',
        'ttf'                          => 'font/ttf',
    );

    /**
     * Filters the list of allowed mime types and file extensions.
     *
     * @since 2.0.0
     *
     * @param string[] $mime_types Key is the file extension with the leading full stop,
     *                           value is the mime type.
     * @param WP_User|null $user       The user object, if the user is logged in. `null` if is not.
     */
    return apply_filters( 'upload_mimes', $t, $user );
}

这个函数返回一个数组,包含了 WordPress 默认允许上传的文件类型。 数组的键是文件扩展名(支持多个扩展名用 | 分隔),值是对应的 MIME 类型。

重点:你可以使用 upload_mimes 过滤器来添加或删除允许上传的文件类型。 这为你的网站提供了极大的灵活性。

五、 绕过文件类型检查?没那么容易!

尽管 wp_check_filetype() 已经做了很多工作,但仍然有一些绕过文件类型检查的方法。 例如:

  • MIME 类型混淆: 某些文件类型可能具有相同的 MIME 类型。 例如,.3gp 文件可以是视频或音频文件。
  • 双重扩展名: 使用类似 filename.jpg.php 的文件名,服务器可能会将其解析为 PHP 文件。
  • 文件头注入: 在文件的开头添加合法的图像文件头,然后在后面添加恶意代码。

然而,WordPress 也在不断加强安全措施来防止这些攻击。 例如,WordPress 会:

  • 使用 wp_get_image_editor() 来验证图像文件的完整性。
  • 禁止直接执行上传目录中的 PHP 文件。
  • 定期更新 WordPress 核心和插件,以修复安全漏洞。

六、 总结

wp_check_filetype() 函数是 WordPress 文件上传安全的重要组成部分。 它通过检查文件的 Magic Numbers 和 MIME 类型,来判断文件的真实类型,防止恶意文件上传。

步骤 说明 使用函数/方法
1. 获取允许的 MIME 类型 获取 WordPress 允许上传的所有 MIME 类型列表。 get_allowed_mime_types()
2. 提取原始扩展名 从文件名中提取扩展名。 pathinfo()
3. 检查扩展名覆盖 允许开发者通过过滤器覆盖文件类型检查逻辑。 apply_filters( 'wp_check_filetype_and_ext' )
4. 使用 getimagesize() 如果是图像文件,尝试使用 getimagesize() 读取文件头信息并获取 MIME 类型。 getimagesize()
5. 使用 finfo_open() 如果 getimagesize() 失败,尝试使用 finfo_open() 读取文件的 Magic Numbers 并获取 MIME 类型。 finfo_open(), finfo_file(), finfo_close()
6. 验证 MIME 类型 将获取的 MIME 类型与允许的 MIME 类型列表进行比较,验证文件类型是否允许上传。 preg_match()
7. 查找正确的扩展名 根据确定的 MIME 类型,查找对应的扩展名。

掌握了 wp_check_filetype() 的工作原理,能让你更好地理解 WordPress 的安全机制,并为你的网站提供更可靠的保护。 当然,安全是一个持续的过程,需要不断学习和实践。 希望今天的讲座对你有所帮助! 咱们下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注