Một bảng quản trị trên máy chủ gần như luôn húc phải cùng một bức tường: tiến trình web cần làm một việc mà nó không có quyền. Chặn một địa chỉ, mở một cổng, đọc journal. Câu trả lời tự hiện ra là cấp một dòng hẹp duy nhất trong sudoers, và không gì hơn:

www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *

Dòng đó đọc lên như «chỉ được chặn». Thực ra nó có nghĩa «bất cứ thứ gì, với quyền root». Dưới đây là vì sao lại thành ra như vậy, cách kiểm tra điều đó trên máy chủ của bạn trong năm phút, và những quy tắc như thế được thay bằng gì.

* thực sự làm gì

Chi tiết quyết định: sudo không so từng đối số một mà so toàn bộ dòng lệnh, và * trong mẫu đi qua các dấu cách một cách thoải mái — nghĩa là qua ranh giới giữa các đối số. Bởi vậy từ nguyên văn banip nằm ở giữa quy tắc không giới hạn điều gì cả: chỉ cần từ banip xuất hiện ở đâu đó trong câu lệnh, còn trước và sau nó có thể truyền bất cứ thứ gì.

Tiếp theo người ta tìm một chương trình có thể được giao một câu lệnh để chạy. Trong trường hợp của chúng tôi, nó nằm ngay trong chính quy tắc. Ở fail2ban, hành động chặn được đặt dưới dạng văn bản, và văn bản ấy có thể được định nghĩa lại trong lúc đang chạy:

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<câu lệnh>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

Lệnh gọi thứ nhất thay văn bản hành động ở một hành động thật, lệnh thứ hai buộc jail kích hoạt — và fail2ban, chạy với quyền root, thực thi thứ đã được cài vào. Chúng tôi làm việc này ngày 4 tháng 7 trên máy chủ của chính mình: một tệp trong /root được tạo ra với quyền root, nghĩa là tiến trình web giành được toàn quyền trên máy từ một quy tắc trông có vẻ hẹp.

Trên đường đi, ba chi tiết đã lộ ra, những chi tiết thường thiếu trong các bài phân tích của người khác:

  • tùy chọn -c đặt trước set bị sudo cắt bỏ: trong mẫu, set dính liền với tên tệp nhị phân và không thể chèn gì vào phía trước;
  • addaction và action mà không có từ banip cũng không đi qua được — nhưng tất cả những gì cần thiết đều vừa trong một câu lệnh set … banip …, và như vậy mẫu vẫn được thỏa mãn;
  • tên của hành động phải là tên thật, nếu không thì chẳng có gì để thay: fail2ban-client get <jail> actions sẽ cho thấy nó.

Ai còn nằm trong cùng danh sách ấy

fail2ban ở đây không có lỗi và cũng không phải trường hợp duy nhất. Nguy hiểm chính là sự kết hợp NOPASSWD với một dấu sao. Những gì chúng tôi tìm thấy bên cạnh nó trên máy chủ của mình:

  • journalctl * — journal mở qua một trình phân trang, và từ trình phân trang có thể khởi chạy một shell. Quy tắc trông như về việc đọc log; trên thực tế nó là một shell root. Cách chữa không phải là mẫu hẹp hơn mà là nhóm systemd-journal: khi đó journalctl hoạt động hoàn toàn không cần sudo;
  • grep * /var/log/fail2ban.log — đối số đầu tiên của grep là mẫu, nhưng dấu sao cho phép cấp thêm một đường dẫn thứ hai, và grep chạy với quyền root. Đọc /etc/shadow bằng quy tắc đó chỉ là một câu lệnh. Thứ thay thế cũng vậy: nhóm adm để đọc log;
  • ufw --force * và các ufw allow/deny/delete * trơ trọi — tiến trình web có thể tắt hẳn tường lửa. Đặc biệt khó chịu là trong cả hai trường hợp, bảng quản trị không hề dùng những quy tắc đó: những quyền đã chết còn sót lại từ các phiên bản đầu của trình cài đặt;
  • lynis-scan.sh * — một lớp bọc chuyển tiếp "$@" vào một tiến trình root thì tương đương với một quy tắc không có bất kỳ giới hạn nào.

Làm sao thấy được ở chỗ bạn có gì

Thứ cần xem không phải là tệp mà là quyền thực tế của một người dùng cụ thể — người dùng mà pool PHP-FPM thực sự chạy dưới tên đó (không phải lúc nào cũng là www-data; trên một trong các máy của chúng tôi, bảng quản trị chạy dưới tên admin):

ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin

Tiếp đó là chính danh sách tệp, và ở đây có hai cái bẫy đã lấy đi thời gian của chúng tôi:

sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/

Cái thứ nhất: quy tắc sống ở hai nơi cùng lúc. Ở chỗ chúng tôi, dòng nguy hiểm có cả trong /etc/sudoers.d/monitor lẫn trong /etc/sudoers chính — trong tệp chính có khoảng tám bản, do các phiên bản khác nhau của trình cài đặt tích lại. Dọn một nơi rồi yên tâm là cách thường thấy để để ngỏ cái lỗ.

Cái thứ hai: sudo bỏ qua những tệp có dấu chấm trong tên. Tệp www-data.bak3 trong /etc/sudoers.d/ trông như một quy tắc đang có hiệu lực và đọc lên cũng như một quy tắc đang có hiệu lực, nhưng không có tác dụng. Điều này đúng theo cả hai chiều: «quy tắc thì có, quyền thì không» và sự an tâm hão huyền rằng một bản sao cấu hình «đang nằm ngay bên cạnh».

Thay bằng gì

Thu hẹp mẫu là vô ích — một dấu sao ở bất kỳ đâu trên dòng cũng đưa bài toán về lại điểm khởi đầu. Chỉ một cách đứng vững: một lớp bọc root nhận các đối số vị trí cố định, tự kiểm tra chúng và gọi chương trình mà không để lại chút khả năng nào để thêm thứ gì.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, và không gì hơn
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }

case "$action" in
  ban)   exec /usr/bin/fail2ban-client set "$jail" banip   "$ip" ;;
  unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac

Lớp bọc thuộc về root, quyền 755, và — điều này không phải tùy chọn — nó không được ghi được từ web, nếu không toàn bộ cấu trúc mất hết ý nghĩa. Trong sudoers chỉ còn lại nó:

www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b

Các đối số không được liệt kê trong quy tắc: chính tập lệnh kiểm tra chúng, và các vị trí trong đó là cố định, nên không có chỗ nào để nhét một văn bản hành động vào. Các quy tắc chỉ đọc — trạng thái, ss, ipset list — đi thành những dòng riêng, cũng không có dấu sao ở nơi nào làm được như vậy.

Riêng về việc thay sudo bằng các nhóm. Cách làm là đúng: systemd-journal cho journal và adm cho log rẻ hơn và an toàn hơn bất kỳ quy tắc sudo nào. Nhưng không thể rút người dùng web ra khỏi các nhóm một cách mù quáng. Chúng tôi đưa www-data ra khỏi một nhóm trên máy chủ có bảng điều khiển và lập tức nhận 403 trên toàn bộ các trang: ở đó Apache chạy dưới tên www-data, tệp của các trang thuộc về một người dùng khác, và quyền đọc đến chính từ việc thuộc nhóm đó. Việc khôi phục đòi hỏi khởi động lại hoàn toàn — không phải nạp lại — Apache và PHP-FPM, vì các tiến trình làm việc cũ giữ nguyên bộ nhóm trước đó và tạo ra tình trạng «chạy được, rồi lại 403».

Kiểm tra sau khi sửa

Trước khi thay một tệp sudoers, cần kiểm tra cú pháp của nó — nếu không có thể mất sudo hoàn toàn:

sudo visudo -cf /etc/sudoers.d/monitor

Sau đó cần xác nhận rằng hướng tấn công cũ đã chết, và làm điều đó dưới tên đúng người dùng ấy:

sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10

Câu trả lời phải là a password is required, còn việc gọi lớp bọc với rác thay cho một địa chỉ phải cho ra invalid ip. Ở đây có một cái bẫy riêng đang chờ: sudo -v lưu xác nhận trong mười lăm phút, và sau đó mọi lần kiểm tra bằng sudo -n đều qua «thành công». Chính bằng cách ấy, một lần chúng tôi đã xác nhận một quy tắc vốn không hề có trên máy chủ. Vì vậy sudo -k trước khi kiểm tra là bắt buộc.

Quan sát để làm gì

Quy tắc sudo ít thay đổi, nhưng chúng thay đổi không tiếng động: trình cài đặt viết thêm vào, bảng của nhà cung cấp hosting bổ sung phần của mình, một gói bị loại bỏ để lại những dòng của nó. Một lần soát xét sẽ đóng được những gì tồn tại hôm nay và không nói gì về những gì bản cập nhật tới sẽ mang lại. Kết luận thực tế rất đơn giản: danh sách những ai có thể trở thành root nên ở trong tầm mắt, bên cạnh phần trạng thái còn lại của máy chủ, chứ không phải trong ký ức sau một sự cố. Tất cả những thứ đó khi gộp lại trông ra sao thì các trang demo bên dưới sẽ cho thấy.