PHP安全编码
译自:《Pro PHP Security》
验证过滤用户的输入
即使是最普通的字母数字输入也可能是危险的,列举几个容易引起安全问题的字符:
! $ ^ & * ( ) ~ [ ] \ | { } ' " ; < > ? - `
在数据库中可能有特殊意义的字符:
' " ; \
还有一些非打印字符:
字符\x00或者说ASCII 0,NULL或FALSE
字符\x10和\x13,或者说ASCII 10和13,\n \r
字符\x1a或者说ASCII 26,表示文件的结束
输入错误的参数类型,也可能导致程序出现意想不到的错误。
输入过多的参数值,可能导致溢出等错误。
PHP中验证用户的输入
这里特别要注意php.ini中的register_globals的设定,在早期的php版本中是默认开启的,这会导致很严重的安全问题:
1
2
3
4
5
6
7
8
9
10
|
<?php
// set admin flag
if
(
$auth
->isAdmin()) {
$admin
= TRUE;
}
// ...
if
(
$admin
) {
// do administrative tasks
}
?>
|
上面这段代码看起来是安全的,但是如果register_globals开启了的话,在访问的url中加入?admin=1即可绕过前半部分的逻辑判断。
更安全的代码应该给$admin赋默认FALSE值:
1
2
3
4
5
6
7
8
9
10
11
|
<?php
// create then set admin flag
$admin
= FALSE;
if
(
$auth
->isAdmin()) {
$admin
= TRUE;
}
// ...
if
(
$admin
) {
// do administrative tasks
}
?>
|
早期人们开发调试的时候发现使用register_globals有极大的便利,所以早期的php版本中默认开启。
但是随着越来越多的安全问题,从php 4.2.0开始,register_globals变为了默认关闭。
当你发现register_globals是on的时候,你可能会在脚本当中加入如下代码使其关闭:
1
|
ini_set
(
'register_globals'
, 0);
|
但实际上,当所有的全局变量已经创建了之后,以上代码并不会起到作用。
但是你可以在文档的根目录下的.htaccess的文件中加上下面这一行:
php_flag register_globals 0
变量声明:强烈建议总是事先声明变量。
检查输入的类型,长度和格式:
字符串检查:在PHP中,字符串几乎可以是任何事情,但有些值并不是严格的字符串类型,可以用is_string()函数来确定。
有些时候你不介意数字作为字符串,可以用empty()函数。
数字类型检查:使用is_int()函数或者is_integer()或is_long(),例如:
1
2
3
|
$year
=
$_POST
[
'year'
];
if
(!
is_int
(
$year
))
exit
(
"$year is an invalid value for year!"
);
|
也可以使用gettype()函数判断类型后做处理:
1
2
3
|
if
(
gettype
(
$year
) !=
'integer'
) {
exit
(
"$year is an invalid value for year!"
);
}
|
至少还有三种方式可以吧$year变量转变为整数:
1
2
3
|
$year
=
intval
(
$_POST
[
'year'
]);
$year
= ( int )
$_POST
[
'year'
];
if
(!settype(
$year
,
'integer'
)) {
exit
(
"$year is an invalid value for year!"
);}
|
如果允许浮点型与零的值,可以使用is_numeric()函数来做判断。 判断一个值是否为布尔型的时候使用is_bool()函数。
下表是对各种类型变量使用各函数判断的结果:
检查字符串的长度使用strlen()变量:
1
2
|
if
(
strlen
(
$year
) != 4)
exit
(
"$year is an invalid value for year!"
);
|
概括总结一下PHP中类型,长度,格式等验证:
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
|
<?php
// set up array of expected values and types
$expected
=
array
(
'carModel'
=>
'string'
,
'year'
=>
'int'
,
'imageLocation'
=>
'filename'
);
// check each input value for type and length
foreach
(
$expected
AS
$key
=>
$type
) {
if
(
empty
(
$_GET
[
$key
])) {
${
$key
} = NULL;
continue
;
}
switch
(
$type
) {
case
'string'
:
if
(
is_string
(
$_GET
[
$key
]) &&
strlen
(
$_GET
[
$key
]) < 256) {
${
$key
} =
$_GET
[
$key
];
}
break
;
case
'int'
:
if
(
is_int
(
$_GET
[
$key
])) {
${
$key
} =
$_GET
[
$key
];
}
break
;
case
'filename'
:
// limit filenames to 64 characters
if
(
is_string
(
$_GET
[
$key
]) &&
strlen
(
$_GET
[
$key
]) < 64) {
// escape any non-ASCII
${
$key
} =
str_replace
(
'%'
,
'_'
, rawurlencode(
$_GET
[
$key
]));
// disallow double dots
if
(
strpos
(${
$key
},
'..'
) === TRUE) {
${
$key
} = NULL;
}
}
break
;
}
if
(!isset(${
$key
})) {
${
$key
} = NULL;
}
}
// use the now-validated input in your application
|
对于一些可能有害的字符,可以用如下的几种方式进行保护:
-
使用 \ 对其进行转义。
-
使用引号把他引起来。
-
使用%nn的方式编码,如urlencode()函数。
可以尝试在php.ini中开启magic_quotes_gpc,这样对于所有由用户GET、POST、COOKIE中传入的特殊字符都会转义。
也可是使用addslashes()函数,但是开启magic_quotes_gpc所造成的影响可能远超过益处。
addslashes()也只对最常见的四个字符做了转义:单引号、双引号、反斜线、空字符。
同时为了使数据还原,需要使用stripslashes()函数,也可能破坏一些多字节的转义。
推荐使用mysql_real_escape_string()函数,虽然只是用来设计转义插入数据库的数据,但是他能转义更多的字符。
如:NULL、\x00、\n、\r、\、'、"和\x1a。使用用例:
1
2
3
4
5
6
7
8
9
10
11
12
|
<?php
$expected
=
array
(
'carModel'
,
'year'
,
'bodyStyle'
);
foreach
(
$expected
AS
$key
) {
if
(!
empty
(
$_GET
[
$key
])) {
${
$key
} = mysql_real_escape_string(
$_GET
[
$key
]);
}
}
?>
|
使用mysql_real_escape_string()函数也不是万能的,转义一些并非是要写入mysql的数据库的数据可能不会产生作用或者出现错误。
可以根据自己的实际需要,自己使用str_replace()函数写一个针对特殊字符的转义。
对于文件的路径与名称的过滤
文件名中不能包含二进制数据,否则可能引起问题。
一些系统允许Unicode多字节编码的文件名,但是尽量避免,应当使用ASCII的字符。
虽然Unix系统几乎可以在文件名设定中使用任何符号,但是应当尽量使用 - 和 _ 避免使用其他字符。
同时需要限定文件名的长度。
php中的文件操作通常使用fopen()函数与file_get_contents()函数。
1
2
3
4
5
|
<?php
$applicationPath
=
'/home/www/myphp/code/'
;
$scriptname
=
$_POST
[
'scriptname'
];
highlight_file(
$applicationPath
.
$scriptname
);
?>
|
上面代码的问题在于用户POST输入的scriptname没有做任何过滤,如果用户输入../../../../etc/passwd,则有可能读取到系统的passwd文件。
1
2
3
4
5
|
<?php
$uri
=
$_POST
[
'uri'
];
if
(
strpos
(
$uri
,
'..'
))
exit
(
'That is not a valid URI.'
);
$importedData
=
file_get_contents
(
$uri
);
|
如果发现 .. 字符串就不执行会不会出现问题呢?如果前面并没有路径限制的话,仍然会出现问题:
使用file协议,当用户输入file:///etc/passwd的时候,会把passwd的内容带入$importedData变量中。
防止SQL注入
SQL注入是如何产生的:
1、接收一个由用户提交的变量,假设变量为$variety:
1
|
$variety
=
$_POST
[
'variety'
];
|
2、接收的变量带入构造一个数据库查询语句:
1
|
$query
=
"SELECT * FROM wines WHERE variety='$variety'"
;
|
3、把构造好的语句提交给MySQL服务器查询,MySQL返回查询结果。
当由用户输入lagrein' or 1=1#时,产生的结果将会完全不同。
防止SQL注入的几种方式:
检查用户输入的类型,当用户输入的为数字时可以使用如下方式:
使用is_int()函数(或is_integer()或is_long()函数)
使用gettype()函数
使用intval()函数
使用settype()函数
检查用户输入字符串的长度使用strlen()函数。
检查日期或时间是否是有效的,可以使用strtotime()函数
对于一个已经存在的程序来说,可以写一个通用函数来过滤:
1
2
3
4
|
function
safe(
$string
)
{
return
"'"
. mysql_real_escape_string(
$string
) .
"'"
;
}
|
调用方式:
1
2
|
$variety
= safe(
$_POST
[
'variety'
]);
$query
=
"SELECT * FROM wines WHERE variety="
.
$variety
;
|
对于一个刚开始写的程序,应当设计的更安全一些,PHP5中,增加了MySQL支持,提供了mysqli扩展:
PHP手册地址:http://php.net/mysqli
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
|
<?php
// retrieve the user's input
$animalName
=
$_POST
[
'animalName'
];
// connect to the database
$connect
= mysqli_connect(
'localhost'
,
'username'
,
'password'
,
'database'
);
if
(!
$connect
)
exit
(
'connection failed: '
. mysqli_connect_error());
// create a query statement resource
$stmt
= mysqli_prepare(
$connect
,
"SELECT intelligence FROM animals WHERE name = ?"
);
if
(
$stmt
) {
// bind the substitution to the statement
mysqli_stmt_bind_param(
$stmt
,
"s"
,
$animalName
);
// execute the statement
mysqli_stmt_execute(
$stmt
);
// retrieve the result...
mysqli_stmt_bind_result(
$stmt
,
$intelligence
);
// ...and display it
if
(mysqli_stmt_fetch(
$stmt
)) {
print
"A $animalName has $intelligence intelligence.\n"
;
}
else
{
print
'Sorry, no records found.'
;
}
// clean up statement resource
mysqli_stmt_close(
$stmt
);
}
mysqli_close(
$connect
);
?>
|
mysqli扩展提供了所有的查询功能。
mysqli扩展也提供了面向对象的版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
<?php
$animalName
=
$_POST
[
'animalName'
];
$mysqli
=
new
mysqli(
'localhost'
,
'username'
,
'password'
,
'database'
);
if
(!
$mysqli
)
exit
(
'connection failed: '
. mysqli_connect_error());
$stmt
=
$mysqli
->prepare(
"SELECT intelligence FROM animals WHERE name = ?"
);
if
(
$stmt
) {
$stmt
->bind_param(
"s"
,
$animalName
);
$stmt
->execute();
$stmt
->bind_result(
$intelligence
);
if
(
$stmt
->fetch()) {
print
"A $animalName has $intelligence intelligence.\n"
;
}
else
{
print
'Sorry, no records found.'
;
}
$stmt
->close();
}
$mysqli
->close();
?>
|
防止XSS攻击
xss攻击一个常用的方法就是注入HTML元素执行js脚本,php中已经内置了一些防御的函数(如htmlentities或者htmlspecialchars):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
<?php
function
safe(
$value
)
{
htmlentities(
$value
, ENT_QUOTES,
'utf-8'
);
// other processing
return
$value
;
}
// retrieve $title and $message from user input
$title
=
$_POST
[
'title'
];
$message
=
$_POST
[
'message'
];
// and display them safely
print
'<h1>'
. safe(
$title
) . '</h1>
<p>
' . safe($message) . '
</p>';
?>
|
过滤用户提交的URL
如果允许用户输入一个URL用来调用一个图片或者链接,你需要保证他不传入javascript:或者vbscript:或data:等非http协议。
可以使用php的内置函数parse_url()函数来分割URL,然后做判断。
设置允许信任的域:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
<?php
$trustedHosts
=
array
(
'example.com'
,
'another.example.com'
);
$trustedHostsCount
=
count
(
$trustedHosts
);
function
safeURI(
$value
)
{
$uriParts
=
parse_url
(
$value
);
for
(
$i
= 0;
$i
<
$trustedHostsCount
;
$i
++) {
if
(
$uriParts
[
'host'
] ===
$trustedHosts
[
$i
]) {
return
$value
;
}
}
$value
.=
' ['
.
$uriParts
[
'host'
] .
']'
;
return
$value
;
}
// retrieve $uri from user input
$uri
=
$_POST
[
'uri'
];
// and display it safely
echo
safeURI(
$uri
);
?>
|
防止远程执行
远程执行通常是使用了php代码执行如eval()函数,或者是调用了命令执行如exec(),passthru(),proc_open(),shell_exec(),system()或popen()。
注入php代码:
php为开发者提供了非常多的方法可以来调用允许php脚本,我们就需要注意对用户可控的数据进行过滤。
调用的几种方式:
include()和require()函数,eval()函数,preg_replace()采用e模式调用,编写脚本模板。
1
2
3
|
<?php
print
Hello . world;
?>
|
上面代码将会输出Helloworld,php在解析的时候会检查是否存在一个名为Hello的函数。
如果没有找到的话,他会自己创建一个并把它的名字作为它的值,world也是一样。
上传文件中嵌入php代码:
攻击者可以上传一个看似很普通的图片,PDF等,但是实际上呢?
linux下可以使用如下命令插入php代码进入图片中:
$ echo '<?php phpinfo();?>' >> locked.gif
把代码插入到了locked.gif图片中。
并且此时用file命令查看文件格式仍为图片:
$ file -i locked.giflocked.gif: image/gif
任何的图像编辑或图像处理的程序包括PHP的getimagesize()函数,都会认为它是一个GIF图像。
但是当你使用cat命令查看图片时,可以看到图片末尾的
当把图片的后缀改为php或者已php的方式解析时,插入的phpinfo()函数便会执行。
Shell命令执行
PHP提供了一些可以直接执行系统命令的函数,如exec()函数或者 `(反引号)。
PHP的安全模式会提供一些保护,但是也有一些方式可以绕过安全模式:
1、上传一个Perl脚本,或者Python或Ruby等,服务器支持的环境,来执行其他语言的脚本可绕过PHP的安全模式。
2、利用系统的缓冲溢出漏洞,绕过安全模式。
下表列出了跟Shell相关的一些字符:
名称 | 字符 | ASCII | 16进制 | URL编码 | HTML编码 |
---|---|---|---|---|---|
换行 | 10 | \x0a | %0a | 
 | |
感叹号 | ! | 33 | \x21 | %21 | ! |
双引号 | " | 34 | \x22 | %22 | "或" |
美元符号 | $ | 36 | \x24 | %24 | $ |
连接符 | & | 38 | \x26 | %26 | &或&#amp |
单引号 | ' | 39 | \x27 | %27 | ' |
左括号 | ( | 40 | \x28 | %28 | ( |
右括号 | ) | 41 | \x29 | %29 | ) |
星号 | * | 42 | \x2a | %2a | * |
连字符号 | - | 45 | \x2d | %2d | - |
分号 | ; | 59 | \x3b | %3b | ; |
左尖括号 | < | 60 | \x3c | %3c | < |
右尖括号 | > | 62 | \x3e | %3e | > |
问号 | ? | 63 | \x3f | %3f | ? |
左方括号 | [ | 91 | \x5b | %5b | [ |
反斜线 | \ | 92 | \x5c | %5c | \ |
右方括号 | ] | 93 | \x5d | %5d | ] |
插入符 | ^ | 94 | \x5e | %5e | ^ |
反引号 | ` | 96 | \x60 | %60 | ` |
左花括号 | { | 123 | \x7b | %7b | { |
管道符 | | | 124 | \x7c | %7c | | |
右花括号 | } | 125 | \x7d | %7d | } |
波浪号 | ~ | 126 | \x7e | %7e | ~ |
如下PHP脚本:
1
2
3
4
5
6
7
|
<?php
// get the word count of the requested file
$filename
=
$_GET
[
'filename'
];
$command
=
"/usr/bin/wc $filename"
;
$words
= shell_exec(
$command
);
print
"$filename contains $words words."
;
?>
|
用户可以输入如下的URL来攻击读取passwd文件:
wordcount.php?filename=%2Fdev%2Fnull%20%7C%20cat%20-%20%2Fetc%2Fpasswd
字符串拼接之后,将会执行 /usr/bin/wc /dev/null | cat - /etc/passwd
这条命令
如果能够不适用命令执行函数与eval()函数,可以在php.ini中禁止:disable_functions = "eval,phpinfo"
PHP中还有一个preg_replace()函数,可能引起代码执行漏洞。
mixed preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit ] )
在 subject 中搜索 pattern 模式的匹配项并替换为 replacement 。如果指定了 limit ,则仅替换 limit 个匹配。
如果省略 limit 或者其值为 -1,则所有的匹配项都会被替换。
特别注意:
/e 修正符使 preg_replace() 将 replacement 参数当作 PHP 代码(在适当的逆向引用替换完之后)。
提示:要确保 replacement 构成一个合法的 PHP 代码字符串,否则 PHP 会在报告在包含 preg_replace() 的行中出现语法解析错误。
1
2
3
4
5
6
7
8
9
|
<?php
function
test(
$str
)
{
//......
//......
return
$str
;
}
echo
preg_replace(
"/\s*\[p hp language="
"](.+?)\[\/php\]\s*/ies"
,
'test("\1")'
,
$_GET
[
"h"
]);
?>
|
当用户输入
?h=[p hp]phpinfo()[/php]
经过正则匹配后, replacement 参数变为'test("phpinfo()")',
此时phpinfo仅是被当做一个字符串参数了。
但是当我们提交
?h=[p hp]{${phpinfo()}}[/php]
时,phpinfo()就会被执行。
在php中,双引号里面如果包含有变量,php解释器会将其替换为变量解释后的结果;单引号中的变量不会被处理。
注意:双引号中的函数不会被执行和替换。
在这里我们需要通过{${}}构造出了一个特殊的变量,'test("{${phpinfo()}}")',达到让函数被执行的效果 ${phpinfo()} 会被解释执行。