NGINX Unit의 파일 시스템 격리 기술
파일 시스템의 rootfs 옵션을 사용하면 임의의 디렉토리를 애플리케이션의 파일 시스템 root로 지정할 수 있습니다. 따라서 애플리케이션을 경량의 On‑Demand 컨테이너로 구성 및 실행하여 보안을 개선하고 애플리케이션을 서로 및 기본 OS로부터 격리(Isolation)하고 인프라의 세분성을 향상할 수 있습니다.
하지만, 이 권한은 책임감 없이 오는 것은 아닙니다.
목차
1. 주의 및 기술 사항
2. 애플리케이션 보안
3. 전역 종속성 조작(Manipulating Global Dependencies)
4. 결론
1. 주의 및 기술 사항
아마도 가장 중요한 사항은 rootfs 기능이 bind mounts 또는 nullfs 파일 시스템 을 지원하는 Linux 및 Unix 기반 시스템에서만 사용할 수 있다는 것입니다.
또한 격리 개체(Isolation)가 새로운 mount Namespace를 정의하는 경우 안전성을 높이기 위해 NGINX Unit은 chroot 대신 pivot_root 시스템 호출을 사용합니다. 결과적으로 rootfs 사용의 또 다른 기술적 측면은 NGINX Unit의 메인 프로세스가 시스템 관리자 기능, 특히 CAP_SYS_ADMIN
을 갖추어 필요한 모든 시스템 호출을 할 수 있어야 한다는 것입니다.
실제로 이러한 두 가지 고려 사항은 Main Process가 root로 실행되어야 함을 의미합니다. 이미 이 요구 사항을 충족할 가능성이 매우 높지만, 100% 확실하지는 않습니다. 일부 설치에서는 NGINX Unit의 Main Process가 다른 시스템 사용자로 실행될 수 있습니다. 그러나 Main Process가 클라이언트 연결을 처리하거나 애플리케이션 코드를 실행하지 않으므로 루트로 실행해야 하는 애플리케이션에 추가적인 위험이 발생하지 않습니다. 이 모든 작업은 권한이 없는 자격 증명으로 실행되는 별도의 프로세스를 통해 수행됩니다.
이 모든 것을 설명한 후에, rootfs가 유용한 몇 가지 시나리오를 검토해 봅시다.
2. 애플리케이션 보안
이것은 언급하기에는 너무 명백해 보이지만, 전체 격리(Isolation) 객체의 주요 목표는 애플리케이션이 설정한 한계를 벗어나 물리적으로 모험을 할 수 없도록 만드는 것입니다. 객체가 처음 도입되었을 때 파일 시스템 액세스를 제한하는 기능이 현저히 부족했지만 rootfs 옵션은 이 격차를 좁힙니다.
손상된 Python 애플리케이션이 있고 이제 정적 파일 업로드 또는 기타 수단을 통해 임의의 코드를 주입할 수 있다고 가정합니다. 먼저 공격자의 모자를 쓰고 기회를 확인해 봅시다.
from flask import Flask, request, Response
import os, stat, subprocess
application = Flask(__name__)
@application.route("/find/")
def find():
l = []
path = request.args.get("path")
for root , _, files in os.walk(path):
for f in files:
try:
absp = os.path.join(root, f)
if os.stat(absp).st_mode & stat.S_ISUID:
l.append(absp)
except:
pass
return Response("\n".join(l), mimetype="text/plain")
이 코드는 추가로 악용될 수 있는 setuid 실행 파일이 있는지 시스템을 스캔합니다. 이 실행 파일은 일반적으로 자체적으로 허용되지 않습니다. 이러한 실행 파일을 속여 중요한 데이터에 대한 액세스를 제공할 수 있다면 시스템이 심각하게 손상됩니다. 그러나 이러한 잘못된 구성은 정기적으로 발생하므로 기본 설정부터 시작하여 일반적인 상황이 어떻게 진행되는지 살펴보겠습니다.
{
"listeners": {
"*:80": {
"pass": "applications/rootfs_demo"
}
},
"applications": {
"rootfs_demo": {
"type": "python",
"path": "/path/to/rootfs_demo/",
"home": "/path/to/rootfs_demo/venv/",
"module": "wsgi"
}
}
}
이렇게 구성하면 손상된 애플리케이션은 다음을 생성합니다.
$ curl http://localhost/find/?path=/usr/bin
/usr/bin/passwd
/usr/bin/fusermount
/usr/bin/sudo
/usr/bin/chfn
/usr/bin/umount
/usr/bin/pkexec
/usr/bin/sg
/usr/bin/cp
/usr/bin/atrm
...
보시다시피 초기 스캔은 훌륭하게 잘 작동했습니다. 부주의하게 구성된 /usr/bin/cp
실행 파일을 사용하여 Attack Vector를 얻을 수 있었습니다. 이전에 사용한 것과 동일한 주입 방법으로 몇 가지 중요한 데이터를 추출하는 두 번째 단계를 추가하여 계속하겠습니다.
@application.route("/exfiltrate/")
def exfiltrate():
file = request.args.get("file")
subprocess.run(args = ["/usr/bin/cp", "--no-preserve=mode", file, "./out"])
return Response(open("./out").read(), mimetype="text/plain")
이 코드를 실행하면 다음과 같은 중요한 정보를 볼 수 있습니다.
$ curl http://localhost/exfiltrate/?file=/etc/shadow
root:$6$QF7EX8XQ4BnLFVo/$f3hqo1vdWqK77kEuY4NOKsvgP1.XBtcO4fOND78IV/jP1i6/PtG/RHWZAqL3PQ3AVvwXwgBUbmAeOVtYDSg2o/:18471:0:99999:7:::
...
이제 rootfs를 Mix에 추가하여 악이 다시 우세하지 않도록 합시다. 새로운 구성은 다음과 같습니다.
{
"listeners": {
"*:80": {
"pass": "applications/rootfs_demo"
}
},
"applications": {
"rootfs_demo": {
"type": "python",
"path": "/",
"home": "/venv/",
"module": "wsgi",
"isolation": {
"rootfs": "/path/to/rootfs_demo/"
}
}
}
}
$ curl http://localhost/find/?path=/usr/bin
시스템 디렉토리가 새 파일 시스템 root에 매핑되지 않았기 때문에 Traversal에서 아무것도 생성되지 않습니다.
$ curl http://localhost/exfiltrate/?file=/etc/shadow
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
의도한 대로 현재 중요한 파일에 연결할 수 없습니다. rootfs 옵션은 애플리케이션이 외부에 액세스하는 것을 차단합니다. 또한 원하는 /etc/shadow
에 액세스하거나 마음대로 시스템 디렉토리를 이동할 수도 없습니다. 애플리케이션은 Sandbox 디렉터리로 제한됩니다.
하지만 공격자가 애플리케이션이 Sandbox될 것임을 알고 이를 대비한다면 어떻게 될까요? 간단히 말해서, 충분한 준비를 통해 사용할 수 있는 chroot 환경을 벗어날 수 있는 유명한 방법이 있습니다. 그러나 NGINX Unit은 chroot 대신 pivot_root 시스템 호출을 사용하는 방법을 제공하여 이를 대비합니다. rootfs 옵션과 함께 mount Namespace를 활성화하기만 하면 됩니다.
"isolation": {
"rootfs": "/path/to/rootfs_demo/",
"namespaces": {
"mount": true
}
}
마지막으로, rootfs의 또 다른 기능은 언어별 종속성을 새 파일 시스템 에 자동으로 매핑할 수 있다는 것입니다. 그렇기 때문에 rootfs를 활성화한 후에는 가져오기 지시문을 유효하게 만들기 위해 아무것도 할 필요가 없습니다.
from flask import Flask, request, Response
import os, stat, subprocess
만약 rootfs가 파일 시스템 root만 변경한 경우 두 번째 가져오기 지시문은 유효하지 않게 됩니다. 또한 Flask 가상 환경을 실행하기 위해 아무것도 할 필요가 없었습니다. 이 작업은 자동으로 수행됩니다. 안타깝게도 이러한 유형의 매핑은 현재 NGINX Unit이 지원하는 일부 언어 중 Java, Python 및 Ruby에서만 사용할 수 있습니다.
매핑에 대해 말하자면, rootfs로 해결할 수 있는 또 다른 문제에 대해 간단히 논의해 봅시다.
3. 전역 종속성 조작(Manipulating Global Dependencies)
위에서 언급한 바와 같이 NGINX Unit은 정교한 내부 메커니즘을 활성화하여 rootfs가 새 파일 시스템 root로 전환한 후에도 애플리케이션에서 언어별 종속성을 계속 사용할 수 있도록 합니다. 하지만 다른 종속성은 어떨까요?
일반적으로 애플리케이션이 사용자 지정 라이브러리 또는 시스템의 아무 곳에나 배치할 수 있는 모듈에 의존하는 상황에서 working_directory 옵션 및 환경 객체는 root와 같은 언어별 옵션과 함께 표시되는 사용자 지정 종속성 간에 적합하다고 판단되면 전환할 수 있습니다.
또한 일부 언어 자체는 가상 환경 또는 다른 유형의 버전 관리를 사용하여 이러한 종속성을 조작하는 도구 세트를 제공합니다. NGINX Unit은 실제로 이를 활용하여 기본적으로 Python의 가상 환경을 지원합니다. 그러나 애플리케이션에 미리 정의된 절대 경로에 있어야 하는 고정된 시스템 전체 종속성이 있는 경우 상황이 복잡해질 수 있습니다. 서로 다른 버전의 종속성을 나란히 사용하면 완전히 불가능하지는 않더라도 복잡해질 수 있습니다. 그럼에도 불구하고 기본 런타임 전환 메커니즘을 구현하기 위해 rootfs를 사용할 수 있습니다.
다음 PHP 애플리케이션을 상상해 보십시오(다른 언어에서도 동일한 트릭을 수행할 수 있습니다).
<?php
require '/var/custom/module.php'; // Our hardcoded dependency
module_do_stuff("How do you like this?\n");
?>
이제 종속성인 module.php를 가지고 확인 해봅시다. 두 가지 버전이 있다고 가정해 보겠습니다(단지 서로 다르다는 것을 보여주기 위한 것입니다).
<?php
// Version A, stored as /www/data/a/var/custom/module.php
function module_do_stuff($stuff) {
echo "Implementation A, legacy: ".$stuff;
}
?>
<?php
// Version B, stored as /www/data/b/var/custom/module.php
function module_do_stuff($stuff) {
echo "Implementation B, brand new: ".$stuff;
}
?>
이제 /www/data/a/
및 /www/data/b/
에 두 개의 동일한 애플리케이션 사본을 배치하고 다음 구성을 적용합니다.
{
"listeners": {
"*:80": {
"pass": "applications/ab_app"
}
},
"applications": {
"ab_app": {
"type": "php",
"user": "www-data",
"script": "index.php",
"root": "/",
"isolation": {
"rootfs": "/www/data/a/"
}
}
}
}
애플리케이션의 root 옵션은 rootfs 값에 상대적입니다. 실제로 이것은 rootfs가 사용될 때 모든 애플리케이션 경로 기반 옵션에 적용됩니다.
이 구성에서 curl 명령은 다음을 생성합니다.
$ curl http://localhost
Implementation A, legacy: How do you like this?
다음으로 module.php
를 버전 B로 전환하려고 합니다. 힘들게 다시 설치하거나 다른 컨테이너를 실행하는 대신 다음 명령만 실행하면 됩니다.
$ curl -X PUT -d '"/www/data/b/"' --unix-socket /var/run/control.unit.sock http://localhost/config/applications/ab_app/isolation/rootfs/
이렇게 하면 rootfs 설정(URL의 구성 API 경로 참조)이 업데이트되어 NGINX Unit 구성의 다른 모든 부분은 그대로 유지됩니다. 이제 curl 쿼리의 결과는 다음과 같습니다.
$ curl http://localhost
Implementation B, fancy: How do you like this?
이것은 간단한 예이지만 NGINX Unit을 사용하면 시스템 전체 종속성의 다양한 조합을 고려하여 여러 가상 머신 또는 컨테이너를 생성하고 유지 관리하는 번거로움을 피할 수 있는 방법을 보여줍니다. 또한 NGINX Unit 구성에서 단일 설정을 변경하는 것만으로 애플리케이션 또는 언어 런타임이 미리 정의된 경로에서 예상하는 표준 라이브러리 및 맞춤형 모듈의 변형을 통해 복제 및 회귀할 수 있습니다. 불행하게도, 이 기능은 계층화되고 숨겨진 간접적인 종속성이 존재할 때 다소 비정상적으로 작동할 수 있지만, 현재 우리가 적극적으로 연구하고 있습니다.
4. 결론
새로운 rootfs 기능의 이점을 받는 몇 가지 사용 사례에 대해 설명했습니다. 이것은 NGINX Unit을 강력한 앱 서버에서 강력한 경량 컨테이너 엔진으로 변환하는 방법을 충분히 설명한다고 믿습니다. 또한 매우 다재다능한 격리 기능 세트, 즉 디렉토리 바인딩을 주장하기 전에 다른 기능을 추가해야 한다는 것을 인식하고 있습니다. NGINX Unit의 컨테이너화 약속을 완전히 이행하려면 root뿐만 아니라 Make-Believe 파일 시스템의 모든 지점에서 임의 디렉토리 mount를 지원해야 합니다.
항상 그렇듯이 로드맵을 확인하여 좋아하는 기능이 곧 구현될지 여부를 확인하고 사내 Initiatives에 대해 평가하고 의견을 제시해 주시기 바랍니다. GitHub의 Repo에서 새로운 문제를 자유롭게 열고 개선을 위한 아이디어를 공유하세요.
NGINX Unit의 최신 소식을 빠르게 전달 받고 싶으시면 아래 뉴스레터를 구독하세요.