Hầu hết chúng ta hình dung trộm phiên như móc túi: kẻ gian rình sau lưng, chờ bạn rút ví ra rồi giật lấy. Nhưng có một trò tinh vi hơn nhiều mà ít ai nghĩ tới. Kẻ gian đưa cho bạn một cái ví rỗng có gắn thiết bị theo dõi, nói "dùng tạm cái này đi", rồi ngồi chờ bạn tự nhét tiền vào — vì hắn vẫn giữ chìa của cái ví đó. Bạn chẳng bị giật gì; bạn tự tay bỏ tiền vào cái ví của kẻ gian. Session fixation đúng là trò cái ví đó, và bài này tôi đo nó chạy thật, rồi chặn nó bằng đúng một dòng.

Bài trước đo các thuộc tính bảo vệ cookie phiên khỏi bị đánh cắp. Bài này đo một cuộc tấn công lật ngược ý tưởng đó: kẻ tấn công không cần đánh cắp ID phiên của bạn — nó cho bạn mượn ID phiên của chính nó, chờ bạn đăng nhập bằng ID đó, rồi dùng lại. Đây là session fixation (cố định phiên), và nó khai thác đúng một sơ suất phổ biến trong cách ứng dụng cấp phiên.

Toàn bộ chạy trên ứng dụng tôi tự dựng trong container, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, không nhắm vào hệ thống của ai.

Cuộc tấn công, ba bước

  1. Kẻ tấn công lấy một ID phiên hợp lệ từ chính ứng dụng — chỉ cần mở một trang, server cấp cho nó một sid. Lúc này sid chưa gắn với ai.
  2. Kẻ tấn công ép nạn nhân dùng đúng sid đó — qua một link chứa sẵn session ID, một cookie chèn được, hay một tham số URL. Nạn nhân mở trang bằng sid của kẻ tấn công, rồi đăng nhập.
  3. Kẻ tấn công dùng lại sid cũ. Nếu server gắn trạng thái "đã đăng nhập" vào chính cái sid ấy, kẻ tấn công giờ đang cầm một phiên đã đăng nhập — của nạn nhân.

Điểm cốt lõi: kẻ tấn công không bao giờ cần biết mật khẩu, và không cần đánh cắp gì sau khi nạn nhân đăng nhập. Nó chỉ cần biết trước ID phiên mà nạn nhân sẽ dùng. Nó cầm chìa của cái ví từ đầu.

Số đo

Hai cấu hình, mỗi cái chạy đủ ba bước tấn công. v0 giữ nguyên ID phiên khi đăng nhập; v1 cấp ID mới. Ba lần chạy, kết quả giống hệt:

Cấu hình ID sau đăng nhập Kẻ tấn công dùng lại ID cũ
v0 giữ nguyên ID giữ nguyên TẤN CÔNG THÀNH CÔNG — vào được phiên nạn nhân
v1 cấp ID mới đổi khác chặn (ID cũ trả về "chưa đăng nhập")

Chỗ khiến session fixation khác hẳn trộm phiên

Lỗ hổng không nằm ở chỗ đoán được ID, mà ở chỗ ID không đổi khi quyền hạn đổi. Với v0, cái sid mà kẻ tấn công đưa cho nạn nhân trước khi đăng nhập vẫn là cái sid gắn trạng thái đăng nhập sau đó. Kẻ tấn công không phải phá gì — nó chỉ giữ lại một bản sao của ID mà nó tự chọn từ đầu. Đây là điều làm session fixation khác với đánh cắp phiên, và là chỗ tôi muốn gằn giọng: kẻ tấn công có ID trước cả khi phiên có giá trị, nên mọi thứ chống đánh cắp (HttpOnly, HTTPS) đều vô dụng — không có gì bị đánh cắp cả.

Cách chặn là một dòng: cấp ID phiên mới ngay khi đăng nhập. Với v1, khoảnh khắc nạn nhân đăng nhập, server vứt sid cũ và cấp một sid hoàn toàn mới, ngẫu nhiên. Cái ID mà kẻ tấn công đang cầm lập tức trở thành rác — nó trỏ tới một phiên đã bị bỏ, chưa đăng nhập. Kẻ tấn công không có cách nào biết ID mới, vì nó chỉ được sinh ra sau khi nạn nhân nhập đúng mật khẩu, và đi tới trình duyệt nạn nhân, không tới nó. Cái ví bị đổi khoá đúng lúc tiền vừa được bỏ vào.

Nguyên tắc rộng hơn: đổi ID phiên mỗi khi mức quyền thay đổi. Không chỉ lúc đăng nhập — cả lúc nâng quyền (người dùng thường thành admin), lúc đổi mật khẩu, lúc xác thực hai bước bước hai. Bất cứ khi nào một phiên có thêm quyền, ID cũ của nó phải chết. Quy tắc thực dụng: ID phiên trước và sau một ranh giới quyền hạn không bao giờ được trùng nhau.

Fixation ngược chiều thời gian với đánh cắp phiên

Hai cuộc tấn công dễ lẫn, nhưng chúng ngược chiều nhau về thời gian, và điều đó quyết định cách phòng.

Đánh cắp phiên (session hijacking) xảy ra sau khi nạn nhân đăng nhập: kẻ tấn công lấy ID phiên đang có giá trị — qua XSS đọc cookie, qua nghe lén HTTP, qua log rò rỉ. Phòng nó bằng cách giữ ID bí mật: HttpOnly, Secure, không ghi ID vào log.

Cố định phiên (fixation) xảy ra trước khi nạn nhân đăng nhập: kẻ tấn công cung cấp ID, rồi chờ nó trở nên có giá trị. Giữ ID bí mật không cứu được, vì kẻ tấn công tự chọn ID — nó không cần đọc trộm gì. Phòng nó bằng cách làm cho ID hết hạn tại ranh giới quyền: đổi ID mới khi đăng nhập, để cái ID kẻ tấn công gieo chết đúng lúc nó sắp có giá trị.

Đây là lý do một cái không thay được cái kia. Bạn có thể có cookie HttpOnly; Secure hoàn hảo và vẫn dính fixation, vì fixation không đánh cắp cookie — nó lợi dụng việc cookie không đổi.

Vì sao lỗ này tồn tại

Session fixation tồn tại được vì hai quyết định thiết kế, mỗi cái riêng lẻ đều vô hại:

  • Ứng dụng chấp nhận một session ID do client đưa lên. Điều này bình thường — cookie phiên chính là client đưa ID lên mỗi request. Nhưng nếu ứng dụng chấp nhận một ID nó chưa từng cấp (hoặc một ID cũ), kẻ tấn công có thể gieo trước.
  • Trạng thái đăng nhập được gắn vào ID phiên hiện có thay vì một ID mới. Đây là chỗ hỏng thật. Đăng nhập là ranh giới quyền hạn quan trọng nhất; giữ nguyên ID qua ranh giới đó là để một danh tính cũ mang theo quyền mới.

Cách chặn tấn công đúng ngay tại mắt xích thứ hai: làm mới định danh tại ranh giới quyền. Nó không cần token, không cần mã hoá thêm — chỉ cần một sid mới. Đó là lý do các framework phiên trưởng thành làm việc này tự động trong hàm đăng nhập, và lý do bạn nên kiểm rằng hàm đăng nhập của mình gọi nó.

Nghĩa là gì trong công việc hằng ngày

  • Gọi hàm làm mới phiên trong mọi luồng đăng nhập. Nếu bạn tự viết quản lý phiên, đây là dòng quan trọng nhất; nếu dùng framework, hãy xác nhận nó được gọi (nhiều lỗ fixation đến từ việc tự viết login mà quên bước này).
  • Không bao giờ nhận session ID từ URL hay tham số. Chỉ nhận từ cookie. ID trong URL bị rò qua lịch sử duyệt, log máy chủ, header Referer — và là cách gieo fixation dễ nhất.
  • Huỷ phiên phía server khi đăng xuất, đừng chỉ xoá cookie. Nếu chỉ xoá cookie, ID cũ vẫn hợp lệ trên server và ai giữ được bản sao vẫn dùng được.
  • Kết hợp với các bài trước: HttpOnly (cookie không bị JS đọc) + SameSite (chống CSRF) + làm mới ID khi đăng nhập (chống fixation). Ba lớp chặn ba đường tấn công khác nhau vào cùng một cookie phiên.

Cùng một quy tắc "làm mới định danh tại ranh giới quyền", đổi ngăn xếp

Chuyện "đổi ID phiên khi mức quyền thay đổi" không riêng gì Flask/Python — mọi framework phiên trưởng thành đều có hàm cho việc này, và lỗ fixation gần như luôn đến từ việc quên gọi nó khi tự viết login.

Trong thế giới Java, Spring Security mặc định chống fixation: sau khi xác thực thành công, nó migrate phiên sang một session ID mới (chính sách migrateSession) — chính là HttpServletRequest.changeSessionId() của Servlet 3.1. Điểm nhức nhối là ai tắt nó đi (sessionFixation().none()) vì một lý do nghe hợp lý, hoặc ai tự dựng login bỏ qua Spring Security, sẽ mở lại đúng lỗ này. Có framework tốt không cứu bạn nếu bạn đi vòng qua nó.

Trong Go, không có framework phiên mặc định trong stdlib, nên trách nhiệm rơi thẳng vào lập trình viên: dùng gorilla/sessions hay scs, bạn phải chủ động gọi hàm làm mới (ví dụ scs's RenewToken(ctx)) ngay sau khi đăng nhập. Đây là ví dụ rõ nhất của "một dòng quan trọng nhất" — quên nó là fixation, và chẳng có gì cảnh báo.

Còn ở cơ sở dữ liệu, họ hàng gần nhất là vòng đời token/credential khi quyền đổi. Một pattern an toàn là xoay (rotate) session token hoặc refresh token mỗi lần dùng, và huỷ token cũ khi quyền nâng lên — đúng tinh thần "định danh cũ phải chết tại ranh giới quyền". Nhiều hệ auth lưu phiên trong DB/Redis cũng phải cẩn thận: cập nhật nội dung phiên mà giữ nguyên khoá phiên là để hở đúng fixation.

Mẫu số chung: một định danh phiên được cấp trước khi có quyền không được phép sống sót qua thời điểm nó nhận quyền — vì bất cứ ai biết định danh đó từ trước sẽ thừa hưởng luôn cái quyền mới. Bảo vệ định danh khỏi bị đọc trộm (HttpOnly, HTTPS) là một bài toán; làm định danh hết hạn đúng lúc quyền đổi là một bài toán hoàn toàn khác, và cần cách giải riêng. Nên câu hỏi đúng cho mọi luồng nâng quyền — đăng nhập, lên admin, đổi mật khẩu — không phải "cookie này có bí mật không" mà là "định danh phiên trước và sau ranh giới này có phải hai giá trị khác nhau không".

Muốn tự soát, tìm hàm xử lý đăng nhập trong mã của bạn. Ngay sau khi kiểm mật khẩu đúng, có dòng nào làm mới ID phiên không — regenerate, cycle_key, changeSessionId, RenewToken, hay tạo session mới? Nếu không có, ID phiên trước và sau khi đăng nhập là một — và bất kỳ ai gieo được cho nạn nhân một session ID trước khi họ đăng nhập đều vào được tài khoản của họ sau đó, không cần mật khẩu, không cần đánh cắp gì.