แผงควบคุมบนเซิร์ฟเวอร์มักชนกำแพงเดิมแทบทุกครั้ง: โปรเซสของเว็บต้องทำสิ่งที่ตนไม่มีสิทธิ์ทำ แบนที่อยู่หนึ่ง เปิดพอร์ตหนึ่ง อ่านบันทึกของระบบ คำตอบที่ผุดขึ้นมาเองคือให้บรรทัดแคบ ๆ เพียงบรรทัดเดียวใน sudoers และไม่ให้อะไรมากกว่านั้น:

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

บรรทัดนี้อ่านได้ว่า «แบนได้เท่านั้น» แต่ความจริงมันหมายถึง «อะไรก็ได้ ในสิทธิ์ root» ด้านล่างนี้คือเหตุที่เป็นเช่นนั้น วิธีตรวจบนเซิร์ฟเวอร์ของคุณเองในห้านาที และกฎแบบนี้ควรแทนด้วยอะไร

* ทำอะไรจริง ๆ

รายละเอียดที่ชี้ขาด: sudo ไม่ได้เทียบอาร์กิวเมนต์ทีละตัว แต่เทียบบรรทัดคำสั่งทั้งบรรทัด และ * ในรูปแบบนั้นข้ามช่องว่างได้อย่างสบาย นั่นคือข้ามพรมแดนระหว่างอาร์กิวเมนต์ ด้วยเหตุนี้คำตรงตัว banip ที่ยืนอยู่กลางกฎจึงไม่ได้จำกัดอะไรเลย: เพียงให้คำ banip ปรากฏที่ใดที่หนึ่งในคำสั่งก็พอ และก่อนหน้ากับหลังจากนั้นจะส่งอะไรเข้าไปก็ได้

ขั้นต่อมาคือมองหาโปรแกรมที่สามารถส่งคำสั่งให้ไปรันได้ ในกรณีของเรามันอยู่ในกฎนั้นเอง ใน fail2ban การกระทำสำหรับการแบนถูกกำหนดเป็นข้อความ และข้อความนี้สามารถนิยามใหม่ได้ระหว่างทำงาน:

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<คำสั่ง>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

การเรียกครั้งแรกเปลี่ยนข้อความของการกระทำจริงตัวหนึ่ง ครั้งที่สองบังคับให้ jail ทำงาน — และ fail2ban ซึ่งรันในสิทธิ์ root ก็ปฏิบัติตามสิ่งที่ถูกใส่ไว้ เราทำสิ่งนี้เมื่อวันที่ 4 กรกฎาคมบนเซิร์ฟเวอร์ของเราเอง: ไฟล์ใน /root ถูกสร้างขึ้นด้วยสิทธิ์ root นั่นคือโปรเซสของเว็บได้สิทธิ์เต็มบนเครื่องจากกฎที่ดูแคบ

ตามทางมีรายละเอียดสามข้อปรากฏขึ้น ซึ่งมักไม่พบในคำอธิบายของคนอื่น:

  • อ็อปชัน -c ที่วางไว้ก่อน set นั้น sudo ตัดออก: ในรูปแบบนั้น set ติดอยู่กับชื่อไฟล์ไบนารีโดยตรง และแทรกอะไรไว้ข้างหน้าไม่ได้;
  • addaction และ action ที่ไม่มีคำ banip ก็ผ่านไม่ได้ — แต่ทุกอย่างที่จำเป็นบรรจุลงในคำสั่งเดียว set … banip … ได้ และรูปแบบก็เป็นไปตามเงื่อนไข;
  • ชื่อของการกระทำต้องเป็นชื่อจริง ไม่เช่นนั้นก็ไม่มีอะไรให้เปลี่ยน: fail2ban-client get <jail> actions แสดงชื่อนั้น

ใครอีกที่อยู่ในรายการเดียวกัน

fail2ban ไม่ได้ผิดในเรื่องนี้ และไม่ใช่กรณีเดียว สิ่งที่อันตรายคือการจับคู่ NOPASSWD กับดอกจันเอง สิ่งที่เราพบอยู่ข้าง ๆ กันบนเซิร์ฟเวอร์ของเรา:

  • journalctl * — บันทึกของระบบเปิดผ่านโปรแกรมแสดงหน้า และจากโปรแกรมนั้นก็เรียกเชลล์ขึ้นมาได้ กฎดูเหมือนเกี่ยวกับการอ่านล็อก แต่ในทางปฏิบัติมันคือเชลล์ของ root ทางแก้ไม่ใช่รูปแบบที่แคบลง แต่เป็นกลุ่ม systemd-journal: แล้ว journalctl ก็ทำงานได้โดยไม่ต้องใช้ sudo เลย;
  • grep * /var/log/fail2ban.log — อาร์กิวเมนต์แรกของ grep คือรูปแบบ แต่ดอกจันเปิดให้ใส่พาธที่สองได้ด้วย และ grep ก็รันในสิทธิ์ root การอ่าน /etc/shadow ด้วยกฎนี้เป็นคำสั่งเดียว ทางแทนก็เหมือนกัน: กลุ่ม adm สำหรับอ่านล็อก;
  • ufw --force * และ ufw allow/deny/delete * เปล่า ๆ — โปรเซสของเว็บสามารถปิดไฟร์วอลล์ได้ทั้งตัว ที่น่าอึดอัดเป็นพิเศษคือทั้งสองกรณีแผงควบคุมไม่ได้ใช้กฎเหล่านั้นเลย เป็นสิทธิ์ที่ตายแล้วซึ่งเหลือมาจากตัวติดตั้งรุ่นแรก ๆ;
  • lynis-scan.sh * — ตัวห่อที่ส่ง "$@" ต่อไปยังโปรเซส root เทียบเท่ากับกฎที่ไม่มีข้อจำกัดใด ๆ

ดูอย่างไรว่าที่คุณมีอะไรอยู่

สิ่งที่ต้องดูไม่ใช่ไฟล์ แต่เป็นสิทธิ์ที่แท้จริงของผู้ใช้รายหนึ่ง — ผู้ใช้ที่พูล PHP-FPM รันด้วยจริง ๆ (ไม่ใช่ www-data เสมอไป บนเครื่องหนึ่งของเราแผงควบคุมรันในชื่อ admin):

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

ต่อมาคือรายการไฟล์เอง และที่นี่มีกับดักสองอันที่กินเวลาของเราไป:

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

อันแรก: กฎอยู่สองที่พร้อมกัน ที่เราบรรทัดอันตรายอยู่ทั้งใน /etc/sudoers.d/monitor และในไฟล์หลัก /etc/sudoers — ในไฟล์หลักมีราวแปดสำเนา ซึ่งตัวติดตั้งรุ่นต่าง ๆ สะสมไว้ การเก็บกวาดที่เดียวแล้วสบายใจคือวิธีปกติที่ทำให้ช่องโหว่ยังเปิดอยู่

อันที่สอง: sudo ไม่สนใจไฟล์ที่มีจุดอยู่ในชื่อ ไฟล์ www-data.bak3 ใน /etc/sudoers.d/ ดูเหมือนกฎที่ใช้อยู่ และอ่านได้เหมือนกฎที่ใช้อยู่ แต่ไม่มีผลใด ๆ เรื่องนี้ทำงานทั้งสองทาง: «กฎมีอยู่ แต่สิทธิ์ไม่มี» และความสบายใจลวง ๆ ที่ว่าสำเนาของค่าคอนฟิก «วางอยู่ตรงข้าง ๆ นั่นแล้ว»

แทนด้วยอะไร

การทำรูปแบบให้แคบลงไม่ช่วยอะไร — ดอกจันที่อยู่ตำแหน่งใดก็ได้ในบรรทัดพาโจทย์กลับไปที่จุดเริ่มต้น มีแนวทางเดียวที่อยู่รอด: ตัวห่อของ root ที่รับอาร์กิวเมนต์ตามตำแหน่งที่ตรึงไว้ ตรวจสอบเองและเรียกโปรแกรมโดยไม่เปิดช่องให้เติมอะไรได้เลย

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip> และไม่มีอะไรมากกว่านั้น
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

ตัวห่อเป็นของ root สิทธิ์ 755 และ — ข้อนี้ไม่ใช่ทางเลือก — ต้องไม่สามารถเขียนได้จากเว็บ ไม่เช่นนั้นโครงสร้างทั้งหมดก็ไร้ความหมาย ใน sudoers เหลือเพียงตัวห่อนี้:

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

อาร์กิวเมนต์ไม่ได้ถูกระบุไว้ในกฎ: สคริปต์ตรวจสอบเอง และตำแหน่งภายในสคริปต์ถูกตรึงไว้ จึงไม่มีที่ให้สอดข้อความของการกระทำเข้าไป กฎที่อ่านเท่านั้น — สถานะ, ss, ipset list — แยกไปอยู่บรรทัดของตัวเอง และไม่มีดอกจันเช่นกันในที่ที่ทำได้

แยกอีกเรื่องคือการแทน sudo ด้วยกลุ่ม แนวทางนี้ถูกต้อง: systemd-journal สำหรับบันทึกของระบบ และ adm สำหรับล็อก ถูกกว่าและปลอดภัยกว่ากฎ sudo ใด ๆ แต่จะดึงผู้ใช้ของเว็บออกจากกลุ่มแบบไม่ดูตาม้าตาเรือไม่ได้ เราเอา www-data ออกจากกลุ่มหนึ่งบนโฮสต์ที่มีแผงควบคุม แล้วได้ 403 พร้อมกันทุกเว็บไซต์: ที่นั่น Apache รันในชื่อ www-data ไฟล์ของเว็บไซต์เป็นของผู้ใช้อีกคน และสิทธิ์อ่านมาจากการเป็นสมาชิกกลุ่มนั้นพอดี การกู้คืนต้องรีสตาร์ต Apache และ PHP-FPM ทั้งตัว — ไม่ใช่รีโหลด — เพราะเวิร์กเกอร์เก่ายังถือชุดกลุ่มเดิมไว้และทำให้เกิดอาการ «ใช้ได้ แล้วก็ 403»

ตรวจสอบหลังแก้

ก่อนจะเปลี่ยนไฟล์ sudoers ต้องตรวจไวยากรณ์ของไฟล์ก่อน — ไม่เช่นนั้นอาจเหลือเครื่องที่ไม่มี sudo เลย:

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

หลังจากนั้นต้องยืนยันว่าเส้นทางเดิมตายแล้ว และทำในนามของผู้ใช้รายนั้นด้วย:

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

คำตอบควรเป็น a password is required ขณะที่การเรียกตัวห่อด้วยขยะแทนที่อยู่ควรให้ invalid ip ที่นี่มีกับดักของตัวเองซุ่มอยู่: sudo -v เก็บการยืนยันไว้สิบห้านาที และหลังจากนั้นการตรวจด้วย sudo -n ทุกครั้งก็ผ่าน «อย่างสำเร็จ» เราเคยยืนยันกฎที่ไม่มีอยู่บนเซิร์ฟเวอร์เลยด้วยวิธีนี้พอดี ฉะนั้น sudo -k ก่อนตรวจจึงเป็นสิ่งจำเป็น

เฝ้าดูไปเพื่ออะไร

กฎของ sudo เปลี่ยนไม่บ่อย แต่เปลี่ยนอย่างเงียบ ๆ: ตัวติดตั้งเขียนเพิ่ม แผงของโฮสติงใส่ของตัวเอง แพ็กเกจที่ถอนออกไปทิ้งบรรทัดของมันไว้ การตรวจสอบครั้งเดียวปิดสิ่งที่มีอยู่วันนี้ และไม่บอกอะไรเลยว่าอัปเดตครั้งหน้าจะนำอะไรมา ข้อสรุปเชิงปฏิบัติง่าย ๆ คือ รายชื่อผู้ที่สามารถกลายเป็น root ควรอยู่ในสายตาเคียงข้างสถานะอื่นของเซิร์ฟเวอร์ ไม่ใช่อยู่ในความจำหลังเกิดเหตุ ทั้งหมดนี้เมื่อประกอบเข้าด้วยกันมีหน้าตาอย่างไร ดูได้จากหน้าเดโมด้านล่าง