[1주차 실습] 컨테이너 격리

sh5·2024년 8월 31일
0

KANS 3기

목록 보기
1/2

KANS (Kubernetes Advanced Networking Study) 3기 스터디 기록입니다.

흔히들 도커의 동작원리를 설명할때 chroot를 사용해서 프로세스를 격리시켜 컨테이너를 만든다고 한다. (chroot는 루트 디렉토리를 변경시키는 명령어) 그러나 이는 잘못되었다. chroot의 보안적 문제 때문에 pivot_root라는게 사용되고 있기 때문이다. chroot를 쓰면 프로세스를 격리시켜도 다시 호스트 디렉토리로 이동할 수 있기 때문이다.

도커 없이 컨테이너를 다양한 방법으로 만들어 보고 다음의 파일을 실행하여 탈옥(호스트 디렉토리로 이동)이 되는지 확인해 보자.

#include <sys/stat.h>
#include <unistd.h>

int main(void)
{
  mkdir(".out", 0755);
  chroot(".out");
  chdir("../../../../../");
  chroot(".");

  return execl("/bin/sh", "-i", NULL);
}

도커 없이 컨테이너 만들기 1 (chroot)

chroot를 사용해서 격리된 프로세스를 만들어 본다.

# 관리자로 전환
sudo su

# 위치 이동
cd /tmp

# 작업 폴더 생성
mkdir myroot

# chroot로 프로세스 격리
# chroot는 다음과 같이 쓸 수 있다.
# chroot {새로운루트} {새로운루트에서 실행될 명령어} 
chroot myroot /bin/sh

위에서 chroot해보면 에러가 난다. chroot를 진행하는 폴더안에 실행할 /bin/sh이 없기 때문이다. 그러므로 필요한 파일들을 옮겨 넣는다.

# /bin/sh이 실행에 필요한 파일들 조회
ubuntu@MyServer:/tmp$ ldd /bin/sh
        linux-vdso.so.1 (0x00007fffc8c73000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007175e1e00000)
        /lib64/ld-linux-x86-64.so.2 (0x00007175e20ad000)

# 파일들 복사
mkdir -p myroot/bin
cp /usr/bin/sh myroot/bin/
mkdir -p myroot/{lib64,lib/x86_64-linux-gnu}
cp /lib/x86_64-linux-gnu/libc.so.6 myroot/lib/x86_64-linux-gnu/
cp /lib64/ld-linux-x86-64.so.2 myroot/lib64

# ls명령어도 쓸것이므로 의존성파일 조회
ldd /usr/bin/ls
        linux-vdso.so.1 (0x00007ffc54938000)
        libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007a3f05e57000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007a3f05c00000)
        libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007a3f05b69000)
        /lib64/ld-linux-x86-64.so.2 (0x00007a3f05eaf000)
        
# ls와 관련된 파일들 복사
cp /usr/bin/ls myroot/bin/
mkdir -p myroot/bin
cp /lib/x86_64-linux-gnu/{libselinux.so.1,libc.so.6,libpcre2-8.so.0} myroot/lib/x86_64-linux-gnu/
cp /lib64/ld-linux-x86-64.so.2 myroot/lib64

# 최종 파일구조
tree myroot
myroot
├── bin
│   ├── ls
│   ├── mkdir
│   ├── mount
│   ├── ps
│   └── sh
├── escape_chroot
├── lib
│   └── x86_64-linux-gnu
│       ├── libblkid.so.1
│       ├── libc.so.6
│       ├── libcap.so.2
│       ├── libgcrypt.so.20
│       ├── libgpg-error.so.0
│       ├── liblzma.so.5
│       ├── libmount.so.1
│       ├── libpcre2-8.so.0
│       ├── libprocps.so.8
│       ├── libselinux.so.1
│       ├── libsystemd.so.0
│       └── libzstd.so.1
├── lib64
│   └── ld-linux-x86-64.so.2
├── proc
└── usr
    └── lib
        └── x86_64-linux-gnu
            └── liblz4.so.1

이제 테스트해볼 명령어와 파일들이 옮겨졌으므로 chroot으로 컨테이너를 생성해 보자.

# chroot로 루트 경로 변경 후 /bin/sh 실행
chroot myroot /bin/sh

# pwd로 위치 확인
pwd
/

# 루트 디렉토리 파일목록 조회
ls -al /

이제 chroot로 컨테이너 생성하기전 디렉토리내 파일과 생성된 쉘에서의 파일목록을 비교해 보자

확인해 보니 동일하다.
이제 탈옥을 시도해 보자.
일단 cd로 이동이 되는지 확인해 본다.

cd ../../../
ls -al /

cd로는 탈옥이 되지 않는다.
이번엔 맨 위에서 보여준 C스크립트로 해보자.

# 호스트에서 파일 생성
vi escape_chroot.c
---
#include <sys/stat.h>
#include <unistd.h>

int main(void)
{
  mkdir(".out", 0755);
  chroot(".out");
  chdir("../../../../../");
  chroot(".");

  return execl("/bin/sh", "-i", NULL);
}
---

# 컴파일
gcc -o myroot/escape_chroot escape_chroot.c

ll myroot
total 48
drwxr-xr-x  8 root root  4096 Sep  1 04:10 ./
drwxrwxrwt 14 root root  4096 Sep  1 07:13 ../
drwxr-xr-x  2 root root  4096 Sep  1 04:10 .out/
drwxr-xr-x  2 root root  4096 Sep  1 04:04 bin/
-rwxr-xr-x  1 root root 16096 Sep  1 04:09 escape_chroot*
drwxr-xr-x  3 root root  4096 Sep  1 04:02 lib/
drwxr-xr-x  2 root root  4096 Sep  1 04:02 lib64/
drwxr-xr-x  2 root root  4096 Sep  1 04:04 proc/
drwxr-xr-x  3 root root  4096 Sep  1 04:04 usr/

file myroot/escape_chroot
myroot/escape_chroot: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=db08d2c6b8345c74424c2e0b0cc711501ac4e440, for GNU/Linux 3.2.0, not stripped

# chroot로 컨테이너 생성
chroot myroot /bin/sh

# 탈옥시도!!
./escape_chroot

chroot 안에서와 호스트에서 확인비교 해보자
사진을 보면 파일목록이 같은걸 확인 할 수 있다.

도커 없이 컨테이너 만들기 2 (pivot_root + namespace type MNT)

chroot으로는 탈옥이 가능하여 pivot_root와 namespace MNT 개념이 추가 되었다. 이를 활영하여 컨테이너를 만들고 탈옥 테스트를 해보자

그전에 설명

pivot_root: 사진과 같이 새로운 루트 디렉토리를 바꾸고 기존 루트 디렉토리를 다른 곳에 마운트 시킨다.

namespace: OS에서 프로세스에 격리된 환경을 제공함 다음과 같은 종류가 존재
이중에 MNT(Mount)를 사용하여 pivot_root로 인해 생기는 디렉토리(기존 루트)를 마운트 해제 시킨다.

  • Mount: 마운트 되는 파일시스템의 격리
  • Cgroup: 프로세스가 proc/self/cgroup에 가상화된 새로운 cgroup 마운트를 가지게한다
  • IPC: 프로세스간 통신을 격리
  • Network: IP, Port, Routing Table 등의 네트워크 리소스 격리
  • Mount: 마운트 되는 파일시스템의 격리
  • PID: process ID를 격리시켜 다른 프로세스에 접근을 제한
  • UTS: 호스트, 도메인 명 격리
  • User: UID, GID 격리
  • Time: 시간격리
# 현재 프로세스의 네임스페이스 확인
lsns -p $$
        NS TYPE   NPROCS PID USER COMMAND
4026531834 time      110   1 root /sbin/init
4026531835 cgroup    110   1 root /sbin/init
4026531836 pid       110   1 root /sbin/init
4026531837 user      110   1 root /sbin/init
4026531838 uts       106   1 root /sbin/init
4026531839 ipc       110   1 root /sbin/init
4026531840 net       110   1 root /sbin/init
4026531841 mnt       103   1 root /sbin/init

실습 (namespace MNT)

먼저 namespace MNT로 격리된 환경을 만들어 보자.

## 사용할 명령어

mount
# mount -t [filesystem type] [device_name] [directory - mount point]
## root filesystem tree에 다른 파일시스템을 붙이는 명령
## -t : filesystem type  ex) -t tmpfs  (temporary filesystem : 임시로 메모리에 생성됨)               
## -o  : 옵션  ex) -o size=1m  (용량 지정 등 …)
## 참고) * /proc/filesystems 에서 지원하는 filesystem type 조회 가능

unshare
# unshare [options] [program] [arguments]]
## "새로운 프로세스,네임스페이스를 만들고 나서 프로그램을 실행" 하는 명령어입니다

터미널 2개를 띄우고 비교해 보면서 진행한다.
지금 두 프로세스는 동일한 정보를 가지고 있다

##### 터미널 1 #####
unshare --mount /bin/sh
df -h

##### 터미널 2 #####
df -h

unshare가 실행된 터미널1 에서 mount를 해본다. 그리고 호스트인 터미널2과 df -h를 비교해 보면 터미널1에서 마운트 된 none 파일시스템이 터미널2에선 보이지 않는다.

##### 터미널 1 #####
mount -t tmpfs none /tmp/new_root
df -h

##### 터미널 2 #####
df -h

이제 /tmp/new_root에 파일을 만들어 보면 호스트에선 보이지 않는다. 프로세스가 namespace mnt에 의해서 격리되었기 때문이다.

실습 (pviot_root)

이제 pivot_root를 써서 unshare로 격리된 환경에서 루트를 바꾸자.

## 사용할 명령어
pivot_root
# pivot_root [new-root] [old-root]
## 사용법은 심플합니다 ~ new-root와 old-root 경로를 주면 됩니다

pivot_root로 /tmp/new_root가 새로운 루트 디렉토리가 되게한다.

##### 터미널 1 #####
# 디렉토리 생성
mkdir /tmp/new_root #신규루트용 디렉토리
cp -r /tmp/myroot/* /tmp/new_root/ #chroot로 컨테이너 만들기에서 만든 파일들 복사
mkdir /tmp/new_root/old_root #기존루트용 디렉토리

# unshare로 격리환경 생성
unshare --mount /bin/sh

/tmp/new_root에 뭐가 있는지 확인해 보자

이제 pivot_root를 실행 해보자.
실행해 보고 루트 디렉토리를 조회해 보면 원래 /tmp/new_root에 있던게 / 로 이동한결 볼 수 있다. 그리고 이제 old_root안에 원래 / 에 있던게 보이는걸 확인할 수 있다.

##### 터미널 1 #####
# pivot_root 실행
cd /tmp/new_root
pivot_root . old_root

# 신규루트 확인
cd /
ls /
bin escape_chroot lib lib64 proc old_root usr

이제 탈옥해 보자. /에 escape_chroot가 있다.
escape_chroot를 했음에도 / 에는 /tmp/new_root만 보인다.

cd /
./escape_root
cd ../../../
ls /
bin escape_chroot lib lib64 proc old_root usr

Namesapce 추가설명

위에서 namespace에 대해 간략히 설명하고 종류를 보여주었다. 한번 namespace의 종류별 실체를 확인해 보고 이해력을 높이자.

네임스페이스는 프로세스에 격리된 환경을 제공하기 위함이라고 했었다. 추가로 다음과 같은 특징이 있다.

  • unshare 명령어로 격리할 수 있다.
  • 자식은 부모 네임스페이스 상속된다.
  • 네임스페이스 격리는 선택적으로 할 수 있어서 호스트의 파일을 선택적으로 사용이 가능하다.
  • lsns 명령어로 네임스페이스 정보 확인 가능하다. 이 명령어가 실질적으로 확인할때 쓰인다.

명령어 확인

터미널 2개를 띄우고 모두 root로 전환한다.

##### 터미널1 #####
sudo su

# 현재 사용중인 프로세스의 namespace 파일과 inode 확인 가능
root@MyServer:/tmp# ls -al /proc/$$/ns
total 0
dr-x--x--x 2 root root 0 Sep  1 04:19 .
dr-xr-xr-x 9 root root 0 Sep  1 03:43 ..
lrwxrwxrwx 1 root root 0 Sep  1 04:19 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Sep  1 04:19 uts -> 'uts:[4026531838]'

# lsns 명령어로 현재 프로세스의 namespace 확인
# NS: 해당 네임스페이스 id, 해당 값이 같으면 그 프로세스는 같은 네임스페이스를 공유하고 있다고 보면 된다
# type: 네임스페이스 타입
# NPROCS: 해당 네임스페이스와 연결된 프로세스 개수
# PID: 해당 네임스페이스를 최초 주입한 프로세스ID
lsns -p $$
root@MyServer:/tmp# lsns -p $$
        NS TYPE   NPROCS PID USER COMMAND
4026531834 time      108   1 root /sbin/init
4026531835 cgroup    108   1 root /sbin/init
4026531836 pid       108   1 root /sbin/init
4026531837 user      108   1 root /sbin/init
4026531838 uts       104   1 root /sbin/init
4026531839 ipc       108   1 root /sbin/init
4026531840 net       108   1 root /sbin/init
4026531841 mnt       101   1 root /sbin/init

# lsns 명령어로 타입지정 하여 확인
root@MyServer:/tmp# lsns -t mnt -p $$
        NS TYPE NPROCS PID USER COMMAND
4026531841 mnt     101   1 root /sbin/init

1. MNT Namespace

unshare로 mnt 네임스페이스만 격리하여 실행하고 격리된 프로세스와 격리되지 않은 호스트를 비교해 보자.

##### 터미널1 ######
# -m 옵션으로 mount namespace만 격리
# 추가 명령어가 없어서 기본쉘인 bash로 실행된다.
unshare -m
lsns -p $$

##### 터미널2 ######
lsns -p $$

아래 사진을 보면 격리된 좌측은 mnt 타입의 NS, PID, COMMAND가 다르다는걸 확인할 수 있다. 새로 격리된 환경에서 프로세스가 생성되어서 네임스페이스도 새로 생성되고 프로세스ID도 달라지고 unshare로 생성된 쉘은 기본쉘이 bash여서 COMMAND도 다르다.

2. UTS Namespace (Unix Time Sharing)

옛날에 물리적 한계 떄문에 한대의 서버에서 최대의 효율성을 내고자 서버를 시분할 나눠쓰기를 했었다. 이때 쓰였던 네임스페이스 이다. 이 네임스페이스에는 호스트명, 도메인명 격리도 포함된다.

이번에도 uts 네임스페이스만 격리하고 비교해 보자

##### 터미널1 ######
# -u 옵션으로 uts namespace만 격리
# 추가 명령어가 없어서 기본쉘인 bash로 실행된다.
unshare -u
lsns -p $$

##### 터미널2 ######
lsns -p $$

추가로 격리환경에서 hostname을 변경하고 호스트에서도 바뀌는지 확인해 보자

##### 터미널1 ######
hostname KANS #변경
hostname      #확인

##### 터미널2 ######
hostname      #확인

3. IPC Namespace (Inter-Process Communication)

프로세스 간 통신자원 분리 관리를 해준다. share memory, pipe, message queue 등의 격리가 포함된다.

이번에도 ipc 네임스페이스만 격리하고 비교해 보자

##### 터미널1 ######
# -i 옵션으로 ipc namespace만 격리
# 추가 명령어가 없어서 기본쉘인 bash로 실행된다.
unshare -i
lsns -p $$

##### 터미널2 ######
lsns -p $$

이번에도 NS, NPROCS, COMMAND가 다르다

IPC는 shared memory가 포함되다고 해었다. 그래서 2개이 컨테이너 간 shared memory를 만들어서 같은 IPC namespace를 쓰는지 확인해 보자.

##### 터미널1 #####
# 호스트로 돌아와서
docker run --rm --name test1 --ipc=shareable -it ubuntu bash
# 컨테이너 안에서
# shared memory 조회
ipcs -m
# shared memory 생성
ipcmk -M 2000
# shared memory 생성
ipcmk -M 2000
# shared memory 조회
ipcs -m

##### 터미널2 #####
# 호스트로 돌아와서
docker run --rm --name test2 --ipc=container:test1 -it ubuntu bash
# shared memory 조회
ipcs -m

4. PID Namespace

Process ID의 결리를 담당한다. 부모에서 생성된 자식 프로세스는 자기자신이 시작된 process ID는 1번으로 보이게 된다.

1번 프로세스는 다음과 같은 특징을 가지기 때문에 중요하다

  • init 프로세스, 첫 유저모드로 들어가는 프로세스
  • 시그널 처리
  • 좀비, 고아 프로세스 처리
  • 죽으면 시스템 패닉 발생 (reboot)

터미널1에서 unshare로 pid namespace만 격리하자. 거기서 pid를 확인하고 해당 pid가 가지는 namespace id를 호스트에서 어떻게 나온는지 비교해 보자

##### 터미널1 #####
# -f옵션: fork옵션, child process를 fork하여 새로운 네임스페이스로 격리
# -p옵션: pid namespace 격리
# --mout-proc: /proc을 마운트 하여 ps명령어로 프로세스를 확인하기 위함
unshare -fp --mount-proc /bin/sh

# pid 확인
ps -ef
# ns id 확인
lsns -t pid -p 1

##### 터미널2 #####
ps -ef
lsns -t pid -p {해당pid}

아래 사진은 pid를 비교해 볼 수 있다.

아래 사진은 namespace id를 비교해 볼 수 있다.
그리고 해당 pid를 죽여버리면 격리환경이 killed 되는걸 확인할 수 있다.

5. Network Namespace

이는 추후에 정리 예정

6. User Namespace

UID, GID를 격리한다. 이를 통해 컨테이너의 루트권한 문제가 해결된다. 부모의UID-자식의UID가 중첩되지만 다른 값으로 보이게 격리되는 구조. 도커도 user namespace 지원을 하나 기본설정은 아님.

도커 컨테이너 안에서와 호스트에서의 uid가 어떻게 보이는지 확인해 보자.

##### 터미널1 #####
docker run -it ubuntu /bin/sh
whoami
id
readlink /proc/$$/ns/user

##### 터미널2 #####
whoami
id
readlink /proc/$$/ns/user

사진을 보면 둘다 root이며 uid=0으로 나온다. 이로서는 부족해서 namespace도 확인하니 같은 namespace를 공유하는걸로 확인된다. 그러므로 같은 권한을 가진 같은 사용자라고 보면 된다.

하지만, 이건 큰 문제를 야기한다. 컨테이너가 탈취되어 해당 유저로 호스트에 접근할 수 있게 되면 보안상 큰 문제다.

unshare로 격리하고 process id를 비교하여 어떻게 다른 user로 보이는지 봐보자.

##### 터미널2 #####
# -U옵션: uid namespace 격리
unshare -U --map-root-user /bin/sh
whoami
id
ps -ef | grep '/bin/sh'

##### 터미널2 #####
whoami
id
ps -ef | grep '/bin/sh'

사진을 보면 pid는 같은데 사용자가 다른걸 볼 수 있다. pid가 같은 이유는 pid namespace 격리를 하지 않았기 때문이고 uid만 격리시켜서 다른 사용자 이름이 보이는 것이다. 호스트(ubuntu)-컨테이너(root) 인데도 id 명령어 결과가 다른걸 볼 수 있다.

근데 왜 도커가 기본제공을 안하는가? 그건 패키지 설치와 리소스 접근이 어렵기 떄문이다. 참고로 K8S에서도 1.25 부터에서도 pod usernamespace가 가능, 1.30부터 베타로 올라와서 활용이 가능해 보인다.

정리

이렇게 chroot가 아닌 pivot_root + namesapce MNT로 컨테이너를 만들어 봤다. 별도의 프로세스를 만드는건 어렵지 않지만 그 프로세스를 호스트와 격리시키는것에 꽤 난이도가 있는거 같다.

0개의 댓글