php源码审计[file_put_contents与data协议]【通过】

前言

学长问了我一个php的问题。下面这个代码为什么创建不了文件。

<?php 
    file_put_contents("data:,123",'123');
?>

1.0 前置知识

This function is identical to calling fopen(), fwrite() and fclose() successively to write data to a file.

file_put_contents是对fopen(), fwrite() and fclose() 的一个封装

file_put_contents当成功写入文件的时候会返回,写入文件的长度。

并且可以知道的是,file_put_contents是可以解析协议的,比如file://,php://这些协议都是可以的。(可以自己尝试一下)

并且也是可以成功解析data://

但是data://协议的话却可以成功返回,写入文件的长度,但是无法成功写入文件。

那就看看源码是怎么解释的吧。(第一次写,请师傅斧正。

2.0 源码分析

2.1 准备php源码调试环境

这个部分,之前我有一篇文章中写了。所以就不在赘述了

2.2 分析流程

2.2.1 file://协议过程

准备好环境之后呢。直接看源码吧。首先找到file_put_contents这个函数的源码,然后点下几个断点,先捋一捋整个的过程。

首先试试file://协议是否能够被解析,并且成功写入文件。

一步步跟一下,发现到

stream = php_stream_open_wrapper_ex(filename, mode, ((flags & PHP_FILE_USE_INCLUDE_PATH) ? USE_PATH : 0) | REPORT_ERRORS, NULL, context);

创建一个文件,但是还不会把内容写进去

继续往下跟进会发现到

case IS_STRING:
			if (Z_STRLEN_P(data)) {
                // 将内容写入文件中
				numbytes = php_stream_write(stream, Z_STRVAL_P(data), Z_STRLEN_P(data));
				if (numbytes != Z_STRLEN_P(data)) {
					php_error_docref(NULL, E_WARNING, "Only %zd of %zd bytes written, possibly out of free disk space", numbytes, Z_STRLEN_P(data));
					numbytes = -1;
				}
			}
			break;

之后在,关闭文件句柄

文件写入和文件句柄关闭没什么好讲的。应该要把重心放到文件的创建以及协议的解析上面。

所以断点就来到了php_stream_open_wrapper_ex

单步进入看看。稍微走几步。来到了wrapper(包装器

以下是一些概念

流Streams这个概念是在php4.3引进的

流有点类似数据库抽象层,在数据库抽象层方面,不管使用何种数据库,在抽象层之上都使用相同的方式操作数据,而流是对数据的抽象,它不管是本地文件还是远程文件还是压缩文件等等,只要来的是流式数据,那么操作方式就是一样的

有了流这个概念就引申出了包装器wrapper这个概念,每个流都对应一种包装器,流是从统一操作这个角度产生的一个概念,而包装器呢是从理解流数据内容出发产生的一个概念,也就是这个统一的操作方式怎么操作或配置不同的内容

官方手册说:“一个包装器是告诉流怎么处理特殊协议或编码的附加代码”

重点在于官方手册说:“一个包装器是告诉流怎么处理特殊协议或编码的附加代码”

那么单步进入这个方法。(稍微精简一下,

PHPAPI php_stream_wrapper *php_stream_locate_url_wrapper(const char *path, const char **path_for_open, int options)
{
	HashTable *wrapper_hash = (FG(stream_wrappers) ? FG(stream_wrappers) : &url_stream_wrappers_hash);
	php_stream_wrapper *wrapper = NULL;
	const char *p, *protocol = NULL;
	size_t n = 0;
    ...
	for (p = path; isalnum((int)*p) || *p == '+' || *p == '-' || *p == '.'; p++) {
		n++;
	}
	// 判断是一个协议,如果是的话,那么就把值赋给protocol
	if ((*p == ':') && (n > 1) && (!strncmp("//", p+1, 2) || (n == 4 && !memcmp("data:", path, 5)))) {
		protocol = path;
	}
	...
	// 如果不存在协议或者是file协议的话,进入
	if (!protocol || !strncasecmp(protocol, "file", n))	{
		/* fall back on regular file access */
        // 生成一个文件包装器。进行统一的文件操作。
		php_stream_wrapper *plain_files_wrapper = (php_stream_wrapper*)&php_plain_files_wrapper;
		// 如果存在协议,进入
		if (protocol) {
			int localhost = 0;
			// 判断是否满足以下的这个格式(话说有一些题目考的就这个点,真相了
			if (!strncasecmp(path, "file://localhost/", 17)) {
				localhost = 1;
			}
// 概念(windows平台上总是会有这种宏,所以用来判断是否是windows平台。
#ifdef PHP_WIN32
			if (localhost == 0 && path[n+3] != '\0' && path[n+3] != '/' && path[n+4] != ':')	{
#else
			if (localhost == 0 && path[n+3] != '\0' && path[n+3] != '/') {
#endif
                // 判断文件是否可达?(没懂。。
				if (options & REPORT_ERRORS) {
					php_error_docref(NULL, E_WARNING, "Remote host file access not supported, %s", path);
				}
				return NULL;
			}
			// 这个if就是把字符串处理成文件的位置(比如file://D:/flag 处理之后的结果就是 D:/flag)
			if (path_for_open) {
				/* skip past protocol and :/, but handle windows correctly */
				*path_for_open = (char*)path + n + 1;
				if (localhost == 1) {
					(*path_for_open) += 11;
				}
				while (*(++*path_for_open)=='/') {
					/* intentionally empty */
				}
#ifdef PHP_WIN32
				if (*(*path_for_open + 1) != ':')
#endif
					(*path_for_open)--;
			}
		}
		// 这个地方没有看懂,请师傅们指点一下
		if (FG(stream_wrappers)) {
		/* The file:// wrapper may have been disabled/overridden */
            
		}
		// 最后通过这里返回
		return plain_files_wrapper;
	}
        ...
}

返回之后,进过一个判断,来到了

跟进一下

stream = wrapper->wops->stream_opener(wrapper,
				path_to_open, mode, options ^ REPORT_ERRORS,
				opened_path, context STREAMS_REL_CC);

判断是否设置了open_dir

这里由于我没有设置,所以直接进入了php_stream_fopen_rel

PHPAPI php_stream *_php_stream_fopen(const char *filename, const char *mode, zend_string **opened_path, int options STREAMS_DC)
{
    // 一大堆的定义
	char realpath[MAXPATHLEN];
	int open_flags;
	int fd;
	php_stream *ret;
	int persistent = options & STREAM_OPEN_PERSISTENT;
	char *persistent_id = NULL;
 	// 解析fopen mod (w , w+ , a ....)
	if (FAILURE == php_stream_parse_fopen_modes(mode, &open_flags)) {
		if (options & REPORT_ERRORS) {
			zend_value_error("`%s' is not a valid mode for fopen", mode);
		}
		return NULL;
	}
	// 
	if (options & STREAM_ASSUME_REALPATH) {
		strlcpy(realpath, filename, sizeof(realpath));
	} else {
        // 判断filepath 和 realpath 是否都存在
		if (expand_filepath(filename, realpath) == NULL) {
			return NULL;
		}
	}
	...
        
#ifdef PHP_WIN32
    // 创建文件(!)
	fd = php_win32_ioutil_open(realpath, open_flags, 0666);
#else
	fd = open(realpath, open_flags, 0666);
#endif
    // 判断是否写入,如果写入 进(后面一堆操作,确实没看太懂,好像和偏移有关)
	if (fd != -1)	{
			...
			//从这里返回
			return ret;
		
	}

}

创建完文件后,还有一大堆操作,技术有限确实没搞懂了。

那接着从返回往下看,返回值后,又做了一些花里胡哨的操作,就返回了stream

之后就是文件的写入以及文件句柄的关闭了。

2.2.2 php://协议

那么不是file://协议又会是怎么样的一个样子呢?有前置知识可以知道,php://也会解析,那么到底是什么地方不同呢?

还是依然从steam往下走

stream = php_stream_open_wrapper_ex(filename, mode, ((flags & PHP_FILE_USE_INCLUDE_PATH) ? USE_PATH : 0) | REPORT_ERRORS, NULL, context);

进入wapper,跟进一下。走到了return

单步进入

接着单步进入,这个时候不同了,他走到了另一个奇怪的地方(ext\standard\php_fopen_wrapper.c)

源码如下(大致意思就是解析php://)

php_stream * php_stream_url_wrap_php(php_stream_wrapper *wrapper, const char *path, const char *mode, int options,
									 zend_string **opened_path, php_stream_context *context STREAMS_DC) /* {{{ */
{
	int fd = -1;
	int mode_rw = 0;
	php_stream * stream = NULL;
	char *p, *token = NULL, *pathdup;
	zend_long max_memory;
	FILE *file = NULL;
#ifdef PHP_WIN32
	int pipe_requested = 0;
#endif

	if (!strncasecmp(path, "php://", 6)) {
		path += 6;
	}

	...(php://协议的一堆操作检验 比如:php://stdin, php://stdout 和 php://stderr ....)
	// 进入filter的判断
	} else if (!strncasecmp(path, "filter/", 7)) {
		/* Save time/memory when chain isn't specified */
		if (strchr(mode, 'r') || strchr(mode, '+')) {
			mode_rw |= PHP_STREAM_FILTER_READ;
		}
		if (strchr(mode, 'w') || strchr(mode, '+') || strchr(mode, 'a')) {
			mode_rw |= PHP_STREAM_FILTER_WRITE;
		}
		pathdup = estrndup(path + 6, strlen(path + 6));
		p = strstr(pathdup, "/resource=");
		if (!p) {
			zend_throw_error(NULL, "No URL resource specified");
			efree(pathdup);
			return NULL;
		}
		//重点在这: 这里有一个'解包'操作
		if (!(stream = php_stream_open_wrapper(p + 10, mode, options, opened_path))) {
			efree(pathdup);
			return NULL;
		}

		...
#endif
	return stream;
}

进入了之后,一直下一步,知道断定停在了 else if (!strncasecmp(path, "filter/", 7)),再往下调一下,就到了

	// '解包'操作
if (!(stream = php_stream_open_wrapper(p + 10, mode, options, opened_path))) {
			efree(pathdup);
			return NULL;
		}

单步进入看看,跳到了(main\streams\streams.c)

又是熟悉的味道,进入看看,

来到了(main\streams\streams.c),然后来到了,一个判断是否存在协议的点。由于protocol是空的,所以断点一定是会进入这个断点的。

所以最后的结果就一定是返回一个文件包装器。

跳出之后又来到了main\streams\streams.c

这个就和file://协议创建文件方式一模一样了。然后也是一样的,文件写入,文件句柄关闭

2.2.3 data://协议过程

上面就是一个文件从创建到写入到文件关闭的具体过程了。

那么修改一下代码,看一下data://是怎么个过程,为什么无法创建文件呢?

前面的过程大致都是一样的。从php_stream_open_wrapper_ex进入走到wrapper,单步进入

到了协议的判断这个地方,这里很明显不会进去。

而是跳转到了判断是否允许远程文件包含,肯定也是会跳过的。

image

这里似乎很明显不是返回的文件包装器。那么接着往下看

不出意料,他调到了一个奇怪的地方

static php_stream * php_stream_url_wrap_rfc2397(php_stream_wrapper *wrapper, const char *path,
												const char *mode, int options, zend_string **opened_path,
												php_stream_context *context STREAMS_DC) /* {{{ */
{
    ...(开头又是一大堆定义)

	ZVAL_NULL(&meta);
    // 判断是否为data协议
	if (memcmp(path, "data:", 5)) {
		return NULL;
	}
	
	path += 5;
	dlen = strlen(path);
	...(一堆检验data协议的操作)

	if (comma != path) {
		/* meta info */
		mlen = comma - path;
		dlen -= mlen;
		semi = memchr(path, ';', mlen);
		sep = memchr(path, '/', mlen);

		if (!semi && !sep) {
			php_stream_wrapper_log_error(wrapper, options, "rfc2397: illegal media type");
			return NULL;
		}

		array_init(&meta);
		if (!semi) { /* there is only a mime type */
			add_assoc_stringl(&meta, "mediatype", (char *) path, mlen);
			mlen = 0;
		}
		/* get parameters and potentially ';base64' */
		...(一堆检验data协议的操作)
	} else {
		array_init(&meta);
	}
	add_assoc_bool(&meta, "base64", base64);

	/* skip ',' */
	comma++;
	dlen--;
	// base64解码
	if (base64) {
        ...(解码操作)
	}
	// php为data协议创建了一个临时写入流,将输入写入进去
	if ((stream = php_stream_temp_create_rel(0, ~0u)) != NULL) {
		/* store data */
		php_stream_temp_write(stream, comma, ilen);
		php_stream_temp_seek(stream, 0, SEEK_SET, &newoffs);
		/* set special stream stuff (enforce exact mode) */
		vlen = strlen(mode);
		if (vlen >= sizeof(stream->mode)) {
			vlen = sizeof(stream->mode) - 1;
		}
		memcpy(stream->mode, mode, vlen);
		stream->mode[vlen] = '\0';
		stream->ops = &php_stream_rfc2397_ops;
		ts = (php_stream_temp_data*)stream->abstract;
		assert(ts != NULL);
		ts->mode = mode && mode[0] == 'r' && mode[1] != '+' ? TEMP_STREAM_READONLY : 0;
		ZVAL_COPY_VALUE(&ts->meta, &meta);
	}
	if (base64_comma) {
		zend_string_free(base64_comma);
	} else {
		efree(comma);
	}

	return stream;
}

这里可以看出解析data协议的时候,并不会尝试去获取一个可以写文件的包装器,而是php专门为data协议创建写入流用来存储data协议解析的信息。

并且在最后会将这个流return出去,回到file.c,然后继续的这个流进行写入。

这俩次写入应该是会互相影响的,从前置知识可知,file_put_contents是对三个file操作函数的一次封装。那个把这个函数拆解一下。

<?php

$stream = fopen('data://text/plain,Mrkaixin', 'w+');
fwrite($stream, 'data');
rewind($stream);
var_dump(fread($stream, 8));
fclose($stream);

结果如下

最后的结果是dataixin,可以看到会有一个覆盖的操作。这个data直接覆盖了Mrkaixin的开头的前四个字符。

2.2.2 如何区分php:// 和 data://

到这里肯定还是会有一个问题,就是它是怎么区分dataphp://

main\streams\streams.c

由于实力原因就不往下跟了。(逃。。

2.3 结论

到了这里其实结论也就呼之欲出了

正是data://中为没有创建文件包装器的操作,而是仅仅在解析data协议的时候创建了一个写入流存储数据,从而导致无法创建文件。

@j7ur8 师傅给php提了个bug,官方回答如下

According to the docs https://www.php.net/manual/en/wrappers.data.php#refsect1-wrappers.data-options writing is not supported so seems to be working as designed.

If anything it shouldn't say it wrote 4 bytes instead.(这句话确实没懂)

2.4 Reference

data://

file_put_contents can't create file with data:// wrappers

file_put_contents

3.0 后记

溜了溜了,感觉看源码还蛮有意思的,文章写得浅,请师傅们指点。

  • 通过
  • 未通过

0 投票者

我觉得吧,双方在file_put_contents中filename参数上有一个理解上的差异23333
@j7ur8 师傅 认为data://text/plain,cccc => filename==cccc,然后创建cccc文件写入data

但实际上这个filename却不是想象中的这样,比如最简单的文件写入:

file_put_contents('1.txt','data');

这里1.txt是个普通的文件名,在这种情况下,php会把它默认当成file://1.txt,会给它加上默认的file协议,然后打开这个本地文件,写入data。

但是你如果你开始就指定了协议,比如data,那么php内部就会按照你写的协议格式,去构造这样一个data协议内部结构,但是data协议内部结构实际上你可以抽象成一个临时的buffer,初始化的时候,往这个buffer写了一些东西,然后有一个rewind的操作,而后file_put_contents写入了data,data就会理所当然的覆盖你初始化写的一些东西,最后file_put_contents执行结束的时候,再释放掉这个临时的buffer。

在这里看起来非常自然,反过来问在file_put_contents的filename参数上使用data://协议 为什么要创建文件呢? )

1 Like