Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

[Book] 컨테이너 보안 - 컨테이너화된 응용 프로그램의 보안을 위한 개념, 이론, 대응법과 모범 관행까지

CNCF Technical Oversight Committee 의장인 Liz Rice가 쓴 책에 믿고 보는 류광 님의 번역이라 아주 편안하게 읽은 책이다.

여기까지 읽은 독자라면 컨테이너가 무엇인지에 관한 하나의 '정신 모형'이 머리 속에 확립되었을 것이다. 그런 모형은 컨테이너 배치본의 보안에 관한 논의에 큰 도움이 될 것이다.

올해는 보안에 관해서도 좀 관심을 가져야겠다고 생각하면서 읽기 시작한 책이다. 컨테이너는 깊게 본건 오래되기도 했고(요즘은 그냥 사용만 하니까...) 보안 관련 지식도 쌓으면서 컨테이너 정보도 업데이트할 생각으로 읽었는데 보안이라고 해서 그런지 실용적인 내용을 생각했던 것 같다. 컨테이너 빌드할 때 어떤 옵션을 써야 한다는 등의 실용적인 내용을 생각했지만 이런 내용이 없는 것은 아니지만 컨테이너 동작 방식과 그 가운데 보안과 관련된 부분을 같이 설명해서 위의 맺음말에 나온 것처럼 컨테이너에 대한 정신 모형에 대한 이해도가 높아진 느낌이다.

그래서 어떤 방법을 써야 안전한지를 배웠다기 보다는 어떤 부분이 위험할 수 있는지를 알아서 대응 방법을 여러 후보 중에 상황에 맞게 적절하게 선택할 수 있는 지식을 알게 된 느낌이라 기대 이상으로 아주 좋았다. 원서는 Container Security이고 2020년 4월에 나왔기 때문에 현재와는 달라진 부분은 있을 수 있다는 걸 알고 봐야 한다.

아래는 후기라기보다는 나중에 참고하기 위해서 배운 내용과 느낌을 정리한 부분이다.

1장 컨테이너 보안 위협

위험(risk)은 잠재적인 문제점과 그 문제점이 실제로 발생했을 때 생기는 효과를 아우르는 개념이다.
* 위협(threat) 또는 위협 요소는 그러한 위험의 발생으로 이어지는 경로를 말한다.
* 완화(mitigation)는 위협에 대응하는 것이다. 즉, 완화는 위협을 방지하거나, 적어도 위협이 성공할 가능성을 낮추는 대책이나 활동이다.

deployment를 '배포'로 옮기기도 하지만, 배포는 distribution과 혼동할 여지가 있다. deployment에는 배포뿐만 아니라 설치,
설정, 실행 등 소프트웨어를 사용 가능한 상태로 만드는 모든 과정이 포함된다는 점을 강조하기 위해 이 책에서는 '배치(配置)'를 사용한다.

널리 통용되는 정의에 따르면 가상화(virtualization) 자체는 전혀 다중 입주로 간주되지 않는다. 다중 입주는 서로 다른 사람들이 같은 소프트웨어의 인스턴스 하나를 공유하는 것인데, 가상화에서 사용자들은 자신의 VM을 관리하는 프로그램(hypervisor)에 접근하지 못하므로 소프트웨어를 공유하는 것이 아니다.
그렇다고 VM들 사이의 격리가 완벽하다는 뜻은 아니다. 사실 사용자들은 예전부터 '시끄러운 이웃'에 관한 불평을 제기했다. 물리적 컴퓨터를 다른 사용자들과 공유하면 VM의 성능이 예기치 않게 변한다는 점은 사실이다.

한편, 좀 더 최근에는 ‘시끄러운 이웃’ 문제가 실질적인 문제점이 아니는 주장도 있었다.

이 글은 시끄러운 이웃 문제가 없다기보다는 퍼블릭 클라우드에서는 아주 빠르게 프로비저닝을 할 수 있어서 쉽게 해결할 수 있는 문제라서 큰 문제가 아니라는 주장이긴 하다.

최소 권한 원칙 또는 최소 특권 원칙(principle of least privilege)은 사용자 또는 구성 요소가 해당 작업을 진행하는 데 꼭 필요한 것에만 접근할 수 있게 해야 한다는 것이다.

이 원칙에는 동의하지만, 현실에서는 이렇게 하는데 어려움이 꽤 많은 느낌이다. 스타트업 초기부터 잘해놓는다면 나을 수도 있겠지만 성장하는 서비스에서 초기에 미리 생각해 놓는다는 게 가능할지는 잘 모르겠다.

심층 방어 원칙(principle of defense in depth)이란 보호를 여러 층으로 적용해야 한다는 것이다. 공격자가 방어층 하나를 뚫는다고 해도, 그다음에 또 다른 방어층이 있으면 공격자가 배치본에 해를 끼치거나 데이터를 탈취하지 못한다.

컨테이너는 이러한 폭발 반경(blast radius) 제한 원칙에 잘 맞는다. 컨테이너는 하나의 아키텍처를 한 마이크로서비스의 여러 인스턴스로 분할함으로써 하나의 보안 경계로 작용하기 때문이다.

직무 분리(segregation of duties, SoD; 또는 직무 분장, 의무의 분리) 원칙은 최소 권한 및 폭발 반경 제한과 관련이 있다. 이 원칙은 서로 다른 구성 요소나 사람이 전체 시스템 중 꼭 필요한 가장 작은 부분집합에 대해서만 권한(authority)을 가져야 한다는 것이다. 이 접근 방식은 특정 연산들을 사용자 한 명의 권한만으로는 수행할 수 없게 함으로써, 특권 있는 사용자 한명이 필요 이상으로 큰 피해를 시스템에 끼치는 사태를 방지한다.

2장 리눅스 시스템 호출, 접근 권한, 능력

일반적으로 setuid 비트는 보통의 사용자에게는 없는 어떤 권한을 프로그램에 부여하는 용도로 쓰인다.

ls 명령의 출력을 잘 살펴보면 원본 ping의 접근 권한 문자열에는 보통의 x가 아니라 s가 있는데 이것이 setuid 비트다.

setuid는 권한 확대로 이어지는 위험한 경로가 될 수 있으므로, 일부 컨테이너 이미지 스캐너들은 컨테이너에 setuid 비트가 설정된 파일이 있으면 그 사실을 보고해 준다. 또는, docker run 명령 실행 시 --no-new-privileges 플래그를 주어서 이런 권한 확대를 방지할 수도 있다.

"권한 확대(privilege escalation; 또는 특권 상승)"는 말 그대로 사용자가 가진 권한을 평소보다 더 넓게 확대해서 보통은 실행하지 못하는 일도 실행하게 만드는 것이다. 공격자들은 시스템의 취약점이나 잘못된 설정을 이용해서 자신의 권한을 확대함으로써 추가적인 접근 권한을 얻으려 한다.

기본적으로 컨테이너는 루트로 실행된다. 따라서, 전통적인 리눅스 컴퓨터와는 달리 컨테이너에서 실행되는 응용 프로그램들은 루트로 실행될 여지가 많다. 공격자가 컨테이너 안의 한 프로세스를 장악해서 어떻게든 컨테이너 밖으로 탈출했다면, 그 공격자는 이미 해당 호스트에 대한 루트 사용자이므로 추가적인 권한 확대는 필요하지 않다.

보안을 설명하기 전에 리눅스의 권한 관리와 시스템 호출 구조에 관해서 설명한다.

3장 cgroups와 제어 그룹

'control groups'를 줄인 cgroups는 주어진 그룹에 속한 프로세스들이 사용할 수 있는 자원(메모리나 CPU, 네트워크 입출력 등)을 제한하는 수단이다.

컨테이너를 격리할 때 기본적인 지식이라고 할 수 있는 cgroups와 제어그룹이 어떻게 동작하고 어떤 한계가 있는지를 설명한다.

4장 컨테이너 격리

cgroups가 프로세스가 사용할 수 있는 자원을 제한한다면, 이름공간(namespace)은 프로세스가 "볼(see)" 수 있는 것들을 제한한다. 프로세스를 어떤 이름공간에 넣으면 프로세스는 그 이름공간이 허용하는 것들만 볼 수 있게 된다.

이름공간의 기원은 Plan 9 운영체제로 거슬러 올라간다.

리눅스 커널에 이름공간이 처음 도입된 것은 2002년의 버전 2.4.19에서이다. 이것은 마운트 이름공간이었는데, 이후 Plan 9의 것과 비슷한 기능성이 추가되었다.

현재 존재하는 이름공간들은 lsns라는 명령으로 확인할 수 있다.

컨테이너가 개별적인 호스트 이름을 가지는 이유는 도커가 개별적인 UTS 이름공간을 컨테이너에 적용했기 때문이다. 이처럼 개별적인 UTS 이름공간을 가진 프로세스를 만드는 수단으로 unshare라는 명령이 있다.

chroot는 이름 그대로 프로세스의 "루트(root)를 변경한다(change)". 루트가 바뀌면 프로세스는(그리고 자식 프로세스들은) 파일 시스템 위계구조에서 새 루트 디렉터리보다 아래에 있는 파일들과 디렉터리들에만 접근할 수 있다.

chroot 외에 pivot_root라는 시스템 호출도 있다. 이번 장의 목적에서 chroot를 사용하느냐 pivot_root를 사용하느냐는 그냥 구현상의 세부사항일 뿐이다. 지금까지의 예제들에서 chroot를 사용한 것은 그냥 chroot가 약간 더 간단하고 많은 사람에게 익숙하기 때문일 뿐이다.
보안을 위해서는 chroot 대신 pivot_root를 사용하는 것이 몇 가지 면에서 더 낫다. 실제로 쓰이는 컨테이너 런타임 구현 소스 코드를 보면 pivot_root를 더 자주 보게 될 것이다. 주된 차이점은, pivot_root는 마운트 이름공간의 장점을 취한다는 것이다. pivot_root의 경우 기존 루트는 더 이상 마운트되지 않으므로, 그 마운트 이름공간 안에서 접근이 아예 불가능하다. 반면 chroot 시스템은 이 접근 방식을 사용하지 않아서, 마운트 지점들을 통해서 기존 루트에 접근이 가능하다

사용자 이름공간(user namespace)은 프로세스가 특정한 사용자 ID들과 그룹 ID들을 볼 수 있게 만드는 수단이다. 프로세스 ID들처럼 사용자들과 그룹들은 여전히 호스트에 그대로 남아 있지만, ID들은 프로세스에서 다르게 보인다. 이러한 사용자·그룹 ID 격리의 주된 장점은 컨테이너 안에서 루트 ID 0을 호스트의 비루트 계정에 부여할 수 있다는 것이다. 이는 보안의 관점에서 커다란 장점이다. 이렇게 하면 컨테이너 안에서 소프트웨어 자체는 루트 계정으로 실행되지만, 공격자가 컨테이너에서 탈출해서 호스트로 간다고 해도 거기에서는 권한 없는 비루트 계정일 뿐이다.

이 책을 쓰는 현재, 사용자 이름공간이 아주 널리 쓰이지는 않는다. 도커에서는 이 기능이 기본적으로 비활성화되어 있고, 쿠버네티스는 이 기능의 도입이 논의된 적은 있지만 아직 지원하지 않는다.

chroot로 컨테이너가 어떻게 격리되고 동작하는지를 설명한다. 나는 컨테이너 기초 - chroot를 사용한 프로세스의 루트 디렉터리 격리에서 chroot에 관해서 알게 되었는데 여기서도 핵심만 잘 설명해 주어서 이해하기가 좋았다.

사용자 이름공간을 이용하면 권한 없는 비루트 사용자가 컨테이너화된 프로세스 안에서 실질적인 루트가 되게 할 수 있다.

다음은 도커 이외의 컨테이너 런타임에서 사용자 이름공간을 사용할 때에도 적용되는 문제점들이다.
* 사용자 이름공간은 호스트와의 프로세스 ID 공간 또는 네트워크 이름공간 공유와 호환되지 않는다.
* 컨테이너 안에서 프로세스가 루트로 실행된다고 해도 전체 루트 권한을 가지지는 않는다. 예를 들어 CAP_NET_BIND_SERVICE 능력이 없어서 낮은 번호의 포트들에는 바인딩할 수 없다.
* 컨테이너화된 프로세스가 파일과 상호작용하려면 적절한 접근 권한들이 필요하다(예를 들어 파일을 수정하려면 쓰기 접근 권한이 있어야 한다). 호스트에서 마운트한 파일의 경우에는 호스트의 유효 사용자 ID가 중요하다.

우리가 흔히 컨테이너라고 부르는 것을 좀 더 정확히 표현하는 용어는 "컨테이너화된 프로세스(containerized process)"이다. 컨테이너는 여전히 호스트 컴퓨터에서 실행되는 하나의 리눅스 프로세스이지만, 호스트 컴퓨터의 일부만 볼 수 있고 전체 파일 시스템의 한 부분 트리에만 접근할 수 있다. 또한 그 밖에도 몇 가지 자원들에 대한 접근이 제어 그룹으로 제한된다.

컨테이너가 제공하는 격리의 수준을 제대로 이해하는 데 중요한 점은 이 프로세스 ID들이 서로 다르긴 하지만 둘 다 같은 프로세스에 해당한다는 사실이다. 단지 호스트의 관점에서 볼 때는 프로세스 ID 번호가 더 높은 것일 뿐이다.
호스트에서 컨테이너 프로세스들을 볼 수 있다는 것은 컨테이너와 VM의 근본적인 차이점 중 하나이다. 어떤 방법으로든 호스트에 접근한 공격자는 호스트에서 실행되는 모든 컨테이너를 인식하고 영향을 미칠 수 있다. 공격자가 루트 권한을 가진다면 더욱 그렇다.

컨테이너 응용 프로그램들을 컨테이너 전용 호스트(물리적 컴퓨터이든 VM)에서 실행하는 것이 강력히 권장되는데, 주로는 보안과 관련된 이유 때문이다.
* 오케스트레이터를 이용해서 컨테이너들을 실행하는 환경에서는 사람이 호스트에 접근할 필요가 (거의) 없다. 컨테이너 이외의 응용 프로그램을 전혀 실행하지 않는다면 호스트 컴퓨터에는 최소한의 사용자 계정들만 있으면 된다. 사용자가 적으면 관리가 쉽고, 인증되지 않은 사용자의 로그인 시도를 발견하기도 쉽다.
* 그 어떤 리눅스 배포판도 리눅스 컨테이너를 실행할 호스트 OS로 사용할 수 있지만, 컨테이너 실행에 특화된 소위 '날씬한 OS(thin OS)' 배포판도 여러 개 있다.
* 한 클러스터의 모든 호스트 컴퓨터가 응용 프로그램에 특화된 요구사항들이 전혀 없는 동일한 설정을 공유할 수 있다. 설정을 공유하면 호스트 컴퓨터를 준비하는 과정을 자동화하기가 쉬워진다.

컨테이너의 동작 방식과 함께 어떤 부분이 격리되고 어떤 부분은 격리되지 않은지를 잘 설명해 준다. 컨테이너라고 하면 그 이름 때문에 격리되는 느낌이 강해서 크게 신경 안 쓰기 마련인데(실제로 좋아진 점도 있고...) 그 기반이 되는 구조를 잘 설명해서 좋았다. 컨테이너 보안을 얘기하는데 여기서부터 설명하나 싶다고 하면서도 책을 보면 볼수록 좋은 설명이라는 생각이 든다.

5장 VM과 컨테이너

둘의 근본적인 차이점은, VM은 커널을 포함해 운영체제 전체의 복사본을 실행하는 반면 컨테이너는 호스트 컴퓨터의 커널을 공유한다는 점이다.

VMM은 크게 두 종류로 나뉘는데, 그리 창의적인 이름은 아니지만 둘을 제1형(type 1)과 제2형(type 2)이라고 부른다.

제1형 VMM을 하이퍼바이저(hypervisor)라고 부르기도 한다. Hyper-V, Xen, ESX/ESXi가 하이퍼바이저의 예이다.

노트북이나 데스크톱 컴퓨터에서는 흔히 VirtualBox 같은 소프 트웨어로 VM을 실행하는데, VirtualBox가 제2형 VMM의 예이다.

VirtualBox 외에 Parallels, QEMU 등이 제2형 VMM이다.

KVM은 앞에서 제2형 VMM로 분류한 QEMU(Quick Emulation을 줄인 이름이다)와 함께 자주 쓰인다. QEMU는 게스트 OS가 요청한 시스템 호출을 동적으로 호스트 OS 시스템 호출로 번역한다. QEMU가 KVM이 제공하는 하드웨어 가속의 장점을 취할 수 있다는 점도 언급해야 할 것이다.

격리 측면에서 VM이 그렇게 유리하다면, 사람들이 여전히 컨테이너를 사용하는 이유는 무엇일까? VM이 컨테이너보다 못한 점들도 있기 때문인데, 몇 가지를 들자면 다음과 같다.
* VM은 시동하는 데 컨테이너보다 시간이 훨씬 많이 걸린다. 컨테이너를 실행하는 것은 그냥 새 리눅스 프로세스를 띄우는 것일 뿐이지만, VM을 실행하려면 모든 부팅 및 초기화 과정을 거쳐야 한다.
* 컨테이너는 "한 번 구축하고 모든 곳에서 실행"하는 편리한 능력을 빠르고도 효율적으로 개발자에게 부여한다. VM을 위한 완전한 컴퓨터 이미지를 구축해서 다른 곳에서 실행하는 것이 가능하긴 하지만 너무 느리고 번거롭기 때문에, 그런 기능은 개발자들이 컨테이너를 활용하는 것만큼 자주 쓰이지는 않는다.
* 각 VM은 커널 전체를 실행해야 하므로 추가 부담(overhead)이 존재한다. 반면 컨테이너들은 하나의 커널을 공유하기 때문에 자원 면에서나 성능 면에서 대단히 효율적이다.

VM에 관해서 자세히는 모르는 편이었는데 제1형과 제2형의 구분에 관해서도 알게 되었고 VM과 컨테이너의 차이 특히 그 차이가 보안과 동작에 어떻게 영향을 주는지 이해할 수 있었다.

6장 컨테이너 이미지

하나의 컨테이너 이미지는 크게 두 부분으로 구성되는데, 하나는 루트 파일 시스템이고 다른 하나는 이미지 설정 정보이다.

OCI(Open Container Initiative)는 컨테이너 이미지와 런타임에 관한 표준을 정의하기 위해 결성된 단체이다. 표준 제정 시 도커의 연구 개발 성과를 채용했기 때문에, 도커의 관행과 OCI 표준 명세에는 비슷한 점이 많다.

OCI 이미지를 편리하게 조사하고 조작할 수 있는 도구로 Skopeo가 있다. 특히, Skopeo는 다음처럼 도커 이미지를 OCI 형식 이미지로 변환하는 기능을 제공한다.(skopeo copy docker://alpine:latest oci:alpine:latest)
그런데 runc 같은 OCI 준수(OCI-compliant) 런타임이 이 형식의 이미지를 직접 실행하지는 않는다. 그런 런타임은 먼저 이미지를 풀어서(unpack) 하나의 런타임 파일 시스템 번들을 만든다. 그럼 umoci라는 도구를 이용해서 알파인 컨테이너 이미지를 풀어 보자.

명령줄에서 실행한 docker 명령 자체는 그리 많은 일을 하지 않는다. 이 명령은 주어진 매개 변수들을 해석해서 적절한 API 요청을 도커 데몬에 보내는데, 데몬과의 상호작용은 모두 도커 소켓이라고 부르는 소켓을 거친다. 이 도커 소켓에 접근할 수 있는 모든 프로세스는 데몬에게 API 요청을 보낼 수 있다.

도커 접근 방식에서 그 컴퓨터는 도커 데몬을 루트로 실행해야 한다. 그런데 루트 계정에는 이미지를 구축하고 레지스트리와 상호작용하는 데 필요한 것보다 훨씬 많은 능력이 있다.

도커 데몬이 루트로 실행되고 이 부분 때문에 보안에 허점이 생기는 것은 이 책 전반에 걸쳐서 계속 나온다. 3년 전 책이라 아주 오래되진 않았지만 그사이에 CNCF는 더 커졌고 Kubernetes에서 Docker도 제거되었기 때문에 따로 공부를 더 해봐야 할 것 같다. 계속 Docker를 쓰고 있고 OCI와 잘 호환되긴 하면서도 둘의 정확한 차이를 자세히 보진 않아서 깊게 들어갔을 때는 종종 헷갈리곤 한다.

그런 도구 중 하나는 Moby 프로젝트의 BuildKit이다. 이 도구는 루트없는 모드도 지원한다. (참고로 도커는 회사 이름과 프로젝트 이름이 같아서 생기는 혼란을 피하려고 자신의 오픈소스 프로젝트의 이름을 'Moby'로 바꾸었다.) BuildKit은 앞에서 언급한 도커의 실험적 루트 없는 구축 모드의 기반이다.
또한 레드햇의 podmanbuildah도 루트 권한을 요구하지 않는 이미지 구축 도구이다. 푸자 아바시(Puja Abbassi)의 블로그 글은 이 도구들을 설명하고 docker build와 비교한다.
구글의 Bazel은 컨테이너 이미지뿐만 아니라 다른 종류의 결과물도 구축할 수 있다. 이 도구는 도커 데몬이 없어도 된다는 장점 외에 이미지 구축이 결정론적이라는 장점도 가지고 있다. 후자는 같은 원본을 이용해서 이미지를 구축하면 항상 같은 이미지가 나온다는 뜻이다.
구글은 또한 Kaniko라는 도구도 만들었다. 이 도구는 쿠버네티스 클러스터 안에서 도커 데몬 없이 이미지를 구축한다.

이 명령들은 도커 문서화에 잘 나와 있지만, 이미지에서 Dockerfile을 다시 생성하는 데 관한 필자의 블로그 글도 도움이 될 것이다.

데몬 없이 빌드하는 방식도 좀 더 알아봐야겠다는 생각이 들었다.

이미지에 패스워드나 토큰 같은 민감한 정보를 포함하는 것은 보안의 관점에서 바람직하지 않으므로 피해야 한다.

이미지에 접근할 수 있는 사용자가 보아서는 안 되는 정보는 그 어떤 계층에도 포함하지 말아야 한다.

참고로 blob는 Binary Large Object(이진 대형 객체)를 뜻한다. Binary Large Object를 줄인 두문자어 BLOB가 보통 명사 blob로 자리 잡았다기보다는, 개발자들이 무정형 이진 데이터를 격식 없이 blob("정체를 알기 어려운 흐릿한 덩어리"라는 뜻을 가진 영어 단어)라고 부르던 관행에서 역으로 Binary Large Object라는 해석이 제시되었다고 보는 게 더 정확하다.

Blob이라는 단어의 기원은 이 책에서 처음 알게 되었다.

도커의 경우 지역 컴퓨터에 있는 이미지들의 다이제스트를 다음 명령으로 즉시 조회할 수 있다.(docker image ls --digests)

악의적으로 수정된 Dockerfile을 무심코 사용한다면 구축 과정에서 다음과 같은 피해가 발생할 수 있다.
* 악성 코드나 암호화폐 채굴 소프트웨어가 이미지에 추가된다.
* 구축 과정에 쓰이는 비밀 정보가 누출된다.
* 구축 시스템이 접근할 수 있는 네트워크 구조가 노출된다.
* 구축 시스템 자체가 침해된다.

DockerfileUSER 명령은 이후 구축 명령들을 실행할 사용자 계정을 지정한다. 꼭 루트 권한이 필요하지 않은 구축 명령이라면 USER를 이용해서 적절한 비루트 사용자를 지정하는 것이 바람직하다.

DockerfileRUN 명령으로 컨테이너 프로세스에서 그 어떤 명령이라도 실행할 수 있다는 점을 반드시 명심해야 한다.

Dockerfile의 위변조에 대해서는 크게 고민해 본 적이 없는데 이번 장에서 생각해 보게 되었다. 범용 CI라면 더 민감하겠지만 사내 CI에서는 어느 정도 사용하는 Dockerfile을 신뢰하고 있다고 생각할 수 있는데 시스템 관점에서는 이 부분도 보안에 신경 써야 할 부분이라는 것을 알게 되었다.

7장 컨테이너 이미지의 소프트웨어 취약점

컨테이너 이미지가 기반하는 배포판의 보안 권고 피드를 고려하지 않고 원본 NVD 자료에만 의존하는 스캐너는 해당 이미지에 대해 거짓 양성(false positive; 가양성) 결과, 즉 실제로는 취약점이 없는데 있다고 잘못 판단한 결과를 많이 보고할 가능성이 크다.

컨테이너의 취약점 보고에만 국한되는 것은 아니지만 소프트웨어에서 취약점 검사는 쉽지 않은 일이라고 생각한다. 쉽게 취약점 검사를 해야 한다고 생각할수 있지만 위처럼 거짓 양성 보고도 많은 데다가 거짓 양성인지 아닌지 확인하는 데도 꽤 큰 노력이 들고 거짓 양성이 아니어도 고치기 쉽지 않은 경우가 있다. 그래서 결국 알림이 방치된 상태로 있어서 노이즈만 주고 실질적인 효과를 주지 못한다는 게 개인적인 생각이다. 고칠 수 있게 하기까지 좀 더 고민이 필요하다고 생각하는데 아직 적절한 시스템은 찾지 못했다. GitHub에서도 꽤 노이즈 하다고 생각하고 있다.

8장 컨테이너 격리의 강화

이 책을 쓰는 현재 쿠버네티스의 seccomp 지원은 알파 버전의 기능이며, 프로파일을 적용하려면 PodSecurityPolicy 객체에 적절한 주해
(annotation)를 직접 추가해야 한다.

이런 것도 있다고 생각했지만 위 문서를 보면 PodSecurityPolicy는 Kubernetes 1.21에서 deprecated되고 1.25에서 삭제되었다. Pod Security Admission을 쓰거나 서드파티 어드미션 플러그인을 쓰라고 한다.

컨테이너를 docker run --security-opt="apparmor:<프로파일 이름>" ...로 실행하면 컨테이너는 지정된 프로파일이 허용하는 행동만 할 수 있다. Containerd와 CRI-O도 App Armor를 지원한다.
도커는 기본 AppArmor 프로파일을 제공하지만, seccomp의 경우와 마찬가지로 쿠버네티스는 기본 AppArmor 프로파일을 자동으로 적용하지 않음을 주의하기 바란다. 쿠버네티스 파드의 컨테이너에 AppArmor 프로파일을 적용하려면 적절한 주해를 추가해야 한다.

컨테이너가 할 수 있는 일을 제어하는 건 중요하긴 하지만 아직 어떻게 적용해야 할지는 감이 잘 오진 않았다. 일단 이런 방법도 있다는 점을 알게 되었다.

9장 컨테이너 격리 깨기

컨테이너 이미지에 비루트 사용자가 지정되어 있거나 컨테이너 실행 시 비루트 사용자를 명시적으로 지정하지 않은 한, 기본적으로 컨테이너는 루트로 실행된다. 중요한 것은, 그 루트 계정이 컨테이너 안에서만 루트가 아니라 호스트 자체의 루트라는 점이다.

비루트 사용자가 실행해도 도커는 컨테이너를 루트로 실행한다는 것은 일종의 권한 확대(특권 상승)에 해당한다. 컨테이너가 루트로 실행된다는 것 자체가 반드시 문제점은 아니지만, 보안을 생각하면 뭔가 불안하다. 공격자가 루트로 실행 중인 컨테이너에서 탈출할 수만 있으면 공격자는 호스트의 루트 사용자로서 호스트의 모든 것에 접근할 수 있다.

컨테이너 실행 시 특정 사용자 ID를 명시적으로 지정할 수 있다.

응용 프로그램에 따라서는 컨테이너를 읽기 전용으로 실행할 필요가 있다(--read-only 옵션으로 docker run을 실행하거나 쿠버네티스 PodSecurityPolicyReadOnlyRootFileSystemtrue로 설정해서). 컨테이너를 읽기 전용으로 만들면 공격자가 코드를 설치하기가 어려워진다.

컨테이너를 읽기 전용으로 만든다면 보안상으로는 좋겠지만 현실성은 없게 느껴졌다. 컨테이너는 불변으로 다루는 게 당연하고 좋지만, 트러블슈팅이나 테스트의 목적으로 컨테이너 안에 들어가 보는 경우가 꽤 자주 있고 필요한 도구를 임시로 설치해서 사용한 뒤에 컨테이너를 재시작해서 초기화하는 경우도 많기 때문이다. 보안은 좋아지겠지만 너무 불편해질 거로 생각되었다.

몇 년 전부터 Rootless Containers 프로젝트는 비루트 사용자도 컨테이너를 실행할 수 있도록 커널을 수정하는 작업을 진행했다.

루트 없는 컨테이너는 사용자 이름공간 기능을 이용해서, 호스트의 비루트 사용자 ID를 컨테이너 안의 루트 계정에 대응시킨다. 이렇게 하면 공격자가 컨테이너에서 탈출한다고 해도 자동으로 루트 권한을 가지지는 못한다. 따라서 보안이 크게 개선된다.

이 책을 쓰는 현재 루트 없는 컨테이너는 아직 미성숙 단계이다. runcpodman은 루트 없는 컨테이너를 지원하지만, 도커는 아직 실험적으로만 지원한다. 그리고 어떤 런타임을 사용하든 쿠버네티스에서는 루트 없는 컨테이너를 사용할 수 없다.

잘 몰랐던 부분이다. Docker 안에서 루트로 쓰이고 사용자 지정을 할 때도 있고 안 할 때도 있었는데 보안 관점에서는 꽤 중요한 부분이었다. 따로 공부는 못해봐서 사용자 ID를 대응시키면 실제 동작에 어떤 영향이 있는지 몰라서 쉽게 적용하진 못할 것 같다. 점점 Docker 중심보다는 CNCF 중심으로 달라지고 있어서 이 부분도 찾아봐야 할 것 같다.

--privileged를 그냥 컨테이너가 루트로 실행되게 하는 플래그라고 생각하는 경우가 많은데, 앞에서 보았듯이 어차피 컨테이너는 기본적으로 루트로 실행된다. 이 플래그는 루트로 실행되는 컨테이너에 더 많은 특권을 부여하는 효과를 낸다.

도커가 --privileged 플래그를 도입한 것은 "도커 안의 도커(Docker in Docker)"를 가능하게 하기 위한 것이었다. 이 도커 안의 도커 기능은 컨테이너 안에서 실행되면서 도커 데몬에 접근해서 컨테이너 이미지를 생성해야 하는 이미지 구축 도구와 CI/CD 시스템들에 흔히 쓰인다. 그러나 제롬 페타초니(Jérôme Petazzoni)의 블로그 글이 설명하듯이, 도커 안의 도커 기능은(그리고 일반적으로 --privileged 플래그는) 조심해서 사용해야 한다.

컨테이너에 필요한 능력들을 파악했다면, 최소 권한 원리에 따라 그 능력들만 명시적으로 컨테이너에 배정하는 것이 바람직하다. 이때 권장되는 접근 방식은 다음과 같이 먼저 --cap-drop=all 플래그로 모든 능력을 제거한 후 필요한 것들을 다시 추가하는 것이다.(docker run --cap-drop=all --cap-add=<능력1> --cap-add=<능력2> <이미지> ...)

도커 환경에서는 도커 데몬 프로세스 하나가 도커와 관련된 모든 일을 처리한다. 명령줄에서 docker를 실행하면 docker/var/run/docker.sock에 있는 도커 소켓을 통해서 도커 데몬에게 명령을 전달한다. 이 소켓에 데이터를 보낼 수 있는 개체는 그 도커 데몬에게도 명령을 보낼 수 있다. 도커 데몬은 루트로 실행되며, 사용자가 요청한 소프트웨어를 불평 없이 구축하고 실행한다.
도커 소켓에 접근하는 것은 사실상 호스트에 대한 루트 권한을 획득하는 것이다.

실무 환경 클러스터에서 도커 소켓을 마운팅하는 CI/CD 파이프라인을 실행하는 것은 극도로 나쁜 관행이다.

CI/CD를 만들고 있어서 약간 뜨끔했던 부분이다. 점차 고민을 해봐야겠다.

10장 컨테이너 네트워크 보안

컨테이너 방화벽은 컨테이너들 사이를 오가는 트래픽을 제한한다. 쿠버네티스 같은 오케스트레이터에서는 '컨테이너 방화벽(container firewall)'이라는 용어를 사용하지 않는다. 대신 네트워크 플러그인을 이용해서 "네트워크 정책(network policy)을 강제한다"는 표현을 사용한다. 두 경우 모두 핵심은 승인된 개체들 사이에서만 정보가 오가도록 컨테이너 네트워크 트래픽을 제한하는 것이다.

사용자 공간에서 넷필터의 규칙들을 관리하는 수단은 여러 가지인데, 가장 흔히 쓰이는 두 가지는 iptables와 IPVS이다.

쿠버네티스의 네트워크 정책은 파드들이 주고받을 수 있는 트래픽을 정의한다. 정책은 포트 수준에서 정의할 수도 있고 IP 주소나 서비스, 파드 수준에서 정의할 수도 있다. 한 메시지를 보내거나 받을 때, 만일 정책이 해당 메시지를 승인하지 않으면 네트워크 스택이 연결 자체를 거부할 수도 있고, 또는 해당 메시지를 폐기할 수도 있다.

네트워크 보안도 적용해 보고 싶은 아이디어로 작년부터 생각하고 있는 부분이라 자세한 설명이 반가웠다.

11장 TLS를 이용한 구성 요소 간 보안 연결

이름을 TLS로 바꾼 지 20년이 넘었지만 아직도 'SSL 인증서' 같은 용어가 쓰이고 있다. 인증서를 아주 정확하게 지칭해야 하는 상황에서는 'X.509 인증서'라는 용어를 사용해야 함을 기억해 두기 바란다.

무의식적으로 나도 SSL이란 용어를 쓰고 있어서 기억해 둘 겸 적어뒀다.

12장 비밀 정보를 컨테이너에 전달

환경 변수로 비밀 값을 전달하는 것인데, 이 역시 그리 바람직하지 않다. 이유는 다음 두 가지이다.
* 여러 언어와 프레임워크에서, 응용 프로그램이 충돌하면(crash) 디버그 정보가 덤프된다. 그런데 그런 덤프 데이터에 모든 환경 변수가 포함될 가능성이 크다. 만일 덤프가 로깅 시스템으로 넘어가면, 로그에 접근할 수 있는 모든 사람이 환경 변수에 담긴 비밀 값을 보게 된다.
* 컨테이너에서 docker inspect(또는 이와 동일한 일을 하는 어떤 명령)를 실행하면 컨테이너에 설정된(구축 시점과 실행 시점 모두) 모든 환경 변수가 나온다. 관리자는 컨테이너의 설정을 살펴보기 위해 이런 명령을 흔히 실행하므로, 비밀 값을 알 필요가 없는 관리자가 의도하지 않게 비밀 값을 알게 되는 상황이 벌어질 수 있다.

환경 변수를 통한 비밀 값 전달의 위험을 완화하는 방법 두 가지이다(여러분의 위험 프로파일에 따라서는 도움이 되지 않을 수도 있다).
* 출력 로그를 후처리해서 비밀 값을 제거하거나 난독화한다.
* 보안 저장소(Hashicorp Vault나 CyberArk Conjur 또는 클라우드 공급 업체의 비밀/키 관리 시스템)에서 비밀 값을 조회하도록 응용 프로그램 컨테이너를 수정한다(또는 그런 목적의 사이드카 컨테이너를 부착한다). 이를 자동으로 처리해 주는 상용 보안 솔루션들도 있다.

비밀 값을 전달하는 가장 바람직한 방법은 컨테이너가 볼륨 마운트를 통해 접근할 수 있는 파일에 비밀 값을 기록하는 것이다. 이상적으로, 그 볼륨은 디스크에 실제로 존재하는 디렉터리가 아니라 메모리에만 존재하는 임시 디렉터리여야 한다. 이를 보안 비밀 값 저장소와 결합하면, 평문(해독된 버전) 비밀 값이 저장소에 '저장 중(at rest)' 상태로 남는 일은 결코 생기지 않는다.
이 방법은 호스트의 파일을 컨테이너에 마운트하므로, 컨테이너를 재시동하지 않고 호스트 쪽에서 언제라도 비밀 값을 갱신할 수 있다. 기존 비밀 값이 제대로 작동하지 않을 때 새 비밀 값을 파일에서 다시 읽어 오는 기능이 응용 프로그램에 있다면, 비밀 값 순환 시 컨테이너들을 재시동하지 않아도 새 비밀 값들이 적용된다.

시크릿 관리는 많이 고민했지만, 여전히 어렵긴 한 부분이다. 여기서도 나왔듯이 가장 좋은 방법은 메모리에만 존재하도록 해야 한다는 건 이해하고 있는데 대부분은 환경변수를 쓰고 있을 거로 생각한다 환경변수는 모두가 익숙한 방식이지만 메모리에 넣는다면 모든 애플리케이션에서 읽는 방법도 바꾸어야 해서 현실적으로는 쉽지 않은 문제라고 생각한다. 개인적으로는 컨테이너에 준 시크릿을 동적으로 갱신해서 다시 읽어오게 하는지 좋은지도 잘 모르겠다.(난 이렇게 안 하는 게 낫다고 생각하는 편)

쿠버네티스는 이번 장 도입부에서 설명한 비밀 값의 바람직한 속성들을 자동으로 지원한다.
* 쿠버네티스의 비밀 값은 독립적인 자원의 형태로 생성되므로, 비밀 값을 사용하는 응용 프로그램 코드의 수명 주기에 묶이지 않는다.
* 비밀 값을 암호화해서 저장하는 옵션이 있긴 하지만, 그것이 기본 설정은 아니다(적어도 이 책을 쓰는 현재). 명시적으로 활성화해야 한다.
* 비밀 값은 암호화된 형태로 구성 요소 사이에서 전송된다. 따라서 쿠버네티스 구성 요소들 사이의 연결이 반드시 보안 연결이어야 한다. 다행히 대부분의 배포판은 기본적으로 보안 연결을 사용한다.
* 쿠버네티스는 환경 변수를 통한 비밀 값 전달과 볼륨 마운트를 통한 비밀 값 전달도 지원한다. 후자의 경우 비밀 값은 메모리 안에만 존재하는 임시 파일 시스템의 파일에 저장되며, 실제 디스크에는 절대로 기록되지 않는다.
* 비밀 값을 사용자가 설정할 수 있게 하되 일단 설정한 후에는 쓰기 전용으로만 접근하도록 쿠버네티스 RBAC(role-based access control; 역할 기반 접근 제어)를 설정할 수 있다.

쿠버네티스에서 비밀 값들은 기본적으로 etcd 데이터 저장소에 base64로 부호화되어서 저장된다. 비밀 값이 자동으로 암호화되지는 않음을 주의해야 한다. 자동으로 암호화되어서 저장되도록 etcd를 설정하는 것이 가능하지만, 그런 경우 해독 키가 호스트에 저장되지 않게 하는 데 신경을 써야 한다.
필자의 경험으로 볼 때 대부분의 기업은 서드파티 상용 솔루션을 이용해서 비밀 값을 저장한다.

Kubernetes에서 Secrets의 암호화 관련해서(base64 말고) 몰랐던 부분인데 이 부분을 따로 공부해 봐야겠다.

13장 실행 시점 컨테이너 보호

컨테이너 안에서 또 다른 실행 파일이 실행되는 것을 어떻게 탐지해야 할까? 한 가지 방법은 eBPF를 사용하는 것이다.

Falco는 eBPF를 이용해서 컨테이너의 행동을 관측하고 뭔가 비정상적인 행동(이를테면 이전 예제에서 본 예기치 않은 실행 파일 실행 등)이 탐지되면 경고를 발동한다. 이런 접근 방식은 비정상 행동을 검출하는 데에는 아주 효과적이지만, 그런 행동을 억제하는 데에는 한계가 있다. 왜냐하면 eBPF로는 시스템 호출을 탐지할 수만 있을 뿐 수정할 수는 없기 때문이다. 따라서, 이 접근 방식이 잠재적 공격을 관측하고 경고하는 데에는 효율적이고 강력하지만, 그런 행동을 실제로 차단하려면 또 다른 메커니즘이 필요하다.

실행 시점 컨테이너 보호를 위해 어떤 도구를 사용하든, 반드시 결정해야 할 사항이 하나 있다. 바로, 비정상 행동을 감지했을 때 도구가 어떤 작업을 수행하게 할 것인가이다. 애초에 그런 비정상 행동을 도구가 미리 차단(예방)해 주는 것이 이상적이다.

일반적으로 그런 도구들은 비정상 행동이 검출되었을 때 그 행동을 차단하는 대신 경고만 발동하는 모드도 제공한다. 그런 모드는 실행 시점 프로파일들이 제대로 설정되었는지 확인하는 시험 단계에서 유용할 것이다.

아마 대부분은 시험 단계로 적용해서 경고받으면서 상황을 보고 이후에 막는 조치로 넘어가는 단계를 거칠 거라고 생각된다.

실행 시점에서 코드 주입을 검출하고 방지하는 것을 표류 방지(drift prevention; 또는 이탈 방지)라고 부른다. 제대로 된 실행 시점 솔루션이라면 반드시 표류 방지 기능을 제공해야 한다. 표류 방지는 스캐닝 측면과 컨테이너 실행 측면의 조합으로 이루어진다.

2023/01/23 21:20 2023/01/23 21:20