Having been doing OSCP training for the last month, so I didn’t bother to write vulnhub walkthroughs anymore. Anyway, after OSCP exam I will probably switch to hackthebox, so might be no more vulnhub walkthroughs unless I found any box interesting enough for me to write a walkthrough. =(

Back the the topic, this is just something I found quite strange when making my own box during internship and I think it is worthy to write down. Briefly describing:

when using move_uploaded_file($src, $dest), if $dest already exists, what is the expected behaviour of php?

I have asked some of my friends, they all said that the $dest will be overwritten, it is my original thoughts also, until it overwrote my upload functional file with owner as root and permission as 644.

I was writing a vulnerable upload functional file named upload.php, due to some reason, the uploaded files need to be in the same directory of upload.php, scared of upload.php being overwritten, I chown root:root upload.php and chmod 644 upload.php, but still, my upload.php can be overwritten, and I have to rewrite my upload function as I didn’t backup ><

since my apache server was running by the default as www-data user, not even in the root group, it should not be able to overwrite my upload.php, I started to wonder the behaviour of move_uploaded_file(), so I did the following experiment.

  1. chown root:root upload.php and chmod 000 upload.php -> still can be overwritten.
  2. chown root:root . , chmod 777 . upload.php and chmod a+t . -> upload.php cannot be overwritten anymore.

with sticky bit set in the web directory, all the files within the directory can only be deleted by root and its owner. So move_uploaded_file() is actually trying to delete my existing file first, then create a new one? That also align with the output of ls -al upload.php as the overwritten upload.php is now www-data : www-data

I decided to go to the source code level to figure out the underlying behaviour of move_uploaded_file()

I was using php 5.5.9 which can be downloaded here: https://www.php.net/distributions/php-5.5.9.tar.bz2

from line 5816 to 5866 in /ext/standard/basic_functions.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
PHP_FUNCTION(move_uploaded_file)
{
char *path, *new_path;
int path_len, new_path_len;
zend_bool successful = 0;

#ifndef PHP_WIN32
int oldmask; int ret;
#endif

if (!SG(rfc1867_uploaded_files)) {
RETURN_FALSE;
}

if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss", &path, &path_len, &new_path, &new_path_len) == FAILURE) {
return;
}

if (!zend_hash_exists(SG(rfc1867_uploaded_files), path, path_len + 1)) {
RETURN_FALSE;
}

if (php_check_open_basedir(new_path TSRMLS_CC)) {
RETURN_FALSE;
}

if (VCWD_RENAME(path, new_path) == 0) {
successful = 1;
#ifndef PHP_WIN32
oldmask = umask(077);
umask(oldmask);

ret = VCWD_CHMOD(new_path, 0666 & ~oldmask);

if (ret == -1) {
php_error_docref(NULL TSRMLS_CC, E_WARNING, "%s", strerror(errno));
}
#endif
} else if (php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR TSRMLS_CC) == SUCCESS) {
VCWD_UNLINK(path);
successful = 1;
}

if (successful) {
zend_hash_del(SG(rfc1867_uploaded_files), path, path_len + 1);
} else {
php_error_docref(NULL TSRMLS_CC, E_WARNING, "Unable to move '%s' to '%s'", path, new_path);
}

RETURN_BOOL(successful);
}
1
2
3
if (!SG(rfc1867_uploaded_files)) {
RETURN_FALSE;
}

SG macro is to retrieve the attributes from the global sapi_globals_struct, which is defined in line 141 to 145 in /main/SAPI.h, so this is just to determine if the file is uploaded this time. if not, return false.

1
2
3
if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss", &path, &path_len, &new_path, &new_path_len) == FAILURE) {
return;
}

this is to pass the parameters from users, if any of the parameters is not convertible or number of parameters does not align, it will report error. Basically it is just an intermediate function to pass parameters.

1
2
3
if (!zend_hash_exists(SG(rfc1867_uploaded_files), path, path_len + 1)) {
RETURN_FALSE;
}

to check the hash of the uploaded file to see if the path is within rfc1867_uploaded_files, if not, return false. This is to make sure that move_uploaded_file only does what its name suggests.

1
2
3
if (php_check_open_basedir(new_path TSRMLS_CC)) {
RETURN_FALSE;
}

open_basedir check, nothing much to say

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (VCWD_RENAME(path, new_path) == 0) {
successful = 1;
#ifndef PHP_WIN32
oldmask = umask(077);
umask(oldmask);

ret = VCWD_CHMOD(new_path, 0666 & ~oldmask);

if (ret == -1) {
php_error_docref(NULL TSRMLS_CC, E_WARNING, "%s", strerror(errno));
}
#endif
} else if (php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR TSRMLS_CC) == SUCCESS) {
VCWD_UNLINK(path);
successful = 1;
}

here comes the most important part, if will first call VCWD_RENAME(path, new_path) if not successful, then call php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR TSRMLS_CC) == SUCCESS, as the function name suggests, it will try to rename $src to $dest first, if not successful, then try to copy $src to $dest, so need to trace to VCWD_RENAME().

from line 297 to 301 in /TSRM/tsrm_virtual_cwd.h

1
2
3
4
5
#if defined(TSRM_WIN32)
# define VCWD_RENAME(oldname, newname) (MoveFileEx(oldname, newname, MOVEFILE_REPLACE_EXISTING|MOVEFILE_COPY_ALLOWED) == 0 ? -1 : 0)
#else
# define VCWD_RENAME(oldname, newname) rename(oldname, newname)
#endif

it might be using the underlying *nix rename function?

some outdated comments from users in https://www.php.net/manual/zh/function.move-uploaded-file.php

1

sadly, it is wrong.