본문 바로가기

보호의 원리

운영체제가 자원을 보호하는 단위인 보호 도메인과, 누가 무엇을 할 수 있는지 표현하는 접근 행렬을 살펴봅니다. 접근 행렬을 열로 나눈 ACL과 행으로 나눈 역량 리스트를 모델로 비교하고, Linux 커패빌리티와 최소 권한·권한 분리 원칙을 실무 예와 함께 정리합니다.

여러 사용자와 프로그램이 한 컴퓨터를 함께 쓰려면, 한 사용자의 프로세스가 다른 사용자의 데이터를 마음대로 읽거나 고칠 수 없어야 합니다. 한 프로그램의 버그가 시스템 전체를 망가뜨려서도 안 됩니다. 보호(protection)는 프로세스와 사용자가 허락된 자원에, 허락된 방식으로만 접근하도록 운영체제가 통제하는 메커니즘입니다.

보안(security)은 이보다 넓은 목표로, 내부와 외부의 위협으로부터 시스템의 기밀성, 무결성, 가용성을 지키는 일입니다. 보호는 보안을 이루는 도구이며, 아무리 정교한 보호 장치도 사용자의 비밀번호가 유출되면 소용이 없다는 점에서 보안이 다루는 범위가 더 넓습니다. 이 글은 보호의 원리를, 12장 3편은 보안 위협과 방어를 다룹니다.

보호가 없다면

운영체제의 보호 장치가 없다면 다음과 같은 일이 벌어질 수 있습니다. 일반 프로그램이 커널의 메모리를 덮어써서 시스템이 멈추고, 한 프로세스가 다른 프로세스의 메모리를 읽어 비밀번호를 빼냅니다. 버그가 있는 프로그램이 /etc/passwd를 덮어써 아무도 로그인하지 못하게 되고, 무한 루프에 빠진 프로그램이 CPU를 독차지해 다른 프로그램이 실행되지 못합니다.

운영체제는 여러 겹의 장치로 이를 막습니다. CPU의 모드 비트는 커널 모드와 사용자 모드를 나누어 특권 명령을 커널만 쓰게 하고, 페이지 테이블의 권한 비트는 프로세스마다 메모리를 나누고 읽기 전용 영역을 지킵니다. 타이머 인터럽트는 CPU를 독차지하는 프로그램에게서 CPU를 되찾아 오고, 파일 권한은 누가 어떤 파일을 읽고 쓸 수 있는지 정합니다. 앞의 장들에서 본 이 장치들이 모두 보호의 구성 요소입니다.

보호 도메인

보호 도메인(protection domain)은 프로세스가 접근할 수 있는 자원과, 자원마다 허락된 연산의 집합입니다. 프로세스는 언제나 어떤 도메인 안에서 실행되며, 그 도메인이 허락하는 범위에서만 자원을 쓸 수 있습니다.

Unix에서는 프로세스의 사용자 ID(UID)와 그룹 ID가 도메인을 정하는 주요 요소입니다. 다만 실제 접근 판단에는 유효 ID, 보조 그룹, ACL, 커패빌리티, SELinux 같은 보안 정책이 함께 관여하므로, UID가 같아도 프로세스마다 할 수 있는 일이 다를 수 있습니다. 커널 모드와 사용자 모드도 도메인의 한 종류로 볼 수 있습니다.

프로세스는 실행 중에 도메인을 바꾸기도 합니다. 시스템 콜을 부르면 사용자 모드에서 커널 모드로 옮겨 가고, 일반 사용자가 passwd 명령을 실행하면 이 파일에 설정된 SetUID 비트 때문에 root의 권한으로 실행되어 /etc/shadow를 고칠 수 있습니다. 도메인을 바꾸는 이런 통로는 정해진 방식으로만 열려 있어야 하며, 그 통로가 공격의 표적이 되기도 합니다.

접근 행렬

접근 행렬(access matrix)은 보호를 표현하는 이론적 모델입니다. 행은 도메인, 열은 자원이고, 칸에는 그 도메인이 그 자원에 할 수 있는 연산이 들어갑니다. 빈 칸은 아무것도 허락되지 않았다는 뜻입니다.

도메인file1file2printerdomain2
domain1read, writereadprintswitch
domain2readread, write
domain3readprint

도메인 자체도 열에 넣을 수 있습니다. domain1 행의 domain2 칸에 있는 switch는 domain1에서 domain2로 전환할 수 있다는 뜻으로, 도메인 전환도 같은 표 안에서 통제합니다.

실제 시스템에는 도메인과 자원이 수천, 수만 개 있고 대부분의 칸이 비어 있으므로, 행렬을 그대로 저장하면 공간이 크게 낭비됩니다. 그래서 행렬을 열 방향이나 행 방향으로 잘라 빈 칸을 빼고 저장합니다. 다음 코드는 빈 칸을 적지 않은 행렬에서 두 방향의 목록을 뽑아냅니다.

# 접근 행렬: (도메인, 자원) -> 허용된 연산. 빈 칸은 적지 않는다.
matrix = {
    ("domain1", "file1"): {"read", "write"},
    ("domain1", "file2"): {"read"},
    ("domain1", "printer"): {"print"},
    ("domain1", "domain2"): {"switch"},
    ("domain2", "file1"): {"read"},
    ("domain2", "file2"): {"read", "write"},
    ("domain3", "file2"): {"read"},
    ("domain3", "printer"): {"print"},
}

def acl(resource):              # 열 하나: 이 자원에 누가 무엇을 할 수 있나
    return {d: sorted(ops) for (d, r), ops in matrix.items() if r == resource}

def capabilities(domain):       # 행 하나: 이 도메인이 어디에 무엇을 할 수 있나
    return {r: sorted(ops) for (d, r), ops in matrix.items() if d == domain}

def allowed(domain, resource, op):
    return op in matrix.get((domain, resource), set())   # 적혀 있지 않으면 거부한다

print("file2의 ACL:", acl("file2"))
print("domain1의 역량 리스트:", capabilities("domain1"))
print("domain3이 file1에 쓰기:", allowed("domain3", "file1", "write"))
print("domain1이 domain2로 전환:", allowed("domain1", "domain2", "switch"))
file2의 ACL: {'domain1': ['read'], 'domain2': ['read', 'write'], 'domain3': ['read']}
domain1의 역량 리스트: {'file1': ['read', 'write'], 'file2': ['read'], 'printer': ['print'], 'domain2': ['switch']}
domain3이 file1에 쓰기: False
domain1이 domain2로 전환: True

열 하나를 뽑으면 그 자원의 접근 제어 목록이, 행 하나를 뽑으면 그 도메인의 역량 리스트가 됩니다. 같은 행렬을 다른 방향으로 읽었을 뿐이므로 권한이 새로 생기거나 사라지지는 않습니다. allowed()가 적혀 있지 않은 조합을 모두 거부하는 것처럼, 보호는 명시적으로 허락하지 않은 것을 거부하는 기본 거부(default deny)에서 출발합니다.

ACL과 역량 리스트

보호 모델 · 같은 권한의 두 방향

ACL은 자원별 열, 역량 리스트는 도메인별 행으로 접근 행렬을 읽습니다

본문 접근 행렬에서 file1의 열과 domain1의 행을 선택해 펼쳤습니다. 같은 domain1 → file1의 read·write 권한이 두 표현에서 어디에 놓이는지 비교합니다. 전체 행렬의 모든 권한을 나열한 그림은 아닙니다.

열 선택 · file1을 중심으로

질문: 이 자원에 누가 어떤 연산을 할 수 있는가?

file1 열에서 허용된 도메인을 찾는 ACL 투영domain1에서 file1로 read와 write, domain2에서 file1로 read 화살표가 들어간다. domain3는 file1에 허용 권한이 없으므로 허용 화살표가 없다. 화살표 방향은 도메인에서 자원이며 두 가지 표현이 권한을 새로 만드는 것은 아니다.domain1read·writedomain2readdomain3허용 권한 없음file1선택한 열domain3는 허용 간선 없음
같은 file1 ACL을 세로로 펼친 두 허용 관계domain1의 read·write와 domain2의 read가 같은 file1로 향한다. domain3에는 허용 권한이 없어 선이 없다. 배치만 세로로 바꾸며 원 행렬의 domain1과 file1 관계를 바꾸지 않는다.file1선택한 열domain1read·writedomain2readdomain3허용 권한 없음선 방향: 도메인→자원domain3는 허용 선 없음

file1 열의 개념 목록

  • domain1 → file1: read, write
  • domain2 → file1: read
  • domain3 → file1: 없음

빈 항목은 허용 권한 없음입니다. 실제 ACL에서는 무권한 항목을 생략할 수 있습니다.

행 선택 · domain1을 중심으로

질문: 이 도메인은 어느 자원에 어떤 연산을 할 수 있는가?

domain1 행에서 허용된 네 자원을 찾는 역량 투영domain1에서 file1 read·write, file2 read, printer print, domain2 switch 네 자원으로 향하는 네 화살표다. domain2는 switch 연산의 전환 대상 자원이다. 자원 이름 아래 연산은 그 도메인과 자원 사이 권한이며 새로운 허용을 추가하지 않는다.domain1선택한 행file1read·writefile2readprinterprintdomain2switch
같은 domain1 역량의 네 방향을 세로로 펼치기하나의 domain1 노드에서 file1 read·write, file2 read, printer print, domain2 switch로 향하는 네 화살표다. 네 경로의 포트를 나누며 원 행렬의 허용 연산을 그대로 유지한다.domain1선택한 행file1read·writefile2readprinterprintdomain2switch선의 출발은 domain1domain2는 전환 대상

domain1 행의 개념 목록

  • domain1 → file1: read, write
  • domain1 → file2: read
  • domain1 → printer: print
  • domain1 → domain2: switch

domain2는 전환 대상 자원이고 switch는 그 도메인으로 전환할 수 있는 권한입니다.

역량은 위조·변조할 수 없도록 보호해야 합니다. 이 객체별 역량 리스트는 Linux의 CAP_NET_BIND_SERVICE처럼 root 특권을 세분화한 capability 집합과 같은 자료구조가 아닙니다.

ACL: 자원마다 붙이는 목록

접근 제어 목록(access control list, ACL)은 접근 행렬을 열 방향으로 나눈 것으로, 자원마다 누가 무엇을 할 수 있는지 목록을 붙여 둡니다. 자원을 중심으로 관리하므로 이 파일에 누가 접근할 수 있는지 한눈에 알 수 있고, 어떤 사용자의 권한을 빼앗으려면 그 자원의 목록에서 항목을 지우면 됩니다. 대신 한 사용자가 접근할 수 있는 자원을 모두 찾으려면 모든 자원의 목록을 뒤져야 합니다.

Unix의 전통적인 권한은 ACL을 아주 간단하게 줄인 것입니다. 사용자 목록 대신 소유자, 그룹, 나머지의 세 범주만 두고, 범주마다 읽기, 쓰기, 실행의 세 비트를 줍니다. 특정 사용자에게만 권한을 더 주어야 한다면 POSIX ACL을 씁니다.

setfacl -m u:deploy:rx /var/www/app     # 소유자도 그룹도 아닌 deploy 사용자에게 읽기와 실행 권한
getfacl /var/www/app                    # 추가된 user:deploy 항목과 mask 항목을 확인
setfacl -x u:deploy /var/www/app        # 그 항목을 지운다

getfacl로 보면 user:deploy:r-x 항목과 함께 mask 항목이 생긴 것을 볼 수 있습니다. acl(5)에 따르면 mask는 이름이 붙은 사용자와 그룹 항목에 줄 수 있는 권한의 상한으로, 항목에 rwx가 있어도 mask가 r-x라면 실제로는 r-x만 허락됩니다. 파일 시스템이 ACL을 지원해야 하며, ext4와 XFS는 이를 지원합니다.

역량 리스트: 도메인이 쥐는 열쇠

역량 리스트(capability list)는 접근 행렬을 행 방향으로 나눈 것입니다. 도메인마다 자기가 접근할 수 있는 자원과 연산의 목록을 가지며, 목록의 항목 하나하나를 역량(capability)이라고 합니다. 역량은 열쇠와 같아서, 가진 것만으로 접근할 수 있고, 다른 도메인에 넘겨주면 권한도 함께 넘어갑니다. 그래서 역량은 사용자가 위조하거나 고칠 수 없게 커널이 보관하거나, 암호학적으로 서명해 진짜인지 확인할 수 있어야 합니다.

역량 방식에서는 권한을 빼앗기가 어렵습니다. 역량이 여러 도메인에 복사되어 흩어져 있으면 그것들을 모두 찾아 회수해야 하기 때문입니다. 그래서 역량이 직접 자원을 가리키는 대신 중간의 표를 가리키게 해 표의 항목을 지우거나, 역량에 만료 시각을 넣는 방법을 씁니다. 웹 서비스의 접근 토큰에 만료 시간이 있는 것도 같은 이유입니다.

Unix의 파일 디스크립터도 역량과 닮았습니다. 권한은 open()할 때 한 번 확인하고, 그 뒤로는 fd를 가진 것만으로 읽고 씁니다. 파일을 연 뒤에 권한을 바꾸어도 이미 열린 fd로는 계속 접근할 수 있고, Unix 도메인 소켓으로 fd를 다른 프로세스에 넘기면 그 프로세스도 같은 파일에 접근하게 됩니다. FreeBSD의 Capsicum은 이 성질을 이용해, 프로세스가 미리 연 fd 외에는 새 자원에 접근하지 못하도록 가두는 기능입니다.

Linux 커패빌리티

Linux의 커패빌리티(capabilities)는 이름은 같지만 위의 역량 리스트와는 다른 개념입니다. 전통적인 Unix에서 root는 권한 검사를 모두 건너뛰었지만, Linux는 root의 특권을 capabilities(7)에 정리된 수십 개의 단위로 나누어 따로 주거나 뺄 수 있게 했습니다. 1024번 미만의 포트에 바인딩하는 CAP_NET_BIND_SERVICE, 다른 사용자의 프로세스에 신호를 보내는 CAP_KILL, 시스템 시계를 바꾸는 CAP_SYS_TIME, 파일 권한 검사를 건너뛰는 CAP_DAC_OVERRIDE 같은 것들입니다.

grep Cap /proc/self/status                               # 지금 프로세스의 커패빌리티 집합(16진수 비트마스크)
capsh --decode=0000000000000400                           # 비트마스크를 이름으로 풀기
sudo setcap cap_net_bind_service=+ep /usr/local/bin/web   # 이 프로그램에만 낮은 포트 바인딩을 허락
getcap /usr/local/bin/web                                 # 파일에 붙은 커패빌리티 확인

/proc/self/status의 CapEff는 지금 유효한 커패빌리티이고, CapBnd는 이 프로세스와 자식들이 가질 수 있는 상한입니다. 웹 서버가 80번 포트를 쓰려고 root로 실행할 필요 없이, 실행 파일에 CAP_NET_BIND_SERVICE 하나만 붙이면 됩니다. 반대로 CAP_SYS_ADMIN은 너무 많은 일을 허락해 사실상 root와 다름없으므로, 이것을 주는 것은 피해야 합니다.

더 깊이: 커패빌리티의 다섯 집합과 exec

CapEff와 CapBnd는 스레드마다 있는 커패빌리티 집합 다섯 가운데 둘입니다. capabilities(7)에 따르면 커널이 권한을 검사할 때 보는 것은 유효(effective) 집합이고, 허용(permitted) 집합은 유효 집합에 올릴 수 있는 것의 상한입니다. 허용 집합에서 한 번 뺀 커패빌리티는 SetUID root 프로그램이나 그 커패빌리티를 주는 파일 커패빌리티가 붙은 프로그램을 실행하지 않는 한 다시 얻을 수 없습니다. 그래서 특권이 필요한 초기화를 마친 서버가 남은 커패빌리티를 허용 집합에서 빼 두면, 나중에 그 프로세스가 장악되더라도 공격자가 되찾을 수 없습니다.

나머지 셋은 execve()로 다른 프로그램을 실행할 때 무엇이 이어지는지에 관여합니다. 상속(inheritable) 집합은 exec를 지나도 남지만, 실행할 파일에도 같은 커패빌리티가 상속 가능으로 표시되어 있어야 새 프로그램의 허용 집합에 더해집니다. 그래서 root가 아닌 프로그램이 커패빌리티를 가진 채 도우미 프로그램을 실행하기 어려웠고, Linux 4.3에 앰비언트(ambient) 집합이 더해졌습니다. 앰비언트 집합은 특권이 없는 프로그램을 exec할 때도 보존되며, 허용 집합과 상속 집합에 모두 있는 커패빌리티만 넣을 수 있습니다. 마지막으로 CapBnd에 해당하는 상한(bounding) 집합은 exec로 얻을 수 있는 커패빌리티를 제한하며, Linux 2.6.25부터 스레드마다 따로 있습니다.

최소 권한 원칙

최소 권한 원칙(principle of least privilege)은 프로세스와 사용자에게 일을 하는 데 꼭 필요한 권한만 주는 원칙입니다. 프로그램에 취약점이 있어 공격자가 그 프로그램을 장악해도, 공격자가 얻는 것은 그 프로그램의 권한뿐이므로 피해를 그만큼으로 묶어 둘 수 있습니다. 웹 서버는 문서 디렉터리를 읽기만 하고 로그 디렉터리에만 쓸 수 있게, 데이터베이스는 자기 데이터 디렉터리에만 접근하게, 배포용 봇 계정은 배포에 필요한 작업만 할 수 있게 하는 식입니다.

컨테이너를 실행할 때는 다음과 같은 옵션으로 권한을 줄입니다.

docker run \
  --read-only \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  -u 1000:1000 \
  my-web-image

--read-only는 컨테이너의 루트 파일 시스템을 읽기 전용으로 만들고, --cap-drop ALL은 모든 커패빌리티를 뺀 뒤 --cap-add로 낮은 포트 바인딩만 돌려줍니다. no-new-privileges는 SetUID 프로그램을 실행해도 권한이 늘지 않게 하고, -u 1000:1000은 root가 아닌 사용자로 실행합니다. 이미지가 이 조건에서 동작하려면 쓰기가 필요한 경로를 따로 마운트하는 등 이미지 쪽의 준비가 필요하므로, 옵션 하나하나를 이미지에 맞게 확인해야 합니다. systemd 서비스라면 User=, NoNewPrivileges=yes, CapabilityBoundingSet=, ProtectSystem=strict 같은 설정으로 같은 일을 합니다.

권한 분리

권한 분리(privilege separation)는 한 프로그램을 권한이 높은 부분과 낮은 부분으로 쪼개는 설계입니다. OpenSSH는 네트워크에서 들어오는 데이터를 해석하는 위험한 일을 권한 없는 프로세스에 맡기고, 인증 확인처럼 특권이 필요한 일만 작은 특권 프로세스가 맡습니다. 웹 브라우저도 웹 페이지를 그리는 렌더러를 권한이 거의 없는 샌드박스 프로세스에서 돌립니다. 공격자가 위험한 부분을 장악해도 손에 넣는 것은 권한 없는 프로세스이므로, 다시 특권 프로세스나 커널의 결함을 찾아야 합니다.

정리

  • 보호는 프로세스와 사용자가 허락된 자원에 허락된 방식으로만 접근하게 하는 메커니즘이고, 보안은 위협으로부터 시스템을 지키는 더 넓은 목표입니다.
  • 보호 도메인은 자원과 허락된 연산의 집합이며, 프로세스는 시스템 콜이나 SetUID 같은 정해진 통로로 도메인을 바꿉니다.
  • 접근 행렬은 도메인과 자원의 모든 권한을 표로 나타내며, 열로 나누면 ACL, 행으로 나누면 역량 리스트가 됩니다.
  • ACL은 자원별로 관리해 권한을 빼앗기 쉽고, 역량은 가진 것만으로 접근해 위임하기 쉽지만 회수가 어렵습니다. Unix의 파일 디스크립터는 역량과 닮았습니다.
  • Linux 커패빌리티는 root의 특권을 단위별로 나눈 것이며, 최소 권한과 권한 분리는 공격자가 프로그램을 장악했을 때의 피해를 줄입니다.

연습 문제

1. 접근 행렬을 두 방향으로 읽기

본문의 접근 행렬에서 domain3의 역량 리스트와 printer의 ACL을 적으세요. 그리고 domain3이 file1에 쓰려는 요청과, domain1이 domain2로 전환하려는 요청이 허락되는지 판정하세요.

해설 보기

domain3의 역량 리스트는 file2에 read, printer에 print입니다. printer의 ACL은 domain1에 print, domain3에 print이며, domain2는 printer에 아무 권한이 없습니다. domain3 행의 file1 칸은 비어 있으므로 file1에 쓰려는 요청은 거부됩니다. domain1 행의 domain2 칸에는 switch가 있으므로 전환은 허락됩니다. 두 방향의 목록은 같은 행렬을 다르게 읽은 것이어서, 어느 쪽으로 판정해도 결과가 같습니다.

2. 권한을 당장 빼앗아야 할 때

domain2의 file2 쓰기 권한을 즉시 빼앗아야 합니다. ACL 방식과 역량 방식에서 각각 무엇을 해야 하나요? Unix에서 파일의 권한을 바꾸었는데도 이미 그 파일을 쓰기용으로 열어 둔 프로세스가 계속 쓸 수 있는 이유도 설명하세요.

해설 보기

ACL 방식에서는 file2의 목록에서 domain2의 write를 지우면 다음 접근부터 거부됩니다. 역량 방식에서는 domain2가 가진 역량을 회수해야 하는데, 그 역량이 다른 도메인에 복사되어 있다면 그것들까지 찾아야 하므로 어렵습니다. 그래서 역량이 중간의 표를 가리키게 해 표의 항목을 무효로 하거나, 만료 시각을 두어 시간이 지나면 쓸 수 없게 합니다. Unix는 권한을 open()할 때 확인하고, 열린 파일 디스크립터는 역량처럼 그 자체로 접근을 허락합니다. 그래서 권한을 바꾸어도 이미 열린 fd에는 영향이 없고, 그 프로세스가 파일을 닫거나 종료되어야 접근이 끝납니다.

3. 80번 포트를 쓰는 웹 서버

일반 사용자로 실행하는 웹 서버가 80번 포트를 써야 합니다. root로 실행하는 것 말고 최소 권한 원칙에 맞는 방법을 두 가지 이상 제시하고, root로 실행할 때의 위험을 설명하세요.

해설 보기

첫째, 실행 파일에 CAP_NET_BIND_SERVICE만 붙여 일반 사용자로 실행해도 낮은 포트에 바인딩하게 할 수 있습니다. 둘째, systemd의 소켓 활성화를 쓰면 systemd가 80번 포트를 열어 소켓을 서버에 넘겨주므로, 서버는 처음부터 권한이 없어도 됩니다. 셋째, 높은 포트에서 서버를 실행하고 앞단의 리버스 프록시나 방화벽 규칙이 80번으로 들어온 연결을 그 포트로 넘기게 할 수도 있습니다. root로 실행하면 서버의 취약점 하나로 공격자가 시스템 전체의 권한을 얻습니다. 포트를 연 뒤 권한을 내려놓도록 짠 서버도 있지만, 권한을 내려놓기 전의 코드에 결함이 있으면 같은 위험이 남습니다.

다음 글 리눅스 권한 체계에서는 Linux의 사용자와 그룹, rwx 권한 비트가 실제로 어떻게 판정되는지, SetUID 같은 특수 권한과 umask가 어떻게 동작하는지 살펴봅니다.