12/08/2018, 17:13

Khắc phục điểm yếu SQL Injection trong Rails

Các bạn có bao giờ viết một scope như này không? scope :find_user, ->(name){where "user = #{name}"} Nếu bạn đã viết và sử dụng nó trong dự án thì xin chia buồn, độ bảo mật của dự án là cực kỳ thấp. Đây là một trong những lỗi SQL Injection cực kỳ cơ bản. Các lập trình viên đã nói về nó rất ...

Các bạn có bao giờ viết một scope như này không?

scope :find_user, ->(name){where "user = #{name}"}

Nếu bạn đã viết và sử dụng nó trong dự án thì xin chia buồn, độ bảo mật của dự án là cực kỳ thấp. Đây là một trong những lỗi SQL Injection cực kỳ cơ bản. Các lập trình viên đã nói về nó rất nhiều. Nhưng với một số người mới bước chân vào lập trình thì khi luyện tập, không ai dạy cho họ cách viết sao cho đúng. Trong bài viết này mình sẽ giải thích cho các bạn SQL Injection là gì, tại sao cần phải sửa và sửa như nào cho đúng.

Nhược điểm của SQL Injection là gì

SQL Injection là một kỹ thuật lợi dụng những lỗ hổng về câu truy vấn lấy dữ liệu của những website không an toàn trên web, đây là một kỹ thuật tấn công rất phổ biến và sự thành công của nó cũng tương đối cao. Nó nằm trong top 10 các lỗ hổng Injection được liệt kê trong OWASP. Tuy nhiên vào những năm gần đây đã xuất hiện nhiều framework đã hỗ trợ rất tốt việc bảo mật SQL Injection này. Trong rails, các bạn có thể thấy một ví dụ của SQL Injection như sau:

User.where("email = #{params[:email]}")

Đó là tất cả những gì cần thiết cho một hacker để truy nhập vào toàn bộ DB của bạn. Các bạn có thể nghi ngờ rằng: "Một câu lệnh như thế thì có thể gây ra ảnh hưởng gì?". Tin mình đi, mình sẽ chứng minh cho các bạn thấy ngay bây giờ.

Tấn công cơ bản

params[:email] là do client gửi lên, điều đó đồng nghĩa client có thể viết bất cứ thứ gì họ muốn vào trong câu query của bạn. Ví dụ:

# http://domain.com/query?email=') or 1=1--
email = "') or 1=1--"

@user = User.where("email = #{email}").first
render @user

#=> #<User id: 1, email: "a@a.com", name: "A", admin: false, created_at: "2015-10-02 13:14:38", updated_at: "2015-10-02 13:14:38">
  • Phần đầu tiên ') của params làm cho query trả về kết quả rỗng, email = '
  • Tiếp theo, 1=1 luôn là true, trả về user đầu tiên trong bảng User.
  • Cuối cùng, -- là một comment của SQL. Đây là kỹ thuật để hủy bỏ bất kỳ sự thay đổi truy vấn nào có thể xảy ra ở phía server. Hầu hết các phương pháp tấn công SQL Injection sẽ theo format:
  1. Kết thúc query.
  2. Chèn sự tấn công.
  3. Ngăn cản sự chỉnh sửa từ phía server. Trông thì có vẻ bình thường, nhưng hacker có thể sử dụng nó để lấy các thông tin quan trọng hơn. Hãy xem một ví dụ nữa:
# http://domain.com/query?email=') or admin='t'--
email = "') or admin='t'--"

@user = User.where("email = #{email}").first
render @user

#=> #<User id: 193, email: "admin1@email.com", name: "Admin1", admin: true, created_at: "2015-09-28 01:33:39", updated_at: "2015-09-28 01:58:35">

Sử dụng params[:email] với giá trị như trên, hacker có thể dễ dàng lấy được thông tin tài khoản admin.Và khi một khi tài khoản admin bị hack thì các bạn cũng biết điều gì sẽ xảy ra rồi đấy.

Enumerating

Không chỉ vậy, để có thể lấy được hết danh sách các tài khoản admin trong DB vô cùng đơn giản bằng cách sử dụng một id filter

# http://domain.com/query?email=') or admin='t' and id > 193--
payload = "') or admin='t' and id > 193--"

@user = User.where("email = #{payload}").first
render @user

#=> #<User id: 291, email: "admin2@email.com", name: "Admin2", admin: true, created_at: "2015-09-28 01:33:39", updated_at: "2015-09-28 01:58:35">

Ở đây kẻ tấn công chỉ cần thêm and id >193 để lấy thông tin tài khoản admin khác, và cứ làm như vậy cho đến khi lấy được hết thông tin về tất cả các tài khoản admin. Đến lúc này bạn có thể nghĩ rằng:

"Bảng user bị lộ, nhưng mình đã mã hóa mật khẩu nên ít nhất thiệt hại cũng chỉ nằm ở bảng đó. Mọi chuyện nghe có vẻ cũng không quá tệ nhỉ"

Nếu bạn suy nghĩ như vậy thì mình rất tiếc khi phải thông báo cho bạn rằng: Đây mới chỉ là bước khởi đầu thôi, các kẻ tấn công có thể làm mọi thứ trở nên tồi tệ hơn rất nhiều.

Lấy thông tin về các bảng khác

Bước đầu tiên để lấy thông tin từ bảng khác là phải xác định các bảng đó. Bước này tùy thuộc vào database mà bạn sử dụng. Tuy nhiên, các loại DB đều có các chức năng tương tự nhau mà mình sẽ nói ở dưới. Ở bài vietes này mình sử dụng DB mặc định của rails để làm ví dụ (sqlite 3). Làm thế nào để những kẻ tấn công có thể xác định được các bảng khác? Câu trả lời là dựa vào bảng sqlite_master Bảng này sẽ liệt kê toàn bộ các thông tin của DB bao gồm các bảng và index. Để truy cập các thông tin này thì những kẻ tấn công sẽ làm như sau, các bạn hãy chú ý phần payload:

# http://domain.com/query?email=') union select 1,name,1,1,1,1 from sqlite_master--
payload = "') union select 1,name,1,1,1,1 from sqlite_master--"

@user = User.where("email = #{payload}").first
render @user

#=> #<User id: 1, email: "schema_migrations", name: "1", admin: true, created_at: 1, updated_at: 1>

Kỹ thuật đầu tiên là thêm toán tử union. Đây là một toán tử của SQL cho phép kết hợp kết quả của 2 câu query select. Bên cạnh đó, ta cũng thấy một kỹ thuật query mới để query bảng hệ thống :

select 1,name,1,1,1,1 from sqlite_master--

Câu query trên cho phép những kẻ tấn công chọn tên cột từ bảng sqlite_master, và điền giá trị 1 vào các cột đó, nếu không DB sẽ trả về một exception.

SELECTs to the left and right of UNION do not have the same number of result columns:
SELECT "users".* FROM "users" WHERE (email = ') union select
name, 1, 1, 1, 1 FROM sqlite_master--') ORDER BY "users"."id" ASC LIMIT 1

Câu query cuối cùng được gửi tới DB như sau:

SELECT "users".* FROM "users" WHERE (email = ')
  UNION
SELECT 1,name,1,1,1,1 FROM sqlite_master--')  ORDER BY "users"."id" ASC LIMIT 1

Hãy nhớ rằng query đầu tiên được gửi đến bảng users không trả về một kết quả nào vì thế kết quả của câu query thứ 2 sẽ được hiểu như là một User và sẽ được điền thông tin như là một user object với thông tin từ sqlite_master. Đặc biệt, payload được tạo để trường name tương ứng với trường email trong User. Trong ví dụ vừa rồi kết quả là : email: "schema_migration" không có ích gì. Tất nhiên những kẻ tấn công có thể sử dụng kỹ thuật enumeration đã nói ở trên để duyệt qua toàn bộ bảng sqlite_master, nhưng cách này sẽ rất chậm. Thay vào đó, chúng sẽ sử dụng một cách đơn giản hơn, đó là chỉnh sửa lại params để sử dụng function và lấy thông tin toàn bộ bảng trong một lần duy nhất.

# http://domain.com/query?email=') union select 1,group_concat(name, ','),1,1,1,1 from sqlite_master--
payload = "') union select 1,group_concat(name, ','),1,1,1,1 from sqlite_master--"

@user = User.where("email = #{payload}").first
render @user

#=> #<User id: 1, email: "users,credit_cards,schema_migrations,unique_schema_migrations,sqlite_sequence", name: "1", admin: true, created_at: 1, updated_at: 1>

Đoạn code trên sử dụng function group_concat được cung cấp bởi sqlite3 để kéo toàn bộ bảng vào trong một giá trị: users, credit_cards, schema_migrations, unique_schema_migrations, sqlite_sequence. Và bây giờ thì kẻ tấn công đã có toàn bộ thông tin về các bảng trong DB của bạn, bao gồm cả bảng credit_cards

Truy cập các bảng khác

Bây giờ thì kẻ tấn công đã biets là bạn có bảng credit_card, hắn sẽ lấy thông tin nhiều nhất có thể. Sử dụng kỹ thuật union tương tự như ở trên

# http://domain.com/query?email=') union select 1,number, 1, 1, 1, 1 FROM credit_cards--
payload = "') union select 1,number,1,1,1,1 FROM credit_cards--"

@user = User.where("email = #{payload}").first
render @user

#=> #<User id: 1, email: "4242 4242 4242 4242", name: "1", admin: true, created_at: 1, updated_at: 1>

Bây giờ thì bạn đã bắt đầu cảm thấy sợ rồi chứ. Số thẻ credit card đã hiển thị ở trường email rồi đó. Và bây giờ thì bữa tiệc của những kẻ tấn công mới bắt đầu. Và câu hỏi bây giờ chỉ đơn giản là chúng mất bao lâu để để dump toàn bộ database của bạn mà thôi.

Cách khắc phục.

Sau những gì mà mình trình bày ở trên thì hẳn bây giờ các bản đã cảm thấy tầm quan trọng của việc fix lỗ hổng SQL Injection rồi chứ. Cách khắc phục cũng khá đơn giản, đó là sử dụng parameterization. Đây là cách an toàn nhất để xử lý input không an toàn từ user. Và nếu bạn sử dụng ActiveRecord, Sequel, ROM, hoặc các ORM khác thì tất cả đều phải sử dụng parameterizing queries. Hãy cùng tham khảo một số trường hợp thường gặp dưới đây nhé (tất cả đều dựa trên ActiveRecord)

Single Parameter Queries

Đây là trường hợp phổ biến nhất thường gặp trong ruby:

# Unsafe
User.where("email = '#{email}'")
User.where("email = '%{email}'" % { email: email })

# Safe
User.where(email: email)
User.where("email = ?", email)
User.where("email = :email", email: email)

Bạn có thể thấy dòng 3 gần giống với dòng 8, điểm khác n hau duy nhất là dòng 3 sử dụng string formatting thay vì parameterization khiến cho việc SQL injection trở nên không an toàn.

Compounding Queries

Một vài trường hpojw bạn sẽ phải sử dụng đến toán tử AND

# Unsafe
def unsafe_query
  sql = []
  sql << "email = #{email}" if condition1?
  sql << "name = #{name}"   if condition2?
  # ... etc

  User.where(sql.join(' and '))
end

# Safe
def safe_query
  User.all.tap do |query|
    query.where(email: email) if condition1?
    query.where(name: name)   if condition2?
    # ... etc
  end
end

ActiveRecord vô cùng tuyệt vời khi bởi vì nó cho phép chúng ta dễ dàng nối các query lại với nhau. Và toán tử OR cũng tương tự

# Unsafe
def unsafe_query
  sql = []
  sql << "email = #{email}" if condition1?
  sql << "name = #{name}"   if condition2?
  # ... etc

  User.where(sql.join(' OR '))
end

# Safe
def safe_query
  sql   = []
  param = []

  if condition1?
    sql << "email = ?"
    param << email
  end

  if condition2?
    sql << "name = ?"
    param << name
  end

  User.where(sql.join(' OR '), *param)
end

LIKE Query

LIKE cũng là một trong các toán tử phổ biến mà chúng ta hay gặp:

# Unsafe
User.where("email LIKE '%#{partial_email}%'")

# Safe
User.where("email LIKE ?", "%#{partial_email}%")

Raw Queries

Khi các toán tử trên đều bó tay thì chúng ta đành phải sử dụng đến biện pháp cuối cùng: raw query.

# Unsafe
st = ActiveRecord::Base.connection.raw_connection.prepare(
  "select * from users where email = '#{email}'")
results = st.execute
st.close

# Safe
st = ActiveRecord::Base.connection.raw_connection.prepare(
  "select * from users where email = ?")
results = st.execute(email)
st.close

Trên đây là những kinh nghiệm và tìm hiểu của mình bởi mình cũng đã từng rất chật vật vì không pass nổi CI do SQL Injection trong quá trình làm việc. Hi vọng bài viết này có thể giúp các bạn phần nào. Nguồn: http://gavinmiller.io/2015/fixing-sql-injection-vulnerabilities/

0