『 BangMaple's Blog 』

BangMaple's Blog. Nơi sẻ chia kiến thức!

Bảo mật Java Web Application trước lỗ hổng Cross-Site Request Forgery (CSRF)

1 nhận xét
Security: What is Cross-Site Request Forgery? - Davos Networks ...

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
Vì code khá dài nên mình sẽ đính kèm source code ở dưới bài viết, từ giờ mình chỉ đưa ra những nội dung chính đối với Java Web Application.

Chúng ta đăng nhập ứng dụng Web với tài khoản của admin.
Hình 3 - Đăng nhập với tài khoản admin 
Ta sẽ vào tiếp chức năng thay đổi mật khẩu.
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
Sau khi nhập mật khẩu bất kì, ta nhận được kết quả báo thành công!

Hình 6 - Thay đổi mật khẩu thành công
Dựa vào những kĩ năng tấn công phi vật lý (social engineering), ta có thể thấy được dữ liệu trong form được đẩy qua cho ChangePasswordController với nội dung txtUsername=admin&txtPasswordChange=...&btnAction=Change password now!

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ỉ
Truy cập vào địa chỉ, ta nhận được kết quả như sau:
Hình 9 - Thành công trong việc thay đổi mật khẩu của người dùng khác
Kiểm chứng lại dữ liệu trong cơ sở dữ liệu, ta nhận thấy mật khẩu của người dùng bang đã bị thay đổi.
Hình 10 - Mật khẩu của người dùng bang đã bị thay đổi
Vậy ta sẽ cần phải pháp, đó là tạo một Anti-CSRF Token đưa đến cho từng form nhập và code trong ChangePasswordController sẽ được sửa lại như sau là một ví dụ:

 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 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ó.
Hình 11 - Form đã được thêm csrfToken
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 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
Vậy ta đã thực hiện áp dụng chức năng vô hiệu hoá được tấn công CSRF.

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!


------