+13

Phân tích về các vấn đề bảo mật phổ biến và cách giải quyết khi phát triển Backend RestAPI

Lời nói đầu

Xin chào mọi người, mình tự giới thiệu sơ qua về bản thân, mình trước đây học ngành an toàn thông tin, sau khi ra trường thì hiện mình đang làm kỹ sư R&D tại một cơ quan chuyên về an toàn thông tin của chính phủ. Bây giờ mình dần thiên về lập trình (dev) hơn là kiểm thử bảo mật (pentest), có lẽ là do cuộc đời đưa đẩy, nhưng cũng chẳng hoàn toàn là như thế, bởi lẽ mình thấy nếu như biết một chút về bảo mật, làm dev sẽ có cảm giác an toàn hơn, nhưng chỉ là cảm giác thôi, đừng chủ quan nhé!

Những ai làm về bảo mật chắc thuộc lòng Top 10 lỗ hổng bảo mật của OWASP rồi. Nhưng trong bài viết này, mình sẽ không đi quá sâu về các kỹ thuật mà Hacker sử dụng để thăm dò, khai thác các lỗ hổng của backend theo hướng chuyên môn cao, và cũng sẽ không nêu ra một tiêu chuẩn nào về lập trình để bảo mật hơn cả, bởi vấn đề về bảo mật là vấn đề ở chính lập trình viên chứ không phải là của ngôn ngữ lập trình. Nội dung phía dưới này sẽ mô tả các lỗi bảo mật mà mình thấy phổ biến theo mức độ mà có thể những người không làm về an toàn thông tin hay lập trình viên cũng có thể hiểu được. Tuy nhiên mình vẫn sẽ giữ lại một vài từ ngữ chuyên ngành bằng Tiếng Anh vì như thế sẽ bộc lộ được đầy đủ ý nghĩa hơn.

Lý do viết ra bài này là vì sau một thời gian đi làm, cũng "nhảy" đâu đó một vài nơi, và mình mới nhận ra, hầu hết các dev đều ít, hoặc không có khái niệm quá cụ thể về việc làm sao để code cho bảo mật. Hầu hết chỉ cần code input cho ra được output, rồi thì cùng lắm có thêm phần xác thực, thêm tý nữa là phân quyền, bug đến đâu thì sửa đến đó... Một phần lý do ở đây là chương trình đào tạo lập trình thì không thể đi sâu đến mức làm sao để "Secure Coding", thứ 2 nữa là giới hạn nghiệp vụ của Tester. Nói đến đây các bạn Tester sẽ kiểu: "ủa, bọn tao chỉ test đúng test case thôi là được chứ". À thì đúng rồi, công việc của Tester là họ sẽ test theo đúng test case thôi, chứ đâu test theo chuẩn bảo mật, miễn sao pass hết test case thì đẩy lên production thôi. Nhưng Hacker và Pentester thì họ lại không nghĩ vậy, và thật không may, đa số các dự án phần mềm thì lại không tồn tại bất kỳ 1 Hacker Mũ Trắng hay Pentester xịn xò nào để hỗ trợ họ kiểm thử trước khi ra mắt sản phẩm cả. Cho nên các vấn đề về bảo mật phổ biến dưới đây sẽ rất dễ gặp phải ở một số dev code backend nào đó mới ra trường, hoặc ít kinh nghiệm, mà chính bản thân mình cũng đã từng trải qua như vậy.

Vẫn câu chuyện cũ thôi

Chẳng phải ngẫu nhiên mà SQL Injection vẫn là cái thứ quái quỷ gì đó mà những bài viết về bảo mật cho website từ thuở sơ khai đến giờ vẫn nói dai dẳng, bởi vì hậu quả của nó có lẽ là nặng nề và cũng là phổ biến nhất . Nếu ai code backend đủ nhiều thì có lẽ sẽ hiểu, database là tâm hồn của cả hệ thống, nó là cái nôi để sinh ra vô vàn tính năng cho người dùng, chưa kể đến việc phân tích dữ liệu để phục vụ các nghiệp vụ thống kê cho doanh nghiệp, một khi database rơi vào tay kẻ xấu thì coi như xác định là toang!!! SQL Injection là cái cánh cửa để đi vào database của Hacker. Một hệ thống backend đã bị SQL Injection thì hacker có thể thực hiện các câu truy vấn database tùy chỉnh để tương tác với database của nạn nhân, tất nhiên là bao gồm thêm, xóa, sửa. Hầu hết backend bị lỗi này khi dev viết raw query khi thực hiện truy vấn database trong 1 function nào đó, và có truyền thêm tham số, nếu câu truy vấn đó là cộng chuỗi các tham số trong các câu điều kiện thì xin chia buồn, hệ thống của bạn có thể sẽ là nạn nhân của SQL Injection. Nghe thì có vẻ cũng nguy hiểm đấy nhưng cách để tránh nó cũng dễ thôi. Tính đến thời điểm hiện tại thì hầu hết các ngôn ngữ lập trình phía backend đều đã đưa ra các giải pháp để giải quyết lỗi này, và về cơ bản thì bạn đừng tự tin viết raw query nữa, mà hãy dùng 1 thư viện có sẵn các hàm để thay thế việc làm ngu ngốc đó, ví dụ như TypeOrm của NodeJs chẳng hạn. Việc duy nhất bạn cần làm đó là viết theo đúng chuẩn mà các framework họ đề ra, sau kiểm tra lại theo cách này trước khi triển khai lên máy chủ thật thôi.

Mã hóa mật khẩu trước khi lưu vào database

Mục tiêu của việc làm này là thay vì lưu mật khẩu người dùng ở dạng bản rõ, ví dụ như 12345 thành 1 dạng các chữ cái và số chèn đan xen nhau lộn xộn khó đọc, để có mất database thì Hacker cũng không biết được mật khẩu, hoặc đơn giản hơn là cái ông quản trị database cũng không biết. Bản chất của việc đăng nhập là đơn giản kiểm tra 2 chuỗi với nhau, đầu tiên là chuỗi mà người dùng nhập vào ô mật khẩu và gửi lên api POST /login, thứ 2 là chuỗi của trường password trong database, truyền chuỗi 1 vào hàm băm nếu bằng chuỗi 2 thì là mật khẩu đúng, và ngược lại.

Nhưng nghe này, làm ơn đừng dùng MD5 nữa được không ?. Công nghệ đã trải qua 1 khoảng thời gian đủ dài để phát triển và đa số các hệ thống trước đây đều băm mật khẩu bằng MD5. Bây giờ thì ở đâu đó cũng chia sẻ rộng rãi hầu hết các chuỗi băm của MD5 của các mật khẩu phổ biến rồi, chỉ cần tìm trên Google là có thể ra vô vàn kết quả. Hãy thử chuyển sang băm mật khẩu bằng bcrypt thử xem, tuy nhiên bạn sẽ phải làm nhiều việc hơn là việc chỉ truyền chuỗi vào hàm MD5 rồi thản nhiên lưu vào database. Xem thêm 1 bài viết về phần này trên Viblo.

Thật ngốc nghếch khi chỉ sử dụng 1 khóa bí mật (secret key) cho tất cả Json Web Token (JWT)

Như các bạn đã biết, chúng ta đã qua cái thời gọi là máy chủ trả về HTML và hiển thị dữ liệu trên giao diện đã render kèm theo CSS, Javasript được tải kèm theo ngay sau đó (kiểu MVC truyền thống), cái thời điểm mà người ta vẫn chủ yếu dùng cookie, session để xác thực cho 1 người dùng đăng nhập vào hệ thống. Nhưng vì bởi lẽ các ứng dụng di động phát triển quá nhanh, và các framework, thư viện kiểu như Angular, ReactJs được sử dụng rộng rãi để tạo ra các website dạng SPA (Single Page Application). Vấn đề ở đây là máy chủ không còn nắm trạng thái đăng nhập của người dùng trên website nữa, vì bây giờ hai thứ này không còn nằm trong một hệ thống chung. Muốn xây dựng backend chung cho cả ứng dụng di động và website thì không thể chơi MVC mãi được mà phải chuyển sang RestAPI. Lúc này câu chuyện về xác thực cho API nó khác hoàn toàn với những gì chúng ta đã làm trước đây, một trong cách phổ biến nhất có lẽ là cấp JWT cho người dùng mỗi khi đăng nhập thành công. Sau đó JWT sẽ được lưu trữ ở phía người dùng và sẽ gắn vào Header của HTTP Request mỗi lần yêu cầu gọi đến API.

Tạm bỏ qua về ưu điểm, nói về nhược điểm của JWT, đã cấp rồi thì cái token đấy sẽ được dùng cho đến khi hết hạn, câu chuyện là nếu chưa hết hạn mà muốn thu hồi token thì sao...? Chịu thôi, vì token được tạo ra đã bao gồm cả thời gian hết hạn và không thể chỉnh sửa được, Hacker bằng cách nào đó mà có được token thì coi như xong. cứ gắn vào header rồi request thôi. Nếu sử dụng JWT theo cách thông thường, sẽ chẳng bao giờ xuất hiện chức năng "Đăng xuất khỏi mọi thiết bị". Bản chất của "Đăng xuất khỏi mọi thiết bị" là làm cho các token của các thiết bị đã đăng nhập không còn hợp lệ nữa và trả về status 401 (Unauthorized ) thôi. Tuy nhiên sẽ làm được chức năng này với JWT nếu tạo cho mỗi tài khoản 1 chuỗi secret key ngẫu nhiên riêng biệt. Chúng ta sẽ có 1 cặp userId và secretKey, khi phía người dùng request đến api thì sẽ lấy secret key này ra để truyền vào hàm verify token. Điều này cũng đồng nghĩa với việc, khi cập nhật 1 secretKey mới vào database cho userId này thì các token cũ được lưu ở phiên đăng nhập trước đều không còn tác dụng. Trường secretKey sẽ được cập nhật lại mỗi khi người dùng đổi mật khẩu, khi đăng xuất mọi thiết bị, hoặc khi hệ thống phát hiện đăng nhập bất thường... Để giải quyết luôn các vấn đề query nhiều vào database chỉ để lấy secretKey ra check auth thì có thể sử dụng cache và lắng nghe các sự kiện nêu trên và cập nhật lại vào bộ nhớ cache.

Nhớ tắt Swagger (OpenApi) khi triển khai môi trường production

Cho ai chưa biết, thì Swagger là 1 trang kiểu dạng API Documents được tạo ra tự động dựa trên các điểm cuối của api. Có lẽ đây là món quà tuyệt vời nhất mà lập trình viên backend dành tặng cho lập trình viên frontend. Đây là nơi để cho các lập trình viên frontend xem chi tiết cách sử dụng như url, truyền tham số, phương thức, header... mà backend tạo ra thay vì dùng Exel 😃. Nhưng nó chỉ thực sự phát huy hết tác dụng khi bạn sử dụng trong môi trường phát triển (development). Vì một lý do nào đó mà lập trình viên backend quên mất là phải ẩn trang này đi khi triển khai sản phẩm (production) thì có nghĩa là ai cũng có thể xem được, và trong đó có cả Hacker. Nếu Hacker giỏi thì chắc cũng chẳng cần xem qua Swagger làm gì, nhưng nếu có thì mọi thứ sẽ trở nên dễ dàng hơn nhiều đấy. Burp Suite đã sẵn sàng....

CRUD generator là miếng bánh ngon của Hacker

Cùng với sự phát triển nhanh như vũ bão của các sản phẩm công nghệ thì quá trình phát triển cũng phải cần rút ngắn nhất có thể. Hiểu được vấn đề này nên hiện nay không ít các thư viện đã cung cấp cho lập trình viên một phép thuật, đó là chỉ cần Enter là có thể ra được một bộ code có đầy đủ các chức năng tạo, đọc, sửa, xóa (Create, Read, Update, Delete) cho các phần tài nguyên của database. Điều này giúp nhanh chóng tạo các api mà không cần phải code từng cái một, bao gồm cả việc kiểm tra đầu vào, truyền tham số, phân trang. Mục đích tạo ra các thư viện kiểu như thế này là tốt, nhưng nếu dev lạm dụng nó một cách "lười biếng", thì rất có thể nó sẽ là miếng bánh ngon của Hacker. Bởi vì nếu khi dev code từng cái một thì sẽ cẩn thận kiểm tra phân quyền hơn, nhưng nếu tự động tạo ra một loạt các end point vượt ra ngoài sự đề phòng đó thì rất dễ gây ra các lỗi vi phạm về phân quyền, thậm chí các api còn trả về các dữ liệu nhạy cảm như password, secret key... một cách vô thức.

Thường thì các CRUD sẽ đi kèm luôn các truy vấn liên bảng trong SQL luôn để "bạn chẳng cần phải làm gì thêm", vậy nên ví dụ nếu Hacker nắm được quy luật truyền tham số của các api thì mọi chuyện sẽ rất khó lường. Dĩ nhiên, khi tạo ra các thư viện thì người ta cũng đã tính hết các trường hợp này rồi, lỗi là tại "lười" đọc kỹ docs mà thôi. Vấn đề cũng chỉ xoay quanh việc làm sao để chỉ trả về các dữ liệu cần thiết ở mức hạn chế nhất, và phân quyền rõ ràng xem ai được gọi đến api đó, lưu ý về các api update, vì nếu không chặn, thậm chí hacker có thể update luôn cả email của người dùng thành email của chúng và sử dụng chức năng khôi phục mật khẩu, lúc này mã OTP sẽ gửi về email của hacker đấy nhé! Lỗi này thuộc một phần của IDOR mà mình sẽ nói rõ hơn ở ngay phía dưới này.

DIOR là hãng thời trang, còn IDOR thì nguy hiểm đấy!

Làm Pentester chắc chẳng lạ gì lỗi IDOR, và thường thì nó được "thịt" đầu tiên. IDOR là cụm từ viết tắt của Insecure Direct Object Reference (Tham chiếu đối tượng trực tiếp không an toàn) thuộc về dạng lỗ hổng Broken Access Control. Để hiểu sâu hơn về IDOR có thể xem tại đây. Hiểu nôm na là có thể truy cập các tài nguyên (thường là không thuộc phạm vi phân quyền của mình) bằng cách thay đổi tham số truyền vào.

Lỗi này xuất hiện dựa trên việc phân quyền cho các API không chặt chẽ. Không ít những trang web, đặc biệt các web của sinh viên ở Việt Nam dính phải lỗi này. Kiểu như thay đổi id sinh viên là có thể tải được thông tin sinh viên khác chẳng hạn. Đặc biệt nghiêm trọng hơn khi Hacker đoán được quy luật tạo ra các Id đó ví dụ như Id tự tăng từ 1->n, id là các mã sinh viên cũng có quy luật chung rất dễ và chỉ cần vài dòng code, Hacker có thể lấy tài nguyên đó 1 cách "hàng loạt". IDOR thì không chỉ dừng lại ở việc lấy dữ liệu trái phép, mà như đã nói ở phần trên, Hacker cũng có thể cập nhật dữ liệu, xóa dữ liệu mà không thuộc phạm vi phân quyền. Ví dụ như chỉ cần thay id bài đăng của người khác, và sửa xóa nội dung của họ thông qua API. Tự cập nhật phân quyền của mình mà đáng lẽ ra api này chỉ có Admin mới được phép gọi.

Còn nhiều lắm nhưng tóm lại để tránh được lỗi này thì trọng tâm vẫn là phân quyền cho các api thật cẩn thận, dựa vào token thì có thể xác định ai đang request đến api thông qua user id và quyền của id đó, và cũng từ id này thì cũng sẽ kiểm tra được xem có đúng quyền truy cập không, nếu không thì phải trả về status code 403 (forbidden ) ngay. Điều thứ 2 nữa là hãy sử dụng uuid kiểu (86333626-8def-11ed-a1eb-0242ac120002) thay vì dùng id tự tăng kiểu số tự nhiên (1,2,3,4,5,6) vì như thế sẽ khó đoán hơn...

Brute-force mật khẩu và câu chuyện về rate limit

Hmm, ai mà đọc đến tận phần này rồi thì chắc chắn đang tò mò về mấy cái bảo mật rồi đây. Bây giờ sẽ nói về brute-force, một dạng tấn công cũng phổ biến, thế mà các dev backend cũng ít khi để ý. Cứ nói đến các từ khóa kiểu "Tấn công", "Lỗ hổng", "Hacker" thì có vẻ cao siêu lắm, nhưng thực ra cái brute-force bản chất cũng chẳng có gì to tát cả, tuy nhiên tùy trường hợp thì cũng có lúc khá hiệu quả.

Mục tiêu của dạng tấn công này đa phần hướng đến chức năng đăng nhập là chính. Bạn đã bao giờ quên mật khẩu và cố nhập ra vài cái mật khẩu nhớ mang máng trong đầu và đều bị báo là sai, rồi bất ngờ một trong số đó thì lại đúng chưa ? Đó chính là brute-force, và có chăng Hacker sẽ làm việc đó theo số lượng lớn, có thể lên đấy cả trăm nghìn mật khẩu và thử cho đến khi đăng nhập thành công thì thôi, chủ yếu danh sách các mật khẩu mà Hacker sử dụng để "test" là các mật khẩu phổ biến, dễ đoán, và được thống kê top phổ biến theo hằng năm. Đôi khi cũng sẽ có các mật khẩu liên quan đến thông tin của nạn nhân kiểu nạn nhân tên Vân Anh, sinh ngày 08/11/1995 thì sẽ có các mật khẩu kiểu vananh081195, vananh95, vananh0811...

Để giải quyết vấn đề này thì có thể sử dụng rate-limit cho các api đăng nhập. Có nghĩa là ngoài việc dựa vào các yếu tố như tài khoản, mật khẩu mà người dùng gửi lên khi yêu cầu đăng nhập, chúng ta có thể kiểm tra thêm địa chỉ IP, user-agent để xác định xem có phải thiết bị này đang gửi yêu cầu đăng nhập sai quá nhiều lần và chặn lại sau một khoảng thời gian cụ thể. Cách làm thì cũng khá đơn giản. Nếu địa chỉ IP là XXX gửi yêu cầu đăng nhập cho username là YYY sai quá 2 lần thì sẽ lưu cặp này vào trong bộ nhớ cache của máy chủ. Các lần đăng nhập tiếp theo vẫn tiếp tục sai thì sẽ cộng cái số lần cho đến khi nào bằng 5, nếu lần sau vẫn sai thì sẽ dựa vào thời gian TTL (Time To Live) đã cộng dồn của cache để quyết định xem có cho phép đăng nhập trên địa chỉ IP này nữa không.

Hầu hết các tài khoản bị đánh cắp thông qua chức năng "Quên mật khẩu"

Trừ khi bạn xây dựng ra hệ thống backend chỉ mình bạn dùng, còn không nếu đã bắt người dùng tạo tài khoản và đăng nhập, thì phải kèm theo chức năng quên mật khẩu. Nhưng hãy nhớ rằng, nơi mà người dùng lấy lại mật khẩu cũng sẽ là nơi mà Hacker sẽ khai thác để "lấy hộ" mật khẩu cho người dùng. Bằng một cách ngớ ngẩn diệu kỳ nào đó mà đa số các hệ thống công nghệ lớn trên thế giới như GG, FB, LK, TT cũng đã phải trả bộn tiền thưởng cho các Bug Bounty Hunter khi tìm ra các lỗi bảo mật chiếm quyền tài khoản người dùng thông qua các chức năng khôi phục tài khoản, trong đó có quên mật khẩu. Vậy nên điều cần làm ngay lúc này là hãy kiểm tra ngay các chức năng khôi phục tài khoản trên hệ thống của bạn để xem còn lộ lọt trường hợp nào không nhé! Mình cũng đã gặp trường hợp gửi OTP cho người dùng để khôi phục mật khẩu, nhưng api xác thực OTP thì không rate-limit, và đương nhiên là có thể thử từ 000-000 đến 999-999. Mấu chốt của vấn đề không phải là hệ thống gửi OTP gửi về mail của ai, mà là ai xác thực đúng mã OTP 6 số trước và cập nhật mật khẩu khi khớp OTP, lưu ý nhé.

Tạm kết

Trên đây là chỉ là một vài ví dụ điển hình và chưa đủ để bảo mật cho một Backend RestAPI. Hãy luôn đặt câu hỏi hoài nghi nhất và tự tìm câu trả lời để có thể tổng quát hơn về những gì mình tạo ra. Trong các phần sau, mình sẽ phân tích sâu hơn về một vấn đề kỹ thuật cụ thể dưới hai góc nhìn của cả developer và pentester. Mong bạn đón đọc.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.