Skip to Content

1장. 뿌리를 바꾸다 — chroot는 감옥이었던 적이 없다

루트에 디렉터리 두 개뿐인 프로세스가 바깥에서 도는 프로세스를 죽였습니다. chroot가 하는 일은 1979년부터 딱 하나, 경로 이름의 기준점을 바꾸는 것뿐이고 나머지는 전부 그대로이기 때문입니다.

{/* 삽화 자리: 커다란 저택의 방 하나. 그 안에 있는 사람은 이 방이 집 전체라고 믿고 편안히 지낸다. 방문에는 자물쇠가 없고 살짝 열려 있으며, 문틈으로 복도와 다른 방들이 이어져 보인다. 사람은 문 쪽을 보고 있지 않다. 프롬프트: docs/book/illustration-prompts-container.md */}


배경

컨테이너 이야기는 보통 2013년 Docker에서 시작합니다. 그런데 Docker는 격리를 발명하지 않았어요. 그럼 무엇을 했는지는 4장 몫이고, 여기서는 Docker가 가져다 쓴 격리 중 가장 오래된 조각부터 봅니다. chroot입니다.

chroot 시스템 콜은 1979년 Version 7 Unix를 개발하던 중에 들어갔습니다. 하는 일은 이름 그대로예요. change root, 프로세스가 /라고 부르는 자리를 다른 디렉터리로 바꿉니다.

그럼 1979년엔 왜 만들었을까요. 정확한 기록이 없습니다.

남아 있는 가장 가까운 증언은 커크 맥커식이 사담으로 남긴 한 문단인데, 그것도 1979년 원본이 아니라 3년 뒤 BSD판 얘기입니다. 그는 SCCS 로그를 뒤져 “1982년 3월 18일 빌 조이가 chroot 호출을 추가했다”고 확인하고, 목적에 대해서는 이렇게 씁니다. “내 짐작으로는” 빌이 /4.2BSD 빌드 디렉터리로 chroot해 들어가서 그 트리 안의 파일만으로 시스템을 빌드하려던 것 같다고요. 그리고 한마디 덧붙입니다. 그 시절 chroot의 용도는 그것뿐이었던 걸로 기억한다고(📄 문서 기반 — Kamp & Watson, 「Jails: Confining the omnipotent root」 §9 ).

목적을 말해주는 자료가 18년 뒤 한 사람의 짐작뿐이라는 게 이상하게 들릴 수 있습니다. 그래도 확실한 게 하나 있어요. 누구를 가두려고 만들었다는 기록은 어디에도 없습니다.

시험용이든 빌드용이든, chroot는 시험 대상이 자기를 공격한다고 가정하지 않은 도구였습니다. 내 빌드 스크립트가 탈출하려고 애쓸 리는 없으니까요. 이 장에서 보게 될 chroot의 한계가 전부 그 가정에서 나옵니다.

스토리

그런데 사람들은 그 도구를 다른 데 쓰기 시작했습니다. 네트워크 서비스를 chroot 안에서 돌리는 관행이 퍼졌어요(📄 문서 기반). FTP 서버가 뚫려도 서버 프로세스가 볼 수 있는 게 /var/ftp 아래뿐이면 피해가 거기서 멈추지 않겠냐는 발상이었습니다. 맥커식이 같은 글에서 짚듯 chroot는 FTP 서버보다도 먼저 나왔습니다. 만든 쪽이 그려둔 용도가 아니었다는 뜻이에요.

jail이라는 말이 chroot에 들러붙은 가장 이른 사례는 1991년입니다.

AT&T 벨 연구소의 빌 체스윅은 연구소 게이트웨이를 노리는 침입자를 발견했습니다. 보통은 구멍을 막고 끝냅니다. 체스윅은 가짜 패스워드 파일을 내주고, 침입자가 들어올 환경을 따로 만들었어요. 명령어 몇 개만 놓인 chroot 환경, 안에서 보면 진짜 시스템 같지만 하는 일은 전부 기록되는 방이었죠. 그는 이 방을 Jail이라고 불렀습니다.

왜 쫓아내지 않고 몇 달을 지켜봤는지는 본인이 논문에 적어뒀습니다. 침입자의 키 입력을 보고, 추적하고, 수법을 배우고, 그가 건드린 다른 피해자들에게 알려주려고요. 침입자를 내보내는 것보다 관찰하는 쪽이 정보를 더 준다는 발상, 허니팟의 초기 사례입니다.

그런데 같은 논문에 이런 문장도 있습니다. 우리가 보기에 그 Jail은 그리 그럴듯하지 않았는데, 베르퍼드는 게이트웨이가 원래 좀 이상한가 보다 하고 넘어가는 것 같더라고요.

만든 사람은 그 방이 들킬 수 있다는 걸 알고 있었습니다. 안에 있는 사람만 눈치채지 못했고요(📄 문서 기반 — An Evening with Berferd ).

이름이 문제였습니다. 감옥이라고 부르기 시작하자 다들 감옥처럼 믿기 시작했거든요.

핵심

직접 만들어 보면 chroot가 무엇이고 무엇이 아닌지가 한 번에 보입니다.

가짜 루트 만들기

chroot는 루트만 부를 수 있는 시스템 콜입니다. 리눅스에서는 CAP_SYS_CHROOT 권한이 필요해요. 아래는 전부 root로 실행합니다.

빈 디렉터리 하나에 busybox 실행 파일만 넣고, 그 디렉터리를 루트로 삼아 셸을 띄워보겠습니다.

mkdir -p /newroot/bin cp /bin/busybox /newroot/bin/ cd /newroot/bin && for c in sh ls echo cat; do ln -sf busybox $c; done chroot /newroot /bin/sh -c "ls /"

첫 시도는 이렇게 끝납니다.

chroot: can't execute '/bin/sh': No such file or directory

파일은 분명히 거기 있습니다. 방금 넣었으니까요.

busybox 같은 동적 링크 실행 파일은 자기 안에 libc를 갖고 있지 않습니다. 대신 ELF 헤더에 “실행하기 전에 이 로더를 먼저 띄워라”라고 경로가 적혀 있어요. 커널이 그 로더를 못 찾으면 실행이 실패합니다. 그런데 커널이 돌려주는 오류 코드가 “그런 파일 없음”이라서, 메시지는 정작 멀쩡히 있는 sh를 가리킵니다. 없는 건 sh가 아니라 sh가 쓸 로더예요.

무엇이 필요한지는 ldd로 봅니다.

$ ldd /bin/busybox /lib/ld-musl-aarch64.so.1 (0xffffafd10000) libc.musl-aarch64.so.1 => /lib/ld-musl-aarch64.so.1 (0xffffafd10000)

로더까지 복사하고 다시 하면 그제야 돕니다.

$ chroot /newroot /bin/sh -c "ls /" bin lib

같은 순간 바깥에서 본 /는 이렇습니다.

bin dev etc home lib media mnt newroot opt proc root run sbin srv sys tmp usr var

✅ 실측 (Docker Engine 29.7.2 / alpine 3.20 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch01/fake-root.sh)

같은 기계, 같은 커널, 같은 시각에 /가 두 가지로 보입니다. 안쪽 프로세스에게 /etc는 부를 이름 자체가 없는 경로예요.

첫 시도가 실패한 것도 그냥 사고가 아닙니다. chroot 안은 정말로 비어 있습니다. 실행 파일이 의존하는 로더, 설정 파일, /dev/null까지 넣어주지 않으면 아무것도 안 돌아요. 컨테이너 이미지가 앱 하나 넣는데도 수백 MB가 되는 이유가 여기서 시작합니다. 그 이미지가 어떤 구조로 쌓여 있는지는 8장에서, 그래서 어디를 잘라야 줄어드는지는 11장에서 봅니다.

chroot가 안 바꾸는 것

이제 반대쪽을 봅시다. 바깥에서 sleep 600을 하나 띄워두고, chroot 안에서 그게 보이는지 확인합니다.

chroot 바깥chroot 안
호스트명53b2bd1240da (컨테이너 ID)53b2bd1240da
루트에 있는 것18개 (bin dev etc home …)4개 (bin lib probe.sh proc)
/proc에 보이는 프로세스5개6개
바깥 sleep(PID 22)의 정체sleepsleep

✅ 실측 (같은 환경. 전체 소스: docs/book/code/container/ch01/what-chroot-misses.sh)

파일 시스템만 갈렸습니다. 호스트명은 같고, 프로세스 목록도 같아요. 안쪽에서 /proc을 마운트하면 바깥 프로세스가 전부 보입니다. 안쪽이 하나 더 많은 건 안에서 도는 프로세스 자신이 늘었기 때문이고요.

이 실험은 alpine 컨테이너 안에서 돌렸습니다. 그래서 “바깥”이라고 해도 프로세스가 다섯 개뿐이에요. 리눅스 서버에서 직접 하면 수백 개가 보이고, 결론은 같습니다. 그 수백 개가 실제로 몇 개인지는 3장에서 확인하고, 컨테이너 안이 왜 이렇게 한산한지는 6장에서 다룹니다.

보이기만 하는 게 아닙니다.

바깥 PID 22에 kill: 보냈다 === kill 뒤, 바깥에서 다시 확인 === PID 22 : 죽었음 — chroot 안에서 바깥 프로세스를 죽였다

그런데 여기서 의심이 하나 생깁니다. /proc을 마운트했으니까 된 거 아닌가. 특권 모드로 띄운 컨테이너라서 된 건 아닌가.

아니었습니다. /proc도 안 붙이고, 특권 모드도 아니고, 루트에 bin과 lib 둘만 있는 상태로 다시 해봤습니다.

바깥 sleep PID=14 chroot 안 루트: bin lib kill 보냈다 결과: 죽었음

✅ 실측 (같은 환경. 전체 소스: docs/book/code/container/ch01/minimal-kill.sh)

/proc은 목록을 보여주려고 붙인 거였고, kill에는 필요 없습니다. PID 숫자 하나만 알면 돼요.

루트에 디렉터리 두 개뿐인 프로세스가 바깥 프로세스를 죽였습니다. 경로 이름만 갈렸을 뿐, 시그널을 보낼 권한은 한 톨도 안 줄었으니까요.

여는 글의 문장이 여기서 처음 증명됩니다. 선은 벽이 아닙니다.

왜 이렇게 되는가

커널 문서가 이 장 전체를 한 문장으로 요약해둡니다. 이 호출은 경로 이름 해석 과정의 한 요소를 바꿀 뿐 그 외에는 아무것도 하지 않는다고요. 그리고 곧바로 못 박습니다. 어떤 종류의 보안 목적으로도 쓰라고 만든 게 아니며, 프로세스를 온전히 가두지도, 파일 시스템 관련 시스템 콜을 제한하지도 않는다고요(📄 문서 기반 — man 2 chroot).

chroot가 보안 경계가 못 되는 이유는 두 층입니다. 섞어 읽으면 엉뚱한 결론에 닿아요.

첫째, 사용자가 누구든. chroot는 경로만 가립니다. 프로세스 목록, 시그널을 보낼 권한, 네트워크, 호스트명, 시계는 하나도 안 건드려요. 유닉스에서 프로세스가 하는 일 중 파일 경로를 거치는 건 일부입니다. chroot는 딱 그 일부만 가렸어요. 방금 kill로 본 그대로고, 여기엔 루트 권한도 필요 없습니다.

권한이 없어도 새는 길이 또 있습니다. chroot는 이미 열려 있는 파일 디스크립터를 닫지 않아요. 그래서 chroot를 걸기 전에 열어둔 디스크립터는 그 뒤로도 바깥 파일을 계속 가리킵니다. 같은 문서가 /proc/pid/root 심볼릭 링크로 프로세스의 루트 디렉터리를 알아낼 수 있다는 것도 적어두고, 디렉터리가 chroot 바깥으로 옮겨지면 공격자가 그걸 이용해 빠져나갈 수 있다고 경고합니다. 셋 다 루트 권한이 필요 없습니다.

둘째, 루트라면. 그 경로 가림마저 빠져나갑니다. chroot는 현재 작업 디렉터리를 안 바꿔요. 그래서 chroot를 한 번 더 걸고 나면 .이 새 루트 바깥을 가리키는 상태가 만들어집니다. 커널 문서가 루트 사용자는 이 방법으로 chroot를 걸어 나갈 수 있다고 직접 적어뒀습니다. chroot가 서로 겹쳐 쌓이질 못해서 생기는 구멍이고, 장치 파일을 만들거나 파일 시스템을 마운트하는 길도 따로 있습니다.

마지막 항목은 이 장이 이미 시연했습니다. 조금 전 chroot 안에서 /proc을 마운트했잖아요. 그게 문 하나를 여는 행위였습니다.

전부가 손 놓은 건 아닙니다. NetBSD는 chroot를 보안 기제로 취급하고 알려진 탈출이 없으며, FreeBSD는 중첩 chroot를 막는 장치를 따로 넣었습니다. 이 “막아보려는 쪽”에서 다음 장의 이야기가 나옵니다.

여기서 짚어둘 게 있습니다. 이건 고쳐 쓸 수 있는 버그가 아닙니다. chroot는 설계된 대로 정확히 동작하고 있어요. 1979년에 주어진 일은 “경로 기준점을 바꿔라”였고 그 일은 완벽하게 합니다. 사람들이 그 도구에 감옥이라는 이름을 붙였을 뿐입니다.

정리

  • chroot는 1979년 Version 7 Unix에서 나왔습니다. 왜 만들었는지는 기록이 없어요. 가장 가까운 증언은 1982년 BSD판에 대한 맥커식의 짐작뿐이고, 격리가 목적이었다는 기록은 없습니다.
  • 하는 일은 하나입니다. 그 프로세스가 /라고 부를 자리를 바꿉니다. 커널 문서의 표현으로는 경로 이름 해석의 한 요소를 바꿀 뿐 그 외에는 아무것도 하지 않습니다.
  • 그래서 chroot 안은 정말 비어 있습니다. 동적 로더까지 넣어야 실행 파일이 돕니다. 컨테이너 이미지가 커지는 이유의 출발점입니다.
  • 안 바꾸는 쪽이 훨씬 많습니다. 호스트명, 프로세스 목록, 시그널, 네트워크가 전부 그대로예요. 실측에서 루트에 디렉터리 두 개뿐인 프로세스가 /proc도 특권도 없이 바깥 프로세스를 죽였습니다.
  • 1991년 빌 체스윅이 침입자 관찰용으로 쓰면서 jail이라는 이름이 붙었습니다. 정작 만든 사람은 그 방이 언제든 들통날 수 있다는 걸 알고 있었습니다.

생각해볼 질문

chroot의 문제는 파일 경로 말고는 아무것도 안 나눈다는 것이었습니다. 그럼 나머지도 나누면 되겠네요. 프로세스 목록도 나누고, 네트워크도 나누고, 루트 권한도 나누고요.

실제로 그 길을 간 사람들이 있습니다. 1999년, 어느 공유 호스팅 업체 하나가 자기네 서비스와 고객 서비스를 깨끗하게 갈라놓고 싶어 했어요. chroot로는 안 됐습니다. 방금 본 그대로, 고객이 옆 고객의 프로세스를 보고 또 건드릴 수 있었으니까요.

그래서 커널을 고쳤습니다. 그 결과가 2000년 FreeBSD 4.0에 실립니다(📄 문서 기반 — jail(2)). 앞서 본 man 2 chroot도 마지막에 이 이야기를 흘려요. FreeBSD에는 더 강한 jail() 시스템 콜이 있다고요.

다만 그 방식은 지금 우리가 아는 컨테이너와 다릅니다.

▶ 2장. 감옥과 구역 — FreeBSD jail·Solaris Zones

Last updated on