Phát hiện - ngăn chặn tấn công DDoS tầng Ứng Dụng dựa trên kiểm soát Tần suất tấn công.

Trong bài viết Hạn chế DDoS Attack tầng giao vận TCP SYN Flood bằng Bloom filter trước, ở cuối bài, mình có nhắc qua về cách phòng tránh DDoS Attack tầng Ứng Dụng, ý tưởng của Sư Phụ và Trần Sư huynh cũng như chương trình C++ của mình về 1 chương trình tinh gọn, đảm bảo realtime trong việc phát hiện DDoS. Do vậy, trong bài viết này, mình sẽ trình bày lại kỹ toàn bộ ý tưởng thuật toán của Sư Phụ và Trần Sư huynh về một chương trình "realtime" giúp phát hiện các nguồn tấn công DDoS, dựa trên việc kiểm soát tần suất tấn công của nguồn tấn công. Phương pháp này tốn rất ít không gian lưu trữ, thời gian xử lý thì theo bản thân mình - người code trực tiếp toàn bộ chương trình - thì còn hơn cả "realtime". Đây cũng chính là ý tưởng có trong Paper Nghiên cứu khoa học góp phần bảo vệ luận văn Tiến sĩ của Trần Sư huynh.

Đương nhiên, không phải tự nhiên mà chương trình của mình có thể đạt được tốc độ bàn thờ ngay như vậy. Mình sẽ trình bày bài viết theo timeline từ lúc mình nhận nhiệm vụ implement code cho đến khi đạt được kết quả realtime. Ngoài ra, mình cũng sẽ không quên trình bày lại một số khái niệm có liên quan. Có những khái niệm đã có ở những bài viết trước, mình sẽ không trình bày lại.

Phân biệt với DDoS Attack Tầng Giao Vận bằng TCP SYN FLOOD.

Trong mô hình cấu trúc hạ tầng mạng theo chiều dọc OSI, Tầng Giao Vận là tầng thứ 4 từ dưới lên, là địa bàn của bọn TCP/UDP; còn Tầng Ứng Dụng là tầng thứ 7, tầng trên cùng, là địa bàn của bọn HTTP Request (GET, POST, PUT, DELETE, ...). Muốn lên được tầng 7, đương nhiên phải vượt qua tầng 4! Nghĩa là nếu đã có thể ngoi lên tới tầng thứ 7 thì hacker đã phải vượt mặt được Bloom filter ở tầng 4!

Bạn đã thấy ghê phần nào chưa. Bloom filter ở Tầng Giao Vận đã lọc bỏ vất đi dòng thác TCP SYN Flood từ hàng triệu packet tới trên giây, vậy mà hacker nó vẫn khôn khéo lừa lọc "bắt tay 3 bước" một cách auto cho các zombie, để zombie ngoi được lên Tầng Ứng Dụng mà DDoS bằng cách gửi liên tục tới hàng ngàn HTTP Request mỗi giây.

Ở đây, bạn phải phân biệt rõ 3 mức khái niệm sau: để khởi tạo 1 kết nối thì cần gửi nhiều HTTP Request, để gửi thành công trọn vẹn 1 HTTP Request thì cần phải gửi nhiều packet (gói tin). Ở tầng 4, chúng ta làm việc với packet; nhưng lên tầng 7, chúng ta làm việc với HTTP Request.

Do đó, với số lượng hàng triệu packet/s như ở Tầng Giao Vận, ta có thể sử dụng 1 cấu trúc dữ liệu cồng kềnh như Bloom Filter để ngăn chặn thì không ai kêu j, vì Tầng Giao Vận cho dù có phải xử lý mất thời gian đi chăng nữa, thì cũng ít khi ảnh hưởng đến thứ chết tiệt gọi là TRẢI NGHIỆM NGƯỜI DÙNG (UX - User Experiment). Hơn nữa, để có thể chống đỡ được hàng triệu packet đó, cho dù có cồng kềnh đến mấy thì cũng đáng.

Vậy ở Tầng Ứng Dụng thì sao? Có tầm trên dưới vài nghìn Request/s thôi mà, sao không dùng Bloom filter tiếp cho nhanh? Giảm kích thước Bloom filter xuống cho phù hợp để lọc từ hàng triệu xuống hàng ngàn là được mà? Hay dùng Bloom filter thì cái Trải nghiệm người dùng j đó ở trên nó bị ảnh hưởng như nào à? vân vân mà mây mây ...

Chắc chắn rồi. Bloom filter rất cồng kềnh, hơn nữa còn có "xác suất dương tính sai" nữa. Nếu giảm kích thước Bloom filter xuống thì sẽ làm tăng "xác suất dương tính sai". Mà nói tóm lại, nếu dùng Bloom filter ở Tầng Ứng Dụng thì sẽ làm ảnh hưởng rất nặng đến TRẢI NGHIỆM NGƯỜI DÙNG. Người ta vào trang web của ông mà ông còn bắt người ta đợi "vài giây" để ông băm vào Bloom filter thế thì chết! Nên nhớ rằng nếu ai cũng phải bỏ ra "vài giây" đợi thì chúc mừng, trang web của bạn sẽ ko có ma nào thèm ghé thăm đâu! Người dùng là 1 thứ rất khó chiều, do đó làm j thì làm, cứ phải làm sao để cho tất cả mọi quá trình xử lý backend trở lên trong suốt với người dùng! Ông còn định đem dăm ba cái Bloom filter từ Tầng 4 lên đây để đuổi hết người dùng của tôi đi à ông dev!

Well, ta có thể thấy hacker có thể vượt qua Tầng Giao Vận để leo lên tấn công được vào Tầng Ứng dụng là đã rất pro rồi! Nhưng như trong tư tưởng của Độc Cô Cửu Kiếm có nói "chiêu thức sinh ra để bị phá"; do đó, dưới vai trò của một người bảo vệ hệ thống, ta lại phải càng tìm ra cách thức khôn khéo hơn để phá giải quá trình DDoS Tầng Ứng Dụng của hacker. Biết là "xây thì khó chứ phá thì mấy", phá hoại hệ thống thì dễ, còn bảo vệ hệ thống thì khó, nhưng khó đến mấy thì vẫn cứ phải cố mà tìm cách chống đỡ! 😂

Điều này cũng giống như việc sau. Hàng năm, người dân miền Trung nước ta đều phải chịu sự phá hoại mùa màng rất nặng nề của thiên tai (bão, lụt, ...); nhưng họ vẫn trồng lúa nước và rau quả hàng năm. Họ ko vì lý do năm nào cũng bị thiên tai phá hoại mà dừng lại việc trồng lúa nước cả, cho dù năm nào họ cũng bị thiên tai hành hạ. Biết là khó, có khi biết là làm rồi nhưng vẫn chẳng có kết quả lớn lao j cả, nhưng vẫn không thể không làm. Làm không phải vì chỉ để thu được kết quả, mà làm còn là để khẳng định "chính nghĩa" là "bất diệt". Tinh thần lao động của người nông dân nước ta là bất diệt, như vậy còn ko đáng để khâm phục và học hỏi hay sao!

Cái khó của việc phòng ngừa tấn công ở Tầng Ứng Dụng này chính là các cơ chế đem áp dụng phải trong suốt với người dùng. Do đó, chương trình xử lý phải gọn nhẹ, đảm bảo realtime, hoặc cũng không thể vượt quá xa so với realtime. Nghĩa là nếu đọc Log 4 phút thì không thể mất quá 4 phút xử lý được.

Để có thể hình dung ra rõ hơn độ khó của yêu cầu, bạn có thể nghĩ như sau. Hacker chỉ huy cả nghìn zombie cùng hội đồng, gửi HTTP Request lên server. Cả ngàn zombie chứ không phải vài con hay vài chục con đâu! Mà trong khi đó sức mạnh máy tính của chúng ta có hạn, cho dù có là siêu máy tính, nếu xử lý chay một cách cục xúc, thì cũng không đú lại được cả ngàn con zombie đồng nát hợp lại. 

Ý tưởng kiểm soát "tần suất tấn công". Cách thiết kế chương trình tinh giản cấu trúc dữ liệu của tôi.

Mỗi Request tới sẽ có thông tin là Source IP (IP nguồn - fake hoặc thật) và Time Stamp (Nhãn thời gian). Do đó, Thầy muốn ta lưu trữ thông tin theo dõi được từ việc đọc file log vào một Table (cái bảng). Trong bảng dữ liệu này, mỗi hàng sẽ quản lý 1 Source IP duy nhất, ngoài ra bảng sẽ có các cột như sau:
  • Cột đầu tiên: lưu thông tin Source IP.
  • Cột thứ 2: nhãn thời gian mới nhất của Request nhận bởi Source IP đó.
  • Cột thứ 3: lưu giá trị tần suất gửi Request của Source IP đó trên một đơn vị thời gian.
  • Cột thứ 4: lưu vết lại thời điểm nhận được Request từ Source IP đó. Cái này quan trọng này. Ta sẽ gọi đây là Trace, nó sẽ là 1 mảng có độ dài cố định (64, 128, 256 j đó tùy em kiểm tra so sánh sau, nhưng trước mắt cứ lấy 64 đi cho dễ hình dung).
Mảng này biểu thị cho 1 khoảng thời gian theo dõi, mà mỗi một phần tử trong mảng này, Thầy gọi là 1 Slot, biểu thị cho 1 khoảng thời gian rất bé deltaSlot. Slot phải bé để đảm bảo rằng mỗi một deltaSlot trôi qua, ta chỉ nhận được 1 Request từ cùng 1 Source IP. Nghĩa là giả dụ deltaSlot bằng 1ms, thì trong 1ms, chỉ có 1 Request tới từ Source IP "1" chẳng hạn. Còn nếu trong 1ms đó mà có 2 Request từ cùng 1 nguồn, thì ta sẽ chia đôi deltaSlot để tránh việc trùng lặp sẽ xảy ra tiếp.

Cứ mỗi một deltaSlot trôi qua, em sẽ đọc Request nằm trong khoảng thời gian deltaSlot đó. Nếu thấy IP mới thì thêm vào bảng, gán giá trị cuối của Trace bằng 1. Còn nếu là IP cũ thì xem xem nó có xung đột không. Nếu không xung đột thì đặt giá trị cuối của Trace bằng 1; còn nếu xung đột rồi thì giá trị cuối của nó vẫn là 1, không phải ghi đè vào, nhưng ta sẽ chia đôi deltaSlot đi cho lần đọc Request tiếp theo.

Sau khi đã đọc xong hết các Request trong khoảng thời gian deltaSlot, ta sẽ tiến hành "dịch trái" toàn bộ Trace có trong bảng đi 1 ô. Điều này sẽ đảm bảo cho lần đọc Request tiếp theo, giá trị cuối của Trace luôn bằng 0. Còn nếu trong 1 deltaSlot mà có 1 Source IP có tới 2 Request đến thì ta sẽ chia đôi như trên.

Giờ để ý đến cái Trace này với giá trị tần suất F ở cột thứ ba nhé. F sẽ có giá trị bằng tổng tất cả các số có trong mảng Trace. F sẽ được cập nhật liên tục khi ta dịch trái Trace. 
-------------------------------------------------------
Từ những ý tưởng mà Sư Phụ đã nói, tôi đã thiết kế chương trình của mình như sau:
  • Sử dụng cấu trúc dữ liệu Bitset trong thư viện STL của C++ để áp dụng cho Trace.
  • Sử dụng cấu trúc dữ liệu Map trong thư viện STL của C++ để làm Table lưu trữ, với key là Source IP, value là 1 đối tượng cụ thể của class IP mà tôi tự định nghĩa.
  • Tần số F sẽ được update liên tục trong quá trình dịch bit bằng cách kiểm tra bit đầu và bit cuối của Trace trước khi dịch để biết tăng hay giảm F đi 1 đơn vị.
Sức mạnh của Bitset trong trường hợp này là không nhỏ đâu nhé! Nếu bạn dùng mảng boolean thì thời gian dịch trái 1 ô của bạn vẫn là O(n), nhưng nếu bạn dùng Bitset trong STL thì thời gian dịch trái 1 ô chỉ là O(1)! 

Mình nói rõ là Bitset trong STL nhé, Bitset trong STL thì có độ dài cố định từ lúc khai báo. Còn Bitset trong Boost thì có thể có tùy chọn thay đổi độ dài, nên nếu chọn loại Bitset có thể thay đổi độ dài trong Boost thì thời gian dịch trái 1 ô vẫn là O(n).

Boost là 1 thư viện khổng lồ, hỗ trợ rất lớn cho C++. Nên nếu bạn có làm việc với C++ thì không được phép bỏ qua Boost. Boost rất lớn, không nhỏ như STL đâu! Nên hãy tìm hiểu ngay đi!

Phiên bản 1: "Ơ, Delta với DeltaSlot của Thầy ở đâu, em hiểu sai thuật toán rồi Cường!".

Nhớ lại lần đó, mình muốn làm cho nhanh, liền biến tấu thuật toán của Sư Phụ thành ra 1 thể loại rất là mất dạy. Mình cũng ko nhớ là động lực mất dạy nào làm mình code theo kiểu đó nữa, nhưng đại loại là coi tất cả các dòng Trace như 1 ma trận 2 chiều, liên tục dịch trái 1 bit khi gặp 1 Request. Kiểu như game "Bắn Ruồi" trên cái máy điện tử 4 nút cầm tay thời "cổ đại" ấy.

Và đương nhiên với cách hiểu đó, mình không hề sử dụng j đến deltaSlot cũng như Delta. Cuối cùng, Sư Phụ đã phải đính chính lại cho mình hiểu lại toàn bộ thuật toán!  

Phiên bản 2: "Nó DDoS có 4 phút mà chương trình của chú mất cả tiếng đồng hồ để check, thế thì RIP server!".

Sau khi đã refactor lại code theo đúng thuật toán, vấn đề tiếp theo mình gặp phải là chương trình chạy rất lâu với file log 134k Request trong 4 phút. Trần Sư huynh thấy vậy đã trêu mình 1 câu "Nó DDoS có 4 phút mà chương trình của chú mất cả tiếng đồng hồ để check, thế thì server sập trước khi chú chặn đc tấn công rồi!". Nghe xong đúng là có lý thật. Mục tiêu của chương trình là realtime, tức là nếu file log kia 4 phút thì mình ko thể xử lý lâu hơn 4 phút được! Đằng này vì 1 lý do nào đó, chương trình chạy nửa tiếng ko xong! 

Nhưng cũng từ việc gặp phải vấn đề như vậy, mà Sư Phụ cùng Trần Sư huynh đã bàn lại và đưa ra 1 giải pháp giúp hạn chế việc dịch bit cả bảng srcIP. Cụ thể:
  • Sẽ không xét theo deltaSlot nữa, mà xét theo từng request.
  • Trong khoảng thời gian Delta, sẽ tiến hành đọc các Request như bình thường.
  • Sau mỗi Delta giây, sẽ tiến hành kiểm soát lại toàn bộ bảng srcIP. Những IP nào lâu ko có request đến sẽ bị dịch trái nguyên lần deltaSlot (bằng hiệu của currentTime - lastTime).
OK, vậy là chương trình có vẻ sẽ xử lý nhanh hơn, vì đỡ phải dịch trái 1 bit cả bảng srcIP nhiều lần.

Phiên bản 3: "Đậu má ký tự Enter, mất 1 buổi chiều ngồi dò bug của tao!". 80s xử lý cho file log 30s.

Sau khi implement ý tưởng mới trên, cứ tưởng chương trình rồi sẽ chạy ngon. Nhưng trời hay trêu đùa người ta mà! Mình vẫn nhớ hôm đó, trời nắng đẹp, gió to. Mình ngồi trên văn phòng Cục ATTT, mặt đần thối ra đúng từ 2h chiều cho đến 6h chiều mà ko thể nào tìm ra vì sao lại có con bug khốn nạn vậy.

Đến tối, về nhà hỏi thằng em cùng nhà. Lúc đầu, nó cũng đần thối ra như mình vì ko hiểu sao lại có thể có con bug kia được! Nhưng cũng may, vì kinh nghiệm code lâu năm của nó mà nó đã phát hiện ra lý do vì sao lại có con bug khốn nạn thế này. Vấn đề ko phải nằm ở trong code, mà nằm ở trong file log. Con mẹ nó, dòng log cuối cùng lại là 1 dòng trống, có dấu Enter khi kết thúc file!😠

Đúng là tức ói cmn máu. Code mình ko lỗi, mà lỗi ở log file! Nhưng sau 1 hồi nghĩ kỹ lại, mình nhận thấy lỗi chung quy ra cũng do mình cả thôi. Do đó sau vụ này, mình rút kinh nghiệm và chân thành khuyên các bạn nên "check null" trước khi thực thi statement!

Phiên bản 4: "Có j đó ko ổn, làm sao mày có thể cân cả lũ botnet 4 phút đó trong có 90s? 90/240? Bố của realtime à???"

Ngay tối hôm đó, sau khi đã fix đc con bug khốn nạn, mình thực hiện ý tưởng và thêm một chút cải tiến. Ngoài việc deltaSlot có thể tự chia đôi, mình đã cho nó thêm hành vi nhân đôi, tức là mở rộng khung nhìn.

Tối hôm đó, mình đi hết từ bất ngờ này đến bất ngờ khác. Lúc đầu sau khi fix bug xong, chương trình chạy mất có 80s cho file log 134k Request trong 30s phút. Khi nhận đc kết quả này, bản thân mình thấy quá là thành công rồi. Cả lũ botnet nó đông như kiến cỏ vậy mà 1 mình mày, với con laptop hạng tầm trung, vậy mà có thể phát hiện tấn công trong có 80s. Như vậy cũng đã là làm tốt rồi. Còn hơn chán so với việc ngồi đợi cả nửa tiếng đồng hồ mà chưa thấy chương trình chạy xong như lúc trước! 

Sau khi thêm một vài ý tưởng, mình bắt đầu có thêm nhiều bất ngờ. Đầu tiên là chương trình chạy chỉ có 190s cho file log 134k Request trong 240s! Thế này sure realtime rồi! Say oh yeah! Quá là phê luôn. Nếu code ko có j sai sót, thì mình đúng là 1 vị thần! 😆

Rồi bất ngờ tiếp theo, Mình giảm thời gian chạy từ 190s xuống còn 90s! What the hell! Kết quả này thì đúng là bố của realtime rồi. Rồi mình bắt đầu đâm ra hoài nghi về những dòng code của mình! Thật ko thể tin nổi. Ngay chính mình cũng ko thể tin nổi khi mình làm đc như vậy!

Phiên bản 5: Sử dụng Black List, tôi giảm thời gian xử lý từ 90s xuống còn 60s.

Ngày hôm sau, vì ám ảnh thời gian chạy quá lâu, tôi tiếp tục tìm mọi thủ đoạn để giảm thời gian xử lý của chương trình xuống. Vậy là tôi bèn dùng "black list" (ý tưởng ở phase2 của thuật toán) vào phase1 với cái tên "mini blacklist". 

Cụ thể, tôi sẽ mặc định rằng cứ mỗi Delta giây sau khi mình báo cáo, hệ thống sẽ biết mà chặn những IP có tần số lớn, nghĩa là sẽ ko tiếp nhận HTTP Request từ các srcIP đó nữa. Và bản thân mình đã giả lập ý tưởng đó bằng cách sử dụng "mini blacklist" để lưu lại các srcIP có tần suất lớn sau mỗi lần báo cáo.

Kết quả thu được, chương trình giảm thời gian chạy từ 90s xuống còn 60s. Đến lúc này, có thể nói đúng 1 từ duy nhất: PERFECT!

Hướng phát triển tiếp chương trình.

Sau project này, bản thân mình thấy mình đã tiếp thu thêm rất nhiều thứ. Ngoài nâng cao 1 chút về khả năng lập trình C++, mình còn lĩnh hội được cách phân tích và thiết kế hệ thống của Sư Phụ cũng như Trần Sư huynh. Cố gắng tinh giảm mọi thứ hết mức có thể, từ cấu trúc dữ liệu cho đến hành vi thuật toán. 

Nhưng nói vậy thôi, lúc đầu mình dự định sẽ sử dụng vài ba cái Design Pattern vào cho tiện luyện tập, nhưng vì chương trình có vẻ hơi nhỏ, vs lại bản thân mình cũng chưa phải expert về Design Pattern nên chưa thể nhìn ra được việc sử dụng Design Pattern vào hệ thống nhỏ này. Cũng không biết sau này, module này có thể phát triển to ra hơn ko.

Tổng kết.

Vậy đấy, cuộc đua với team hacker đã thành công. Chúng ta đã phát hiện và ngăn chặn được các hacker DDoS attack với thời gian "bố của realtime". Đạo cao 1 thước, ma cao 1 trượng. Nhưng xưa này tà ko thể thắng chính! :3 

Sau vụ này, mình tiếp tục được Trần Sư huynh giao cho tìm hiểu các hệ thống tiếp nhận và vận chuyển log (như Logagent, Kafka, ...), tìm hiểu về ứng dụng BigData vào xử lý log nhằm ngăn chặn DDoS Attack với phạm vi toàn quốc gia. Tuy nhiên ngay sau khi được cậu bạn cùng lớp đại học review vô cùng rõ ràng cho toàn cảnh về BigData, Hadoop, MapReduce, ... mình lại nhận được lời mời vào làm SOC Analyst cho CMC Infosec. Đây đúng là 1 cơ hội để nâng cao một cách toàn diện võ công Security, chuyên ngành mình hằng đam mê. Vì vậy lại đành phải chia tay Trần Sư huynh ở đây. 

Hi vọng mình sẽ sớm ngày có điều kiện nâng cao kỹ năng làm việc thực tế với BigData; và sử dụng nó vào Security.

Nhận xét

Bài đăng phổ biến từ blog này

Trên con đường tu đạo luôn cực kỳ theo đuổi!

C++ Con trỏ (Pointer) toàn thư: Phần 4: Con trỏ "đa cấp". Đánh nhau bằng con trỏ.

Quan hệ giữa các phân phối xác suất thông dụng nhất: Beta và Dirichlet không giống Gaussian!