Đi tới nội dung chính
Cyber Security

OWASP Top 10 - Bài 1: A05 - Injection (Lỗ hổng Tiêm nhiễm)

4 tháng 7, 20269 phút1742 từZeno

Chào mọi người, Zeno đây! Hôm nay mình sẽ chính thức khởi động series OWASP Top 10 nha.

Dành cho những ai chưa biết thì đây giống như là bảng phong thần xếp hạng 10 rủi ro bảo mật web “khét” nhất thế giới vậy. Và để mở bát cho chuỗi bài này, chúng ta sẽ lôi một “bố già” của làng bảo mật ra ánh sáng: Injection (Tiêm mã).

Thường thì nhắc tới Security, mọi người hay bị ngợp bởi mớ lý thuyết khô khan. Yên tâm, bài này mình sẽ đi thẳng vào thực tế: lỗi nó sờ sờ ra sao, dùng Golang fix thế nào cho chuẩn bài, và cách ép máy móc tự bắt lỗi cho mình luôn.

Pha ấm trà rồi chúng ta bắt đầu thôi!


1. Bản chất của Injection là gì?

Thật ra lỗi bảo mật phần lớn đến từ việc các pháp sư code… lười kiểm tra dữ liệu đầu vào. :D

Injection xảy ra khi chúng ta lấy dữ liệu (do người dùng nhập) và ném thẳng vào hệ thống (database, shell,…) mà quên bọc nó lại cẩn thận. Hệ thống ngây thơ đọc dòng dữ liệu đó và tưởng nhầm nó là một câu lệnh. Boom! Kẻ tấn công vừa có quyền điều khiển hệ thống của bạn luôn!

Lưu ý nhỏ từ Zeno: Injection thực ra có rất nhiều họ hàng hang hốc khác như NoSQL Injection, SSTI (Template Injection), LDAP Injection, hay thậm chí cả XSS (HTML/JS Injection trên trình duyệt). Tuy nhiên để bài viết không bị “bội thực”, Zeno sẽ tập trung bóc tách 2 “hung thần” kinh điển và nguy hiểm nhất ở Server-Side trong Golang: SQL InjectionOS Command Injection nhé!


2. SQL Injection (SQLi) - Ác mộng của Database

Đây là lỗi xảy ra khi kẻ tấn công chèn lệnh SQL độc hại vào input để “dắt mũi” Database của chúng ta. Lỗi này sinh ra từ đâu? Đơn giản là do thói quen nối chuỗi (String Concatenation) huyền thoại của anh em dev nhà mình :v

Phân loại sương sương

  • In-band SQLi (Classic): Kẻ tấn công vừa tiêm mã vừa nhận kết quả trả về ngay trên màn hình. Rất trực quan!
    • Error-based: Ép Database văng ra lỗi để moi thông tin (như tên bảng, phiên bản).
    • UNION-based: Dùng lệnh UNION để gộp kết quả của mình vào kết quả của code gốc.
  • Inferential SQLi (Blind): Loại này “mù” do kết quả không hiện ra màn hình. Hacker phải đoán mò bằng cách gửi các câu hỏi Đúng/Sai.
    • Boolean-based: Nhìn xem trang web có thay đổi gì không (ví dụ mất cái nút hay mất dòng chữ) để đoán đúng/sai.
    • Time-based: Chèn lệnh bắt Database phải “ngủ” (vd: WAITFOR DELAY '0:0:5'). Nếu server mất 5 giây mới rep thì hacker biết là lệnh đã chạy thành công!

Ví dụ minh họa siêu kinh điển

Nhiều tài liệu hay lấy cái ví dụ ' OR '1'='1 tiêm vào ô username. Nhưng Zeno thấy thế này mới chuẩn bài nè:

1
SELECT * FROM users WHERE username = 'admin' -- ' AND password = '...'

Khi tiêm chữ admin' -- vào ô username, ký hiệu -- sẽ biến toàn bộ đoạn check password phía sau thành comment (ghi chú). Câu lệnh giờ chỉ còn là SELECT * FROM users WHERE username = 'admin', và thế là đăng nhập thẳng vào admin luôn mà khỏi cần password! :v

Mình sẽ giải thích thêm một chút về cái lỗi học thuật phổ biến nha: Nếu bạn chỉ tiêm ' OR '1'='1 vào username mà không có dấu chú thích --, thì câu lệnh sẽ là: username = '' OR '1'='1' AND password = ''. Khổ nỗi trong SQL, toán tử AND có quyền ưu tiên cao hơn OR. Nên nó sẽ gom lại thành username = '' OR ('1'='1' AND password = ''). Nếu password trống thì vế phải tịt ngòi (False), vế trái cũng False nốt. Thế là không hack được đâu nhé! EHE! Muốn xài chiêu này thì phải ném vào ô password cơ!


3. OS Command Injection - Thủng luôn hệ điều hành

Khác với SQLi chỉ quậy trong Database, cái này là kẻ tấn công đánh thẳng vào Hệ điều hành (Linux/Windows) luôn!

Ví dụ thực tế nha

Làm tính năng ping mạng bằng PHP chẳng hạn:

1
2
$target = $_GET['ip'];
system("ping -c 4 " . $target);

Giờ thay vì nhập IP, hacker nhập cái này: 8.8.8.8 ; cat /etc/passwd. Và bùm, hệ điều hành sẽ chạy 2 lệnh liên tiếp:

  1. Ping Google :D
  2. Đọc luôn file chứa thông tin user của Linux (cat /etc/passwd). Xong phim!

4. Tóm tắt nhanh bằng bảng cho xịn

Tiêu chíSQLiCommand Injection
Mục tiêuCơ sở dữ liệu (MySQL, Postgres…)Hệ điều hành (Linux, Windows)
Hậu quảMất/sửa/xóa dữ liệuMất quyền điều khiển cả cái server
Hàm hay gây họaNối chuỗi SQLsystem(), exec(), shell_exec()
Cách phòng chốngDùng Parameter / Prepared StatementsDùng API chuẩn hoặc làm Whitelist

5. Thực hành chống Hack bằng Golang!

Go thì mặc định khá an toàn, nhưng dùng sai API thì vẫn “toang” như thường. Cùng Zeno xem code nha.

5.1. Phòng SQL Injection

[Cảnh báo] Code lỗi nè (Vulnerable): Cái tội nối chuỗi bằng fmt.Sprintf.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
	"database/sql"
	"fmt"
	_ "github.com/lib/pq" 
)

func unsafeLogin(db *sql.DB, username string, password string) {
	// Trông thì rõ ràng nhưng thực ra là cạm bẫy :v
	query := fmt.Sprintf("SELECT id FROM users WHERE username = '%s' AND password = '%s'", username, password)
	
	var id int
	db.QueryRow(query).Scan(&id)
}

[Khuyến nghị] Code chuẩn bài (Secure): Dùng Placeholder nha các bạn! Thư viện database/sql nó tự lo hết. (Lưu ý: Postgres dùng $1, $2 còn MySQL thì dùng ? nhé).

1
2
3
4
5
6
7
8
func safeLogin(db *sql.DB, username string, password string) {
	// Viết thế này cho nó chuyên nghiệp và an toàn tuyệt đối!
	query := "SELECT id FROM users WHERE username = $1 AND password = $2"
	
	var id int
	err := db.QueryRow(query, username, password).Scan(&id)
	// ... xử lý lỗi
}

5.2. Phòng Command Injection

Nhớ 2 quy tắc vàng của Zeno:

  1. Không bao giờ gọi qua Shell kiểu sh -c hay cmd /c.
  2. Truyền tham số theo dạng từng mảng cắt rời.

[Cảnh báo] Code này là toang luôn nha:

1
2
3
4
5
6
7
8
import "os/exec"

func unsafePing(ipInput string) {
	// Lại là anh chàng Sprintf tội lỗi :v
	cmdString := fmt.Sprintf("ping -c 4 %s", ipInput)
	cmd := exec.Command("sh", "-c", cmdString)
	cmd.CombinedOutput()
}

[Khuyến nghị] Code an toàn 100 điểm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import (
	"fmt"
	"net"
	"os/exec"
)

func safePing(ipInput string) {
	// Cẩn thận check thêm tí Whitelist cho chắc cú!
	if net.ParseIP(ipInput) == nil {
		fmt.Println("Lỗi: IP fake à?")
		return
	}

	// Chuyền tham số dạng mảng, có gõ dấu ';' OS cũng chịu, không chạy bậy được!
	cmd := exec.Command("ping", "-c", "4", ipInput)
	cmd.CombinedOutput()
}

6. GORM có thực sự an toàn?

Anh em hay bảo nhau “dùng ORM như GORM là auto chống được SQLi”. Sai lầm chết người nha! :v

Cách viết để GORM “đỡ đạn” cho bạn

1
2
3
4
5
// Truyền cả cái Struct vào, thế này là an toàn nhất quả đất!
db.Where(&User{Name: inputName, Email: inputEmail}).First(&user)

// Dùng dấu "?" cũng okela nha:
db.Where("name = ? AND email = ?", inputName, inputEmail).First(&user)

Các Cạm bẫy GORM hay gặp (Rất dễ sập hầm!)

[Cảnh báo] Bẫy số 1: Tự tin nối chuỗi trong Where()

1
2
// GORM bó tay với ca này luôn! EHE!
db.Where(fmt.Sprintf("name = '%s'", userInput)).First(&user) 

[Cảnh báo] Bẫy số 2: Viết Query thuần sai cách

1
2
// Tự bóp mình bằng Raw Query
db.Raw("SELECT * FROM users WHERE name = '" + userInput + "'").Scan(&result)

[Cảnh báo] Bẫy số 3: Lỗi chết người ở Order() Cái này nhiều anh em dính nè! Cái dấu ? KHÔNG BAO GIỜ xài được cho tên cột hay tên bảng. Thế là anh em toàn… nối chuỗi :D

1
2
// Toang! Hacker nhập: "id; DROP TABLE users" là bay màu database nha!
db.Order(sortBy + " DESC").Find(&users) 

Cách fix cực gắt: Làm quả Whitelist (danh sách trắng) kiểm tra tận răng luôn.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func GetSortedUsers(db *gorm.DB, sortBy string) ([]User, error) {
    validColumns := map[string]bool{"id": true, "name": true, "created_at": true}
    
    if !validColumns[sortBy] {
        sortBy = "id" // Sai là mình ép về id hết :v
    }
    
    var users []User
    err := db.Order(sortBy + " DESC").Find(&users).Error
    return users, err
}

7. Tự Động Hóa Quét Lỗi (Đẳng Cấp DevSecOps)

Làm dev xịn là phải cho máy móc tự làm nha! Setup thẳng vào CI/CD cho nó oai.

7.1. Gắn Linter quét tự động

Xài gosec để bắt sống mấy cái lỗi Sprintf. Vào file .golangci.yml quất dòng này vô:

1
2
3
4
5
6
7
8
linters:
  enable:
    - gosec
linters-settings:
  gosec:
    includes:
      - G201 
      - G202 

7.2. Tự chế Unit Test quét luôn cả Code

Cái này là đỉnh của chóp luôn! Dùng package go/ast để bới tung Abstract Syntax Tree của Go lên, thấy ông nào dùng dấu + trong hàm Where là đánh trượt (FAIL) test liền.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Khúc này mình demo ngắn gọn thôi nha:
ast.Inspect(node, func(n ast.Node) bool {
    // ... logic tìm hàm
    if gormDangerousMethods[selExpr.Sel.Name] && len(callExpr.Args) > 0 {
        firstArg := callExpr.Args[0]

        // Nếu thấy dùng toán tử "+" (BinaryExpr với token.ADD) là bắt quả tang luôn!
        if binaryExpr, ok := firstArg.(*ast.BinaryExpr); ok && binaryExpr.Op == token.ADD {
            t.Errorf("[BẮT ĐƯỢC LỖI] Đang nối chuỗi bằng '+' ở hàm .%s nha!", selExpr.Sel.Name)
        }
    }
    return true
})

Tóm lại

Injection nghe thì cũ nhưng chưa bao giờ hết hot. Anh em cứ nhớ thần chú: Tách biệt Mã lệnh ra khỏi Dữ liệu là sống khỏe. Zeno chúc anh em code an toàn, không bao giờ bị dính Injection nha! Hẹn gặp lại ở các bài OWASP tiếp theo! :D


Tài liệu tham khảo

Chủ đề của bài viết

Chia sẻ bài viết

Gửi bài này cho người đang cần đúng chủ đề, hoặc lưu lại để quay về sau.

Đọc tiếp

Tất cả bài viết

Tiếp theo

Gợi ý bài tiếp theo

Tản mạn về VPN

Mở ứng dụng VPN lên, mình lập tức có thể xem một bộ phim bị giới hạn khu vực, hay an tâm lướt web tại một quán cà phê công cộng mà không sợ bị dòm ngó tài khoản …

Mở bài tiếp theo

Thảo luận