Sau khi đọc bài viết băm mật khẩu đúng cách của anh thaidn, mình nhớ lại lúc mình mới ra trường, cũng đã từng nghĩ về vấn đề này (lúc đó mình khá thích môn Bảo Mật Thông Tin ở trường) nhưng chưa bao giờ hiểu tường tận. Chỉ biết là không nên:

  • Lưu password ở dạng plain-text.
  • Hash với một thuật toán hash mạnh, không nên xài MD5, SHA-1 …
  • Hash với salt.

Chỉ hiểu rằng phải nên làm thế, nhưng không hiểu tại sao lại như vậy và một số câu hỏi khác cũng chưa trả lời được như:

  • Password user gửi lên, nên hash ở client hay ở server.
  • Salt nên lưu ở đâu.
  • Salt có cần giữ bí mật hay không?
  • Salt chung cho tất cả, hay salt riêng cho từng user?

Hôm nay, mình quyết định đi tìm câu trả lời cho những vấn đề mình thắc mắc, thay vì mặc định nó đúng.

1. Tại sao không nên lưu plain-text, encrypt hoặc dùng MD5, SHA-1

Nếu lưu plain-text, database bị hack, SQL-injection, password user chìa ra theo một cách không thể dễ dàng hơn để đánh cắp.

Nếu mã hóa 2 chiều, sẽ luôn có một cách để giải mã bằng một chìa-khóa nào đó, sẽ phải tìm cách lưu chìa-khóa một cách an toàn.

MD5 và SHA-1 được chứng minh có đụng độ, nghĩa là 2 password khác nhau, khi hash bằng MD5 hoặc SHA-1 có thể ra cùng một chuỗi.

2. Tại sao phải salt

Ta đã biết, hash algorithm là one-way-function, tức là không thể suy ngược trực tiếp ra password nếu có hash_value (khác với mã hóa, có thể giải mã thông qua chìa-khóa).

Tuy nhiên vẫn có cách để từ hash_value có thể suy gián tiếp ra được password ví dụ brute-force attack, dictionary attack -> điểm chung là ta cần thửđoán password nhiều lần cho tới khi đúng cái cần tìm.

Một cách khác đó là ta có thể tính toán trước giá trị hash của tất cả các trường hợp và của tất cả các thuật toán -> cách này khó, tốn thời gian, nhưng bây giờ với tốc độ tính toán của máy tính, ta vẫn có thể làm được. Bảng lưu trữ password + hash_value của password gọi là Rainbow Table, có thể tự tạo hoặc tải một số bản miễn phí hoặc trả tiền để mua. Từ bây giờ nếu ta có hash_value ta có thể mapping để suy ra được password.

Tuy nhiên nếu ta chỉ hash mỗi password, ta gặp vấn đề đó là:

  • 2 password giống nhau (user vô tình trùng password) thì chuỗi hash(password) sẽ giống nhau.
  • User cố tình đặt password đơn giản và phổ biến (ví dụ password < 4 kí tự, toàn số, toàn chữ) -> dễ nhớ cho user nhưng dễ tra ngược.

Và nếu chỉ hash password thì nếu mất hash_value, có thể tra trong rainbow table để tìm ra được password của người dùng.

Giờ ta thử thay vì hash(password) ta sẽ hash(salt + password):

Từ md5(123456)

id |          hash_md5                |
---------------------------------------
1  | e10adc3949ba59abbe56e057f20f883e |

Thành md5(7nWZLcCK0vsPzIM + 123456)

id |          hash_md5                |    salt         |
---------------------------------------------------------
1  | 0510210d4b370165658bdc0d0b005244 | 7nWZLcCK0vsPzIM |

Giờ giả sử, ta mất bảng dữ liệu gồm hash_md5, salt, kẻ tấn công sẽ phải tính toán lại rainbow table của tất cả các trường hợp cộng với salt. Nếu salt là random cho từng user, kẻ tấn công sẽ phải tính toán toàn bộ trường hợp cộng với riêng từng salt cho toàn bộ user.

Chi phí cho 2 phép tính trên là vô cùng lớn và tốn rất nhiều thời gian để thực hiện. Vậy tóm lại mục đích của salt và random-salt là:

  • Bảo vệ user kể cả khi user dùng password phổ biến và password không mạnh vì user không thể nhớ được các password phức tạp nhưng tốc độ tính toán của máy tính thì càng ngày càng nhanh.
  • Tạo ra nhiều chi phí tính toán, kẻ tấn công không thể tính toán trước rainbow table.

=> Ta trả lời đc 3 câu hỏi:

  • Salt có thể lưu trong database, cùng với hash_value.
  • Không cần tìm cách giữ bí mật salt, nhưng cũng đừng tự ý công khai salt.
  • Nhưng bắt buộc phải random salt cho từng user.

3. Hash ở đâu?

Giờ giả sử hash(password) ở client-side thì vấn đề là gì?

  • Biết được thuật toán dùng để hash.
  • Salt sẽ phải sinh ra ở client, vì ta cần hash password với salt (hash(salt + password)), và db chỉ lưu kết quả hash, không lưu salt.
  • Nhưng nếu salt sinh ra ở client và salt random thì làm sao để compare với hash_value trong database? Vì lần chứng thực tới, salt sẽ lại random và sẽ khác với kết quả trong database -> salt phải duy nhất cho tất cả các trường hợp.
  • Hoặc salt có thể lưu ở DB, nhưng server phải gửi salt về trước cho user trước khi thực hiện hash -> dễ dàng biết được salt hơn.

Nhìn sơ thì thấy việc dùng duy nhất một salt đã chống lại luận điểm ở mục số 2. Vậy quy trình chứng thực đúng là như thế nào?

  • User sẽ gửi plain-text password lên server và over HTTPs.
  • Server sẽ kiểm tra trong database lấy ra salt của user đó, cộng chuỗi ta được salt + password.
  • Thực hiện hash(salt + password) trên server side.
  • Compare kết quả trên với hash_md5 trong database.

4. Tại sao dùng bcrypt thay cho SHA-512

Kết quả của SHA-512 có độ dài 128 kí tự, độ dài của key là 64 bytes. Trông có vẻ cũng khá chắc chắn, vậy tại sao OWASP recommend sử dụng PBKDF2, bcrypt hoặc scrypt hơn là SHA2?

SHA2 là hash algorithm (tất nhiên), nó được thiết kế với mục tiêu là tốc độ, với các CPU hiện đại, có thể generate hàng triệu kết quả trên giây. Nếu dùng một thuật toán có tốc độ như SHA2 tức là bạn đã đem lợi điểm tới cho kẻ tấn công brute-force. Thuật toán nhanh + cấu hình server mạnh, việc brute-force càng trở lên nhanh chóng hơn.

Trong khi đó, bcrypt được gọi là slow-hash algorithm, bcrypt() mất 100ms để tính toán ra chuỗi hash, chậm hơn 10.000 lần so với sha1().

Có nghĩa là vẫn đạt được mục đích hash nhưng giảm thiểu nguy cơ tấn công brute-force.

Tóm lại: SHA-512 không phải là một thuật toán yếu, mà vấn đề là SHA-512 không phù hợp cho việc hash password. Nếu cần hash password thì ta nên dùng các thuật toán slow hash như PBKDF2, bcrypt và scrypt.

5. Tại sao cần Pepper?

Một thực tế là nếu bạn chỉ có “muối” mà không có “tiêu”, ăn thịt gà luộc sẽ không ngon :v. Giả sử, database bạn chạy RAID-1, một ổ cứng hư và cần thay một ổ cứng mới. Nhưng như ta biết, đĩa bị hư là mirror của đĩa còn lại, bạn phải tiêu hủy ổ cứng hư đó nếu không ai đó có thể lục thùng rác và tái tạo lại một phần dữ liệu trong đĩa hư đó.

Xin lưu ý, bạn cần wipe trước khi vứt bỏ một ổ cứng có dữ liệu dù cá nhân hay server, tuy nhiên đĩa bị sốc điện, bad-sector thì wipe cũng chưa đủ an toàn, tốt nhất nên ngiền ra bã.

Dù random-salt đã làm tăng chi phí tạo ra rainbow table nhưng đời không biết đâu mà lần, kẻ tấn công luôn có những động lực không tưởng để đạt được cái mình muốn. Giả sử kẻ tấn công có một siêu siêu máy tính và một mirror ổ cứng hư lục từ một cái thùng rác nào đó. Với siêu máy tính đó, ta có rainbow-table để tra ngược ra password cần tìm.

Vậy làm sao để giảm thiểu nguy cơ trên? Nguyên tắc là không bỏ tất cả trứng trong một giỏ, đó là pepper. Pepper là một chuỗi tương tự như salt, nhưng khác biệt là ta cần giữ bí mật pepper, lưu ở một chỗ khác ngoài database, và không cần pepper-per-user, chỉ cần 1 pepper là đủ.

Từ

hash(salt + password)

Thành

hash(pepper + salt + password)

Ta nên lưu pepper ở application hoặc ở một service khác, nếu database bị compromise, thì kẻ tấn công cũng không có pepper để tạo ra rainbow-table.

6. Bonus

layersSource: https://blogs.dropbox.com/tech/2016/09/how-dropbox-securely-stores-your-passwords

Trong khi tìm câu trả lời để viết bài này, mình tìm được một bài blog của dropbox nói về cách họ lưu password như thế nào. Thấy có 2 điểm khá hay nên muốn nói thêm.

  • Trước khi hash password với salt-per-user, họ có SHA512(password) trước để cố định độ dài của input-password. Theo Dropbox thì việc này giải quyết 2 issues của bcrypt
    • Một số implementation của bcrypt cắt đầu vào còn 72 bytes.
    • Một số implementation khác của bcrypt thì không cắt đầu vào nhưng dẫn tới một vấn đề khác là DoS attack bởi vì cho phép độ dài mật khẩu tùy ý.
  • Dropbox cũng có global pepper nhưng thay vì dùng nó để hash thì họ encrypt. Tức là thay vì hash(pepper + salt + password) thì họ AES256(salt + password) + global-pepper-key. Theo như họ giải thích thì pepper là một lớp phòng thủ sâu hơn và lưu trữ ở một nơi riêng biệt. Nhưng đồng nghĩa với việc là nơi lưu trữ pepper vẫn có thể bị compromise, và khi bị compromise thì việc rotate key không dễ dàng. Dùng pepper để encrypt vẫn đạt được mục đích bảo mật tương tự nhưng thêm khả năng rotate key khi bị compromise.

7. Migrate

Sau khi viết bài này, bạn @vanhtuan0409 có một câu hỏi về việc làm thế nào migrate một hệ thống dùng SHA1 –> bcrypt. Mình thấy đây là một điểm cũng khá cần thiết nên xin phép note thêm.

Ở đây có 2 trường hợp:

  • Hash password bằng SHA1 nhưng không có salt -> mình gọi là sha1_value.
  • Hash password bằng SHA1 + trong DB có salt-per-user, mình có sha1_value, salt.

Về ý tưởng thì ta sẽ dùng SHA1 như cách Dropbox dùng SHA512 để cố định độ dài input.

Với trường hợp đầu ta cần sinh ra thêm salt-per-user và migrate bcrypt(salt, sha1_value) trong đó sha1_value = SHA1(password) -> Tương tự cách của dropbox, may mắn là do sai thiết kế từ đầu (thiếu salt) nên migrate dễ.

Với trường hợp thứ 2 thì hơi lằng nhằng hơn 1 xíu, ta cần migrate kiểu bcrypt(salt, SHA1(salt, password)) nếu thực sự muốn bcrypt với salt hoặc thực ra chỉ cần bcrypt(SHA1(salt, password) cũng được. Tùy tình huống bạn cần tradeoff có thể lựa chọn cách phù hợp.

8. Thông tin của bạn có an toàn?

Như ta thấy, ta đã làm rất nhiều thứ để đảm bảo rằng thông tin của chúng ta trở lên an toàn. Vậy thực sự thông tin của chúng ta đã an toàn hay không? Câu trả lời rất tiếc là KHÔNG? Thông tin của chúng ta có thể an toàn với “bên ngoài” nhưng không an toàn với “bên trong”. Tại sao lại vậy?

Giờ bắt đầu với thông tin cơ bản nhất như địa chỉ, email, sở thích … Khi ta build staing environment, thông tin user được copy sang một môi trường khác, developer có thể overwrite hash_value của user bằng value của hash(salt + 654321) và dễ dàng đăng nhập trên staing environment với password là 654321 và tất cả các thông tin cơ bản của user đã có thể đọc được. Nhưng ít nhất developer vẫn không biết chính xác password của user là gì.

Nhưng, lại là nhưng. Người quản trị hệ thống có mọi quyền trên server mà họ quản trị, password dù đã over HTTPs nhưng tới server vẫn là plain-text. Vẫn có thể capture request ngay ở đầu server và output ra plain-text password của user và cuối cùng thì người quản trị hệ thống vẫn biết được chính xác password của user là gì. Đó là lí do tại sao ta không nên dùng một password cho tất cả các dịch vụ.

Vậy làm thế nào để bảo vệ info của user trong trường hợp này, rất tiếc về mặt kỹ thuật không có cách nào để đảm bảo việc này. Ta chỉ có thể áp dụng policy, NDA về mặt con người để hạn chế vấn đề này thôi.

9. Ref

Lưu ý:

  • Trong bài viết dùng MD5 để minh họa cho đơn giản, trong thực tế ta không dùng MD5.
  • Trong bài viết giả định kẻ tấn công bằng cách nào đó có db users, trong thực tế việc này rất khó do có các tầng bảo mật khác như thiết kế infrastructure, define rule/policy về con người … Trong bài này mình không bàn đến các vấn đề đó.