0x00、前言 在对php7环境下的普通文件上传,自动过滤了特殊符号,通过$_files[file][name]
获取到文件名引发的思考。
0x01、背景 测试环境: php:7.3.4
简单的文件上传源码
<html> <body> <form action="#" method="post" enctype="multipart/form-data" > <label for ="file" >Filename:</label> <input type="file" name="file" id="file" /> <br /> <input type="submit" name="submit" id="button" value="Submit" /> </form> </body> </html> <?php $str =$_POST ["str" ];echo $str ;if ($_FILES ["file" ]["error" ] > 0 ){ echo "Error: " . $_FILES ["file" ]["error" ] . "<br />" ; } else { echo "Upload: " . $_FILES ["file" ]["name" ] . "<br />" ; echo "Type: " . $_FILES ["file" ]["type" ] . "<br />" ; echo "Size: " . ($_FILES ["file" ]["size" ] / 1024 ) . " Kb<br />" ; echo "Stored in: " . $_FILES ["file" ]["tmp_name" ]; } ?>
文件上传过程中,$_files[file][name]
获取到/或者\后的文件名,前面的符号被自动过滤
换了不同的web应用,都是同样的情况,猜测php内核对文件上传的信息做了处理
0x02、rfc1867协议
RCF1867是Form-based File Upload in HTML标准协议,该协议在html基础上为input元素的type属性增加了一个file选项,同时限定了Form的method必须为POST,ENCTYPE必须为multipart/form-data
初始化 主体程序SAPI_POST_HANDLER_FUNC方法,主体前面是初始化,声明一些变量和相关函数
获取boundary 后面开始先获取boundary值
获取过程中对boundary进行合法性校验
if (!boundary || !(boundary = strchr (boundary, '=' ))) { sapi_module.sapi_error(E_WARNING, "Missing boundary in multipart/form-data POST data" ); return ; } boundary++; boundary_len = (int )strlen (boundary); if (boundary[0 ] == '"' ) { boundary++; boundary_end = strchr (boundary, '"' ); if (!boundary_end) { sapi_module.sapi_error(E_WARNING, "Invalid boundary in multipart/form-data POST data" ); return ; } } else { boundary_end = strpbrk (boundary, ",;" ); }
boundary获取后,对buffer流进行初始化
if (!(mbuff = multipart_buffer_new (boundary, boundary_len))) { sapi_module.sapi_error (E_WARNING, "Unable to initialize the input buffer" ); return ; }
接下来也是一些初始化工作
解析Content-Disposition 接下来就是解析multipart/form-data内容字段
声明了一些变量过后,开始解析头部数据
if (!multipart_buffer_headers (mbuff, &header)) { goto fileupload_done; }
读取Content-Disposition字段
if ((cd = php_mime_get_hdr_value(header, "Content-Disposition" ))) {
通过getword方法用”;”分割Content-Disposition字段值
while (*cd && (pair = getword (mbuff->input_encoding, &cd, ';' ))) { char *key = NULL , *word = pair; while (isspace (*cd)) { ++cd; }
存在键值对后,再通过getword方法用”=”分割键值对获取key名,然后判断Key值为”name”还是”filename”
if (strchr (pair , '=' )) { key = getword(mbuff->input_encoding, &pair , '=' ); if (!strcasecmp(key, "name" )) { if (param) { efree(param); } param = getword_conf(mbuff->input_encoding, pair ); if (mbuff->input_encoding && internal_encoding) { unsigned char *new_param; size_t new_param_len; if ((size_t )-1 != zend_multibyte_encoding_converter(&new_param, &new_param_len, (unsigned char *)param, strlen (param), internal_encoding, mbuff->input_encoding)) { efree(param); param = (char *)new_param; } } } else if (!strcasecmp(key, "filename" )) { if (filename) { efree(filename); } filename = getword_conf(mbuff->input_encoding, pair ); if (mbuff->input_encoding && internal_encoding) { unsigned char *new_filename; size_t new_filename_len; if ((size_t )-1 != zend_multibyte_encoding_converter(&new_filename, &new_filename_len, (unsigned char *)filename, strlen (filename), internal_encoding, mbuff->input_encoding)) { efree(filename); filename = (char *)new_filename; } } } }
上面主要涉及两个方法:getword、getword_conf
getword:
static char *php_ap_getword (const zend_encoding *encoding, char **line, char stop) { char *pos = *line, quote; char *res; while (*pos && *pos != stop) { if ((quote = *pos) == '"' || quote == '\'' ) { ++pos; while (*pos && *pos != quote) { if (*pos == '\\' && pos[1 ] && pos[1 ] == quote) { pos += 2 ; } else { ++pos; } } if (*pos) { ++pos; } } else ++pos; } if (*pos == '\0' ) { res = estrdup(*line); *line += strlen (*line); return res; } res = estrndup(*line, pos - *line); while (*pos == stop) { ++pos; } *line = pos; return res; }
getword_conf:
static char *php_ap_getword_conf (const zend_encoding *encoding, char *str) { while (*str && isspace (*str)) { ++str; } if (!*str) { return estrdup("" ); } if (*str == '"' || *str == '\'' ) { char quote = *str; str++; return substring_conf(str, (int )strlen (str), quote); } else { char *strend = str; while (*strend && !isspace (*strend)) { ++strend; } return substring_conf(str, strend - str, 0 ); } } static char *substring_conf (char *start, int len, char quote) { char *result = emalloc(len + 1 ); char *resp = result; int i; for (i = 0 ; i < len && start[i] != quote; ++i) { if (start[i] == '\\' && (start[i + 1 ] == '\\' || (quote && start[i + 1 ] == quote))) { *resp++ = start[++i]; } else { *resp++ = start[i]; } } *resp = '\0' ; return result; }
上传文件限制判断 继续回到主体中,获取了filename或者name后,接下来对上传主体进行默认的php上传限制判断
经过php的默认上传限制判断后,再查看用户提交参数中是否存在MAX_FILE_SIZE字段,即用户定义的上传大小上限,通过自定义大小判断用户上传文件是否超过大小,因此MAX_FILE_SIZE并不能超过PHP中设置的最大上传文件大小。
if (!strcasecmp(param, "MAX_FILE_SIZE" )) { #ifdef HAVE_ATOLL max_file_size = atoll(value); #else max_file_size = strtoll(value, NULL , 10 ); #endif } efree(param); efree(value); continue ; }
判断完大小后,再判断是否进行文件上传
如果是文件上传,会进行一个判断,判断param参数(即name参数)
当name值只存在]字符,即skip_upload = 1成立,会忽略上传的文件,如:name]
当name值只存在[字符,即skip_upload = 1成立,会忽略上传的文件,如:name[
当name值存在[]两个字符,且[位于字段开头位置,c值为0,但实际测试过程还是会上传失败,如:[name]
if (!skip_upload) { long c = 0 ; tmp = param; while (*tmp) { if (*tmp == '[' ) { c++; } else if (*tmp == ']' ) { c--; if (tmp[1 ] && tmp[1 ] != '[' ) { skip_upload = 1 ; break ; } } if (c < 0 ) { skip_upload = 1 ; break ; } tmp++; } if (c != 0 ) { skip_upload = 1 ; } }
parm来源,为获取参数name的值
if (!strcasecmp(key, "name" )) { if (param) { efree(param); } param = getword_conf(mbuff->input_encoding, pair );
上传失败:
后面对文件名的判断,判断文件名是否开头为结尾符号(’\0’表示代码结尾)
if (filename[0 ] == '\0' ) { #if DEBUG_FILE_UPLOAD sapi_module.sapi_error(E_NOTICE, "No file uploaded" ); #endif cancel_upload = UPLOAD_ERROR_D; }
将%00进行Url编码后上传,即出现错误
临时文件创建 文件正常上传判断后,通过php_open_temporary_fd_ex
方法创建临时文件
{ #endif fd = php_open_temporary_fd_ex(PG(upload_tmp_dir), "php" , &temp_filename, PHP_TMP_FILE_OPEN_BASEDIR_CHECK_ON_FALLBACK); upload_cnt--; if (fd == -1 ) { sapi_module.sapi_error(E_WARNING, "File upload error - unable to create a temporary file" ); cancel_upload = UPLOAD_ERROR_E; } }
php_open_temporary_fd_ex
方法中再通过调用php_do_open_temporary_file
方法进行创建文件,最后返回随机临时文件名
文件名路径会根据php环境选取对应的默认临时路径
临时文件名格式为php
+随机字符
+.tmp
结尾
经过文件名、大小的判断过后通过write
方法将文件写入临时文件
然后通过zend_string_release_ex
方法对临时文件进行释放(我理解为关闭释放掉临时文件写入的进程管道)
对parm参数的数组情况进行对应处理后,进入对filename的安全过滤处理
filename安全过滤 有一段注释描述在windows环境下由于特殊符号对路径的影响,所以对filename进行了一些安全处理
通过php_ap_basename方法对filename进行安全过滤
下列代码对filename进行\或者/进行匹配,匹配到的话,就获取最后一个斜线后的内容,前面的内容进行跳过
static char *php_ap_basename (const zend_encoding *encoding, char *path) { char *s = strrchr (path, '\\' ); char *s2 = strrchr (path, '/' ); if (s && s2) { if (s > s2) { ++s; } else { s = ++s2; } return s; } else if (s) { return ++s; } else if (s2) { return ++s2; } return path; }
执行结果:
最后后面的就是针对各个参数生成$_FILES内容,没有了其它的过滤。
0x03、调试分析 主机原因先缺着,后面再补
0x04、总结 在没有找到分析文章前,看着是真的费力,理解上很容易跑偏,学习他们的分析文章再看,可能过程中也有理解错的地方,总体也只是对rfc1867协议的部分内容进行浅析,协议的完整处理过程还是理解不全。
0x05、参考链接 https://www.laruence.com/2009/09/26/1103.html https://blog.csdn.net/gnaw0725/article/details/45869847 https://xz.aliyun.com/t/11486#toc-10