php7内核rfc1867协议对文件上传的过滤浅析
2023-01-14 15:19:15

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"];
//echo phpinfo();
//move uploaded file($file['file']['tmp_name'], $file['file']['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进行合法性校验

//判断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);

//判断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 {
/* search for the end of the boundary */
boundary_end = strpbrk(boundary, ",;");
}

boundary获取后,对buffer流进行初始化

/* Initialize the 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, '=')) {
//通过getword方法获取=前面的key值
key = getword(mbuff->input_encoding, &pair, '=');

//key为name的情况
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;
}
}
//key为filename的情况
} 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) {
//如果获取符号中存在\+quote,会跳过\符号,取quote值,比如"test\",就会忽略掉\符号得到"test"
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)
{
//判断当前str指针是否存在且是否为空
while (*str && isspace(*str)) {
++str;
}

//如果str指针索引循环完不存在的话,表示空,指向""
if (!*str) {
return estrdup("");
}

//索引到以"或者'的时候,调用substring_conf方法开始从"或者'字符的后一位开始选取
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]
/* New Rule: never repair potential malicious user input */
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++;
}
/* Brackets should always be closed */
if(c != 0) {
skip_upload = 1;
}
}

parm来源,为获取参数name的值

//parm来源
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