`seccomp()`는 올바르게 사용하기가 근본적으로 어렵고, libc/아키텍처/실행 환경에 따라 호출되는 syscall이 바뀌는 등 취약한 가정에 기대는 보안 기법이라는 주장과, OpenBSD의 `pledge()`/`unveil()` 같은 모델이 더 낫다는 논의를 정리한다.
Livecount:connecting…
이건 내 개인 블로그다. 이 페이지들에 표현된 견해는 전적으로 내 것이며 내 고용주의 견해가 아니다.
2022-03-11, Categories: linux, programming, security
seccomp()를 올바르게 사용할 방법은 없다고 단언하겠다. gets()를 올바르게 사용할 방법이 없어서 결국 C와 C++ 표준에서 제거된 것과 마찬가지다.
seccomp는 규칙 집합으로 syscall을 필터링할 수 있게 해준다.
가장 명백한 건 프로그램이 하면 안 되는 일은 전부 걸러내는 것이다. 파일 IO를 하지 않는다면 파일을 열지 못하게 하라. 뭔가를 실행하면 안 된다면 그걸 못 하게 하라.
하지만 허용 목록(예: 이미 열린 파일 디스크립터로만 작업 허용)을 쓰든, 차단 목록(예: 이 파일들은 열지 못하게 함)을 쓰든, 근본적으로 결함이 있다.
코드의 open()은 실제로 openat syscall이 된다. 아마도. 적어도 오늘은. 적어도 내 컴퓨터에서는 오늘은 그렇다.
select()는 실제로는 pselect6가 된다. 적어도 금요일에는.
libc를 업그레이드하거나 바이너리를 다른 시스템에 배포하면, 이게 갑자기 실패하기 시작할 수 있다.
printf()를 호출하면 newfstatat syscall을 호출하게 된다. 이건 말로 풀어내기도 어려운 syscall이다. 그런데 처음 printf()를 호출할 때만 그렇다! 그러니 첫 printf() 이후에는 newfstatat를 막아도 된다.
보통은 아마 전부 잘 동작할 것이다. 하지만 그러다 관련 없어 보이는 버그가 발생하고, 도구가 그걸 로그로 남기려 하는데 newfstatat가 막혀 있어서 못 남길 수 있다. 그러면 로그가 없다.
그러니까 무엇을 호출하느냐뿐 아니라, 권한을 떨굴 때 어떤 순서로 호출하느냐에 매우 크게 의존한다.
내 예에서는 verbose 모드를 켰을 때는 잘 됐지만 끄면 안 됐다. verbose 모드에서는 권한을 떨구기 전에 printf()를 호출했기 때문이다.
아마도 모두가 가장 흔히 하고 싶은 건 이거다: 모든 설정이 끝난 뒤에는, 이미 열린 파일 디스크립터를 통한 것 외에는 프로세스가 그 어떤 다른 것과도 상호작용하지 못하게 하라.
그건 거의 맞다. 현재 시간 얻기와 메모리 할당은 아마도 안전할 것이다.
(하지만 원래의 바이너리 on/off seccomp()는 그것들조차 막았다)
하지만 이를 표현할 방법이 없다. 이미 열린 네트워크 소켓과 가장 최소한으로 상호작용하려면 최소한 다음이 필요하다:
pselect6selectpollppollwritepwrite64writevpwritevreadpread64preadpreadvclosesendfilesendtosendmsgsendmmsgrecvfromrecvmsgrecvmmsg그리고 이건, 어떤 안전하지 않은 코드(예: 파서)가 한 fd에서 입력을 받아 다른 fd로 출력을 내보내는 가장 사소한 예에 대해서만 그렇다. 예를 들어 X.509 인증서(악명 높게 파싱하기 까다로운)와 호스트네임을 받아 유효한지 여부를 반환하는 오라클을 구현한다고 하자.
그리고 더 나쁜 점: 이건 완전히 동적이며 아키텍처에 의존한다. 실행마다 달라질 수 있고, 밀리초마다 달라질 수도 있다. 이건 ABI의 일부가 아니다.
libc가 read()를 readv()의 특수한 경우로 구현하도록 바꾸지 못하게 막는 건 아무것도 없다. select()는 내일 poll()을 이용해 구현될 수도 있다.
300+ syscalls가 있고, 아마 늘어날 것이다. 그중 어떤 것들이 “그냥 소켓에서 읽거나 쓰기”에 해당하는지 알고 있는가?
그래서 seccomp(2) manpage가 이렇게 말할 때 현실적이라고 생각하지 않는다:
It is strongly recommended to use an allow-list approach whenever
possible because such an approach is more robust and simple. A
deny-list will have to be updated whenever a potentially dangerous
system call is added
그거 행운을 빈다.
OpenBSD는 분명 이걸 제대로 했다. syscall을 나열하지 마라. poll()이든 select()든 누가 신경 쓰겠는가?
예를 들어 arping은 어떤 나쁜 짓도 전혀 하지 못하게 막기 위해 이 코드를 갖고 있다.
자, 생각해 보라. 프로세스를 완전히 제어할 수 있다 해도 pledge("stdio", "")를 실행한 뒤에 뭘 할 수 있겠는가? 사용자에게 욕설을 출력하기? 틀린 종료 코드로 종료하기? 그래, 하지만 그게 전부다.
하지만 seccomp()는 더 많은 제한을 허용한다. arping에서는 stdout과 stderr로만 쓸 수 있고 읽을 수는 없게 막았다. 하지만 그래서 뭐 어쨌다는 건가? 이 말에 대해 나중에 말을 바꿔야 할지도 모르지만, stdout에서 읽을 수 있는 것이 보안 문제를 일으킬 것 같진 않다.
pledge(), 그리고 unveil()이야말로 여기에서 분명 올바른 해결책이다.
언젠가 Landlock이 그 해답이 될지도 모른다. 하지만 여러 세대에 걸친 Linux 해법들이 악몽처럼 계속 틀려왔던 걸 생각하면, 큰 기대는 하지 않겠다.
지금으로선 unshare()가 가야 할 길인 것 같다. 하지만 그것조차 까다롭다(그리고 그렇게 많이 막지도 못한다). 사용 가능한 도구로 바깥세상에 대한 접근을 떨어뜨리는 방법에 대한 후속 글을 쓸 계획이다.
업데이트: 그 후속 글.
disqus가 광고를 보여주기 시작했다. :-(
정적 읽기 전용 뷰로 (아마도 불완전한) 댓글을 표시하는 중이다. 댓글을 남기려면 버튼을 클릭하라.
광고가 있어도 댓글 보기
내가 발견했거나 해본 무작위 기술적인 것들을 적어두는 블로그.