서버의 관리 패널은 거의 언제나 같은 벽에 부딪힙니다. 웹 프로세스가 권한 없는 일을 해야 하는 것입니다. 주소를 차단하고, 포트를 열고, 저널을 읽는 일입니다. 가장 먼저 떠오르는 답은 sudoers에 좁은 한 줄만 허용하고 그 밖에는 아무것도 주지 않는 것입니다.
www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *
이 줄은 「차단만 가능」으로 읽힙니다. 실제 의미는 「root 권한으로 무엇이든」입니다. 아래에서는 왜 그렇게 되는지, 자신의 서버에서 5분 만에 어떻게 확인하는지, 그리고 그런 규칙을 무엇으로 바꾸는지 다룹니다.
*가 실제로 하는 일
결정적인 세부 사항은 이렇습니다. 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을 발동시킵니다. 그러면 root로 동작하는 fail2ban이 끼워 넣은 것을 실행합니다. 우리는 이 일을 7월 4일에 우리 서버에서 해 보았습니다. /root에 root 권한으로 파일이 생겼습니다. 즉 웹 프로세스가 좁아 보이던 규칙 하나로 그 기계에 대한 완전한 권한을 얻은 것입니다.
그 과정에서 다른 사람들의 정리에는 보통 없는 세 가지 세부 사항이 드러났습니다.
set앞에 붙인-c는 sudo가 잘라냅니다. 패턴에서set은 실행 파일 이름에 곧바로 붙어 있고, 그 앞에는 아무것도 끼워 넣을 수 없습니다.banip이라는 낱말이 없는addaction과action도 통과하지 못합니다. 그러나 필요한 것 전부가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는 이름에 점이 들어간 파일을 무시합니다. /etc/sudoers.d/의 www-data.bak3 파일은 유효한 규칙처럼 보이고 유효한 규칙처럼 읽히지만 효력이 없습니다. 이는 양쪽으로 작동합니다. 「규칙은 있는데 권한은 없다」는 쪽과, 설정의 사본이 「바로 옆에 놓여 있다」는 거짓 안심 쪽 모두입니다.
무엇으로 바꿀 것인가
패턴을 좁히는 것은 소용없습니다. 줄 어디에든 별표가 있으면 문제는 출발점으로 되돌아갑니다. 버티는 방법은 하나뿐입니다. 위치가 고정된 인수를 받아 스스로 검증하고, 무엇도 덧붙일 여지 없이 프로그램을 호출하는 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는 확인을 15분간 캐시하고, 그 뒤에는 sudo -n으로 하는 어떤 점검도 「성공적으로」 통과합니다. 바로 그렇게 해서 우리는 한때 서버에 아예 없는 규칙을 확인한 셈이 되었습니다. 그래서 점검 전의 sudo -k는 필수입니다.
관찰이 필요한 이유
sudo 규칙은 드물게 바뀌지만, 바뀔 때는 소리 없이 바뀝니다. 설치 프로그램이 덧붙이고, 호스팅 패널이 자기 몫을 넣고, 제거된 패키지가 자기 줄을 남깁니다. 한 번의 점검은 오늘 있는 것을 닫아 줄 뿐이고, 다음 업데이트가 무엇을 들여올지에 대해서는 아무 말도 하지 않습니다. 실무적 결론은 간단합니다. 누가 root가 될 수 있는지의 목록은 사고 뒤의 기억 속이 아니라, 서버의 다른 상태와 나란히 눈앞에 있어야 합니다. 이것들이 한데 모이면 어떤 모습인지는 아래 데모 페이지에서 확인할 수 있습니다.