Chào các bạn,
Trong bài viết này mình sẽ nói về một lỗ hỏng website tương đối nghiêm trọng và cách phòng chống nó trên ứng dụng Java Web.
Tấn công Cross-Site Request Forgery được thực thi bên trình duyệt web (client-side), nó có thể đánh cắp thông tin của người dùng, thực hiện hành vi không đúng đắn hoặc thực thi những đoạn code không hợp lệ đối với máy chủ back-end (Java Web).
Chỉ cần thông qua một số thủ thuật như là tấn công phi vật lý (social engineering) để có thể chèn một đoạn code nào đó vào máy của nạn nhân để đánh lừa nạn nhân tiếp tay thực thi giúp đoạn code không hợp lệ đó.
Mình sẽ ví dụ bạn thông qua chức năng thay đổi tài khoản và mật khẩu của người dùng bằng ứng dụng Java Web.
Những đoạn code dưới được code chỉ để demo cho lỗ hỏng CSRF, các bạn có thể tham khảo để phòng tránh
Chúng ta sẽ cùng tạo một Database trong SQL Server và một ứng dụng Java Web quản lý người dùng có chức năng đăng nhập và thay đổi mật khẩu của tài khoản.
1 2 3 4 5 6 7 8 9 10 | CREATE DATABASE UserManagement USE UserManagement CREATE TABLE Users ( Username varchar(100) NOT NULL PRIMARY KEY, Password varchar(100) NOT NULL, Role varchar(100) NOT NULL ) INSERT INTO Users VALUES ('bang','1234','Administrator') INSERT INTO Users VALUES ('admin','4567', 'Administrator') |
Dữ liệu sẽ trông như sau:
Hình 2 - Dữ liệu ở bảng Users trong DB UserManagement sau khi thực hiện câu truy vấn |
Chúng ta đăng nhập ứng dụng Web với tài khoản của admin.
Hình 4 - Thực hiện chức năng thay đổi mật khẩu |
Hình 5 - Thao tác thay đổi mật khẩu |
Hình 6 - Thay đổi mật khẩu thành công |
Kiểm chứng lại trong cơ sở dữ liệu.
Hình 7 - Dữ liệu trong cơ sở dữ liệu sau khi thay đổi mật khẩu của admin |
Ta có thể dễ dàng kết luận địa chỉ đầy đủ sẽ là http://abcxyz.com/DemoCSRF/ChangePasswordController?txtUsername=admin&txtPasswordChange=1234&btnAction=Change password now!
Hãy cùng xem source code mẫu của ChangePasswordController hiện tại nhé!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | package bangmaple.controllers; import bangmaple.daos.UsersDAO; import bangmaple.utils.AntiCSRFToken; import java.io.IOException; import java.sql.SQLException; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; @WebServlet(name = "ChangePasswordController", urlPatterns = {"/ChangePasswordController"}) public class ChangePasswordController extends HttpServlet { private static final String ERROR = "error.jsp"; private static final String SUCCESS = "success.jsp"; protected void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("text/html;charset=UTF-8"); String url = ERROR; try { HttpSession session = request.getSession(); if (request.getParameter("btnAction") == null) { request.getRequestDispatcher("changePassword.jsp").forward(request, response); } else { UsersDAO dao = new UsersDAO(); if (dao.changePassword(request.getParameter("txtUsername"), request.getParameter("txtPasswordChange"))) { url = SUCCESS; } } } catch (ClassNotFoundException | SQLException e) { log("ERROR at ChangePasswordController: " + e.getMessage()); } finally { if (url.equals(SUCCESS)) { request.getSession().invalidate(); response.sendRedirect("success.jsp"); } else { request.getRequestDispatcher(url).forward(request, response); } } } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { processRequest(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { processRequest(request, response); } } |
Dựa vào một số kĩ năng tấn công phi vật lí (social engineering) hoặc SQL Injection, kẻ xấu có thể thu được (các) account cùng chung bảng trong cơ sở dữ liệu. Ví dụ kẻ xấu đó biết được có tồn tại một tài khoản tên là bang và ta sẽ thực hiện tấn công CSRF bằng cách thay đổi tham số txtUsername để thay đổi mật khẩu của người dùng khác mà không phải của mình.
http://abcxyz.com/DemoCSRF/ChangePasswordController?txtUsername=bang&txtPasswordChange=aaaa&btnAction=Change password now!
Hình 8 - Thay đổi tham số của địa chỉ |
Hình 9 - Thành công trong việc thay đổi mật khẩu của người dùng khác |
Hình 10 - Mật khẩu của người dùng bang đã bị thay đổi |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | package bangmaple.controllers; import bangmaple.daos.UsersDAO; import bangmaple.utils.AntiCSRFToken; import java.io.IOException; import java.sql.SQLException; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; /** * * @author bangmaple */ @WebServlet(name = "ChangePasswordController", urlPatterns = {"/ChangePasswordController"}) public class ChangePasswordController extends HttpServlet { private static final String ERROR = "error.jsp"; private static final String SUCCESS = "success.jsp"; protected void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("text/html;charset=UTF-8"); String url = ERROR; try { HttpSession session = request.getSession(); String antiCSRFToken; if (request.getParameter("btnAction") == null) { antiCSRFToken = AntiCSRFToken.getToken(); session.setAttribute("csrfToken", antiCSRFToken); request.getRequestDispatcher("changePassword.jsp").forward(request, response); } else { antiCSRFToken = String.valueOf(session.getAttribute("csrfToken")); if (antiCSRFToken.equals(request.getParameter("csrfToken"))) { UsersDAO dao = new UsersDAO(); if (dao.changePassword(request.getParameter("txtUsername"), request.getParameter("txtPasswordChange"))) { url = SUCCESS; } } else { request.setAttribute("ERROR", "Invalid CSRF Token! Well done, hacker!"); } } } catch (ClassNotFoundException | SQLException e) { log("ERROR at ChangePasswordController: " + e.getMessage()); } finally { if (url.equals(SUCCESS)) { request.getSession().invalidate(); response.sendRedirect("success.jsp"); } else { request.getRequestDispatcher(url).forward(request, response); } } } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { processRequest(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { processRequest(request, response); } } |
Vậy bạn sẽ thắc mắc là Anti-CSRF Token đào ở đâu ra? Token chúng ta có thể sinh ra bằng hàm tuyến tính ví dụ đơn giản là f[session](x) = x + 1, x>0 nhưng mà không ai làm đơn giản như vậy vì dễ bị đoán mò. Nên là mình sẽ sử dụng sự trợ giúp của java.security.SecureRandom và bộ thư viện chữ và số.
Ta tạo một class mới có tên là AntiCSRFToken có chứa hàm static để cho việc tái sử dụng hàm được dễ dàng hơn.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | package bangmaple.utils; import java.security.SecureRandom; public class AntiCSRFToken { private static final String DICT = "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static final int DICT_SIZE = DICT.length(); public static String getToken() { final SecureRandom random = new SecureRandom(); final StringBuilder sb = new StringBuilder(); for (int i = 0; i < DICT_SIZE / 2; i++) { sb.append(DICT.charAt(random.nextInt(DICT_SIZE))); } return sb.toString(); } } |
Ta cũng sửa cho bên view là changePassword.jsp để lúc từ ChangePasswordController trước khi thay đổi thì nó phải trả về thêm một hidden field là csrfToken được generate từ nó, gán vào session và sau đó lúc người dùng submit form thay đổi mật khẩu thì dùng hidden field csrfToken đó chứng thực lại với csrfToken bên back-end server ở session (Java Web).
Nếu người dùng có chứa đúng csrfToken thì mới thực thi hành động thay đổi mật khẩu.
Ở bên trang changePassword.jsp ta thêm một dòng hidden field chứa csrfToken như dòng 15 ở đoạn code dưới.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <%@page contentType="text/html" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>JSP Page</title> </head> <body> <h1>Hello ${sessionScope.USER_NAME}!</h1><br/> <h2>Change your password!</h2><br/> Please input your new password!<br/> <form action="ChangePasswordController" method="POST"> Current username: <input type="text" name="txtUsername" value="${sessionScope.USER_NAME}" readonly="true"/><br/> New password: <input type="password" name="txtPasswordChange"/><br/> <input type="hidden" name="csrfToken" value="${sessionScope.csrfToken}"/> <input type="submit" name="btnAction" value="Change password now!"/> </form> </body> </html> |
Đăng nhập với tài khoản tên bang và thực hiện chức năng thay đổi mật khẩu, khi Inspect và ta thấy được hidden field csrfToken được generate với một chuỗi số chữ ngẫu nhiên.
Vậy khi chúng ta submit form, nó sẽ được gửi đi dạng http://abcxyz.com/DemoCSRF/ChangePasswordController?txtUsername=bang?txtPasswordChange=...&csrfToken=wSE3fY7Mc2SuAXozqPlqFxNKRjc1drX
Trông khá là rườm rà nhỉ, đương nhiên là để tránh dài dòng và lộ thông tin của csrfToken, ta luôn thực hiện khi gửi request đi phải gửi dưới dạng POST và để mã hoá nội dung của request dạng POST, ta nên thêm lớp TLS (HTTPS) cho nó.
Nếu là người dùng thông thường gửi lệnh thay đổi mật khẩu một cách chính đáng, ta sẽ nhận dược thông báo thay đổi mật khẩu thành công.
Hình 11 - Form đã được thêm csrfToken |
Hình 12 - Mật khẩu được thay đổi thành công cho tài khoản bang |
Chúng ta sẽ cố gắng tấn công bằng cách thay đổi tài khoản admin không thông qua tài khoản chính chủ.
Hình 13 - Tiếp tục tấn công CSRF |
Ta nhận được kết quả trả về thất bại vì Anti-CSRF Token không hợp lệ.
Hình 14 - Cố gắng thay đổi mật khẩu của tài khoản khác thất bại |
Từ giờ trở đi, chúng ta nên generate Anti-CSRF Token mọi lúc trước khi thực hiện gửi dữ liệu trong form để tránh bị hack. Để tối ưu nhất, ta nên để thời gian timeout cho session càng ngắn càng tốt!
Để cho việc thử nghiệm dễ dàng, bạn nên sử dụng trình duyệt FireFox hoặc Google Chrome bản cũ để tránh bị lỗi Cross-Origin Read Blocking.
Lỗ hỏng bảo mật CSRF thường được dùng chung với XSS, cho nên bảo mật trước CSRF không phải là bảo mật được tất cả. Bạn nên xem xét bảo mật những thành phần còn lại, validate các nội dung trong headers, review code kĩ là được.
Cảm ơn các bạn đã dành thời gian ra đọc bài viết.
Chúc các bạn học tốt!
------
Source-code Java Web DemoCSRF: https://github.com/bangmaple/BangMapleBlogResources/tree/master/DemoCSRF