OS 명령어 삽입 취약점이란? 위험한 코드와 안전한 예방 방법

OS 명령어 삽입 취약점이 발생하는 원리와 위험한 코드 구조를 살펴보고, 외부 명령어 제거와 입력값 검증, 최소 권한 설정을 이용한 대응 방법을 알아봅니다.

웹서비스를 개발하다 보면 운영체제 기능이 필요한 순간이 생깁니다. 서버 상태를 확인하거나 파일을 변환하고, 특정 프로그램을 실행해야 할 수도 있습니다. 이때 자바의 Runtime.exec(), 파이썬의 os.system(), PHP의 system()과 같은 기능을 사용할 수 있습니다.

문제는 사용자가 입력한 값을 운영체제 명령어와 직접 연결할 때 발생합니다. 외부 입력값이 단순한 데이터가 아니라 명령문의 일부로 해석되면 공격자가 개발자가 의도하지 않은 명령까지 실행할 수 있습니다. 이러한 보안 문제를 OS 명령어 삽입 취약점(OS Command Injection)이라고 합니다.

웹 애플리케이션 내부의 문제로 시작하지만, 피해는 서버 운영체제 전체로 확대될 수 있어 주의가 필요합니다.

OS 명령어 삽입 취약점이란?

OS 명령어 삽입은 외부에서 전달된 입력값이 운영체제 명령어에 포함돼 임의의 명령이 실행되는 취약점입니다. 명령어 인젝션 또는 커맨드 인젝션이라고도 부릅니다.

예를 들어 서버 연결 상태를 확인하기 위해 사용자가 입력한 주소를 ping 명령어에 전달하는 기능이 있다고 가정해 보겠습니다. 개발자가 입력값을 문자열로 연결한 뒤 셸을 통해 실행한다면 사용자가 입력한 값이 명령어 문법으로 해석될 가능성이 있습니다.

운영체제의 셸은 하나의 명령을 실행하는 기능만 제공하지 않습니다. 여러 명령어를 연결하거나 실행 결과를 다른 명령으로 전달하는 문법도 지원합니다. 따라서 외부 입력값에 셸에서 특별한 의미를 가지는 문자가 포함되면 원래 의도한 명령문의 구조가 달라질 수 있습니다.

결국 OS 명령어 삽입의 핵심은 다음과 같습니다.

신뢰할 수 없는 입력값이 운영체제 명령문의 구조에 영향을 주는 것입니다.

OS 명령어 삽입이 발생하는 원인

명령어 삽입 취약점은 주로 웹 요청으로 받은 값을 검증하지 않고 운영체제 명령어에 연결할 때 발생합니다.

대표적인 발생 원인은 다음과 같습니다.

  • 사용자 입력값을 명령어 문자열에 직접 연결하는 경우
  • /bin/sh, bash, cmd.exe, PowerShell과 같은 셸을 호출하는 경우
  • 입력값의 형식과 길이를 제한하지 않는 경우
  • 위험한 문자를 일부 제거하는 방식에만 의존하는 경우
  • 실행 파일과 인수를 하나의 문자열로 조합하는 경우
  • 웹 애플리케이션을 불필요하게 높은 권한으로 실행하는 경우
  • 외부 프로그램을 꼭 사용하지 않아도 되는 기능에 명령 실행 방식을 적용한 경우

특히 sh -ccmd.exe /c처럼 명령문을 셸에 다시 전달하는 코드는 점검이 필요합니다. 셸이 문자열 전체를 해석하므로 명령 연결 문자, 환경변수, 리다이렉션과 같은 기능이 함께 작동할 수 있기 때문입니다.

위험한 자바 코드 살펴보기

다음은 사용자가 입력한 호스트 주소로 연결 상태를 확인하는 예시입니다.

String host = request.getParameter("host");

String command = "ping -c 1 " + host;

Process process = Runtime.getRuntime().exec(
    new String[]{"/bin/sh", "-c", command}
);

겉으로 보면 ping 명령어를 한 번 실행하는 단순한 코드입니다. 그러나 host는 외부에서 전달되는 값이고, 프로그램은 이를 검증하지 않은 채 명령어 문자열에 직접 연결합니다.

더 큰 문제는 완성된 문자열을 /bin/sh -c에 전달한다는 점입니다. 셸은 host 값을 일반적인 주소 데이터로만 보지 않고 명령어 문법의 일부로 해석할 수 있습니다.

이 코드에서 확인해야 할 핵심 흐름은 다음과 같습니다.

HTTP 요청값 → host 변수 → command 문자열 결합 → 셸 실행

외부 입력값이 별도의 차단 과정 없이 명령어 실행 함수까지 전달된다면 OS 명령어 삽입 취약점을 의심해야 합니다.

윈도우 환경에서도 원리는 같습니다. 사용자 입력값을 문자열로 만든 뒤 cmd.exe /c나 PowerShell에 전달한다면 비슷한 문제가 발생할 수 있습니다.

Runtime.exec를 사용하면 항상 취약할까?

Runtime.exec()라는 메서드를 사용했다는 사실만으로 취약하다고 단정할 수는 없습니다. 어떤 값을 실행하는지, 외부 입력값이 포함되는지, 셸을 거치는지를 함께 확인해야 합니다.

예를 들어 다음과 같이 고정된 프로그램과 고정된 인수만 실행하고 외부 입력값이 전혀 포함되지 않는다면 명령어 삽입 가능성은 상대적으로 낮습니다.

new ProcessBuilder(
    "/usr/bin/systemctl",
    "status",
    "my-service"
).start();

반면 ProcessBuilder를 사용하더라도 사용자가 입력한 문자열을 sh -c에 전달하거나 여러 값을 하나의 명령문으로 결합한다면 안전하지 않습니다. 즉, Runtime.exec() 대신 ProcessBuilder로 바꾸는 것만으로 문제가 자동 해결되는 것은 아닙니다.

중요한 것은 외부 입력값과 명령어를 어떤 방식으로 처리하느냐입니다.

OS 명령어 삽입으로 발생할 수 있는 피해

OS 명령어 삽입 공격에 성공하면 웹 애플리케이션이 실행되는 계정의 권한으로 명령을 수행할 수 있습니다.

웹서비스 계정의 권한이 제한돼 있다면 피해 범위도 어느 정도 제한될 수 있습니다. 하지만 서비스 계정에 불필요하게 높은 권한이 부여돼 있거나 중요 파일에 접근할 수 있다면 상황은 달라집니다.

발생할 수 있는 피해는 다음과 같습니다.

  • 서버 내부 파일과 설정정보 열람
  • 중요 파일의 변경 또는 삭제
  • 애플리케이션 소스 코드와 환경설정 노출
  • 데이터베이스 접속정보나 API 키 유출
  • 악성 프로그램 또는 웹셸 설치
  • 서버 자원을 이용한 추가 공격
  • 내부 네트워크 정보 수집
  • 서비스 중단과 시스템 장애
  • 서버 권한 상승 시도

애플리케이션 설정 파일에는 데이터베이스 비밀번호, 클라우드 인증정보, 외부 API 키가 포함되는 경우가 있습니다. 따라서 명령어 실행 권한이 제한된 계정이라도 정보 유출을 통해 다른 시스템으로 피해가 이어질 수 있습니다.

가장 안전한 대응은 외부 명령어를 사용하지 않는 것

OS 명령어 삽입을 예방하는 가장 좋은 방법은 운영체제 명령어 호출 자체를 제거하는 것입니다.

예를 들어 파일 목록을 확인하기 위해 lsdir 명령어를 실행할 필요는 없습니다. 자바에서는 Files.list()와 같은 표준 API를 사용할 수 있습니다. 파일 복사, 압축 해제, 네트워크 연결 확인도 대부분 언어나 프레임워크에서 제공하는 기능으로 처리할 수 있습니다.

앞서 살펴본 연결 상태 확인 기능도 운영체제의 ping 명령어 대신 자바 API를 이용해 구현할 수 있습니다.

String host = request.getParameter("host");

InetAddress address = InetAddress.getByName(host);
boolean reachable = address.isReachable(3000);

이 방식에서는 사용자 입력값이 셸 명령어로 전달되지 않습니다. 따라서 셸 문법을 이용해 명령 구조를 변경하는 위험을 근본적으로 줄일 수 있습니다.

물론 InetAddress.isReachable()의 실제 동작은 운영체제 환경과 권한에 따라 달라질 수 있습니다. 여기서 중요한 점은 기능을 구현할 때 외부 명령어보다 언어가 제공하는 안전한 API를 먼저 검토해야 한다는 것입니다.

외부 프로그램 실행이 꼭 필요한 경우

이미지 변환 프로그램이나 백업 도구처럼 외부 프로그램 실행을 피하기 어려운 경우도 있습니다. 이때는 명령어 전체를 하나의 문자열로 만들지 말고 실행 파일과 각 인수를 분리해야 합니다.

String host = request.getParameter("host");

if (host == null ||
    !host.matches("^[a-zA-Z0-9.-]{1,253}$") ||
    host.startsWith("-")) {
    throw new IllegalArgumentException("허용되지 않은 입력값입니다.");
}

ProcessBuilder builder = new ProcessBuilder(
    "/usr/bin/ping",
    "-c",
    "1",
    host
);

Process process = builder.start();

이 코드는 셸을 호출하지 않고 실행 파일과 인수를 각각 전달합니다. 또한 입력값의 형식과 길이를 제한하고, 옵션으로 해석될 수 있는 값도 차단합니다.

다만 이 예시 역시 모든 상황에 그대로 적용할 수 있는 완성된 보안 코드라는 의미는 아닙니다. 실제 서비스에서는 허용할 도메인 형식, IP 주소 형식, 운영체제 종류, 실행 파일 특성을 추가로 검토해야 합니다.

허용 목록 방식으로 입력값 제한하기

입력값 검증은 가능하면 허용 목록 방식으로 적용해야 합니다.

예를 들어 사용자에게 백업 유형을 선택하게 하는 기능이라면 입력값을 명령어에 직접 넣기보다 서버 내부에서 미리 정해둔 값과 연결하는 방식이 안전합니다.

String type = request.getParameter("type");

Map<String, String> allowedTypes = Map.of(
    "daily", "/opt/scripts/daily-backup",
    "weekly", "/opt/scripts/weekly-backup"
);

String executable = allowedTypes.get(type);

if (executable == null) {
    throw new IllegalArgumentException("허용되지 않은 작업입니다.");
}

new ProcessBuilder(executable).start();

사용자는 daily 또는 weekly라는 정해진 값만 선택할 수 있고, 실제 실행 경로는 서버 내부에서 결정합니다. 외부 입력값이 실행 파일이나 명령어 문장으로 직접 사용되지 않는다는 점이 핵심입니다.

특정 특수문자만 제거하는 차단 목록 방식은 권장하기 어렵습니다. 운영체제와 셸 종류에 따라 특별한 의미를 갖는 문자가 다르고, 인코딩이나 공백 처리 방식으로 검증이 우회될 수도 있기 때문입니다.

셸 이스케이프만으로 충분하지 않은 이유

일부 개발자는 위험한 문자 앞에 이스케이프 문자를 추가하면 문제가 해결된다고 생각합니다. 하지만 셸 이스케이프 방식은 운영체제, 셸 종류, 따옴표 사용 위치에 따라 동작이 달라질 수 있습니다.

특히 하나의 프로그램이 리눅스와 윈도우에서 모두 실행된다면 같은 문자열도 서로 다르게 해석될 수 있습니다. 코드 수정 과정에서 따옴표 위치가 바뀌거나 새로운 인수가 추가되면서 기존 방어가 무력화될 가능성도 있습니다.

따라서 대응 우선순위는 다음과 같이 잡는 것이 좋습니다.

  1. 운영체제 명령어를 안전한 내부 API로 대체합니다.
  2. 외부 실행이 필요하면 셸 호출을 제거합니다.
  3. 실행 파일과 인수를 각각 분리합니다.
  4. 입력값을 허용 목록으로 제한합니다.
  5. 실행 계정의 권한과 접근 범위를 최소화합니다.

이스케이프 처리는 다른 방법을 적용하기 어려운 제한적인 상황에서 보조 수단으로 검토해야 합니다.

최소 권한과 실행환경 제한

안전하게 코드를 작성하더라도 운영환경에서는 추가적인 보호가 필요합니다.

웹 애플리케이션을 root나 관리자 권한으로 실행하면 취약점 발생 시 피해가 운영체제 전체로 확대될 수 있습니다. 서비스 전용 계정을 만들고 필요한 디렉터리와 파일에만 접근할 수 있도록 권한을 제한해야 합니다.

외부 프로그램을 실행해야 한다면 다음 사항도 확인하는 것이 좋습니다.

  • 실행 파일을 절대경로로 지정했는가?
  • 실행 가능한 프로그램을 사전에 제한했는가?
  • 불필요한 환경변수를 전달하고 있지 않은가?
  • 작업 디렉터리를 안전한 위치로 고정했는가?
  • 실행시간 제한이 설정돼 있는가?
  • 표준 출력과 오류 출력의 크기를 제한하는가?
  • 서비스 계정이 중요 설정 파일에 접근할 수 있는가?
  • 컨테이너나 샌드박스로 실행 범위를 분리할 수 있는가?

외부 프로세스가 종료되지 않고 계속 실행되면 서버 자원이 고갈될 수 있습니다. 명령어 삽입 방지와 별개로 실행시간 제한과 프로세스 종료 처리도 필요합니다.

오류 메시지와 로그 관리

운영체제 명령어 실행 과정에서 발생한 오류를 사용자 화면에 그대로 출력해서는 안 됩니다.

오류 메시지에는 서버의 파일 경로, 운영체제 종류, 실행 명령어, 계정명과 같은 정보가 포함될 수 있습니다. 이러한 정보는 공격자가 서버 구조를 파악하는 데 활용할 수 있습니다.

사용자에게는 “요청을 처리할 수 없습니다”와 같은 일반적인 메시지만 보여주고, 상세 오류는 접근이 제한된 서버 로그에 기록하는 것이 좋습니다.

로그에는 다음과 같은 내용을 남길 수 있습니다.

  • 기능을 요청한 사용자와 시간
  • 실행한 작업의 종류
  • 허용 목록 검증 결과
  • 외부 프로그램의 종료 코드
  • 실행시간 초과 여부
  • 반복적으로 거부된 입력 요청

단, 비밀번호나 인증 토큰 같은 민감정보를 로그에 그대로 기록하지 않도록 주의해야 합니다.

OS 명령어 삽입 취약점 점검 방법

소스 코드를 점검할 때는 먼저 운영체제 명령을 실행하는 함수를 찾습니다.

자바에서는 Runtime.exec()ProcessBuilder, 파이썬에서는 os.system(), subprocess의 셸 실행 옵션, PHP에서는 system(), exec(), shell_exec() 등이 주요 점검 대상입니다. Node.js에서는 child_process.exec()처럼 셸을 사용하는 기능을 확인해야 합니다.

함수를 찾은 뒤에는 외부 입력값이 어디에서 들어와 어떤 경로로 전달되는지 추적합니다.

HTTP 파라미터
→ 변수 저장
→ 문자열 결합
→ 명령 실행 함수

실행 함수만 보고 취약 여부를 판단하기보다 입력값의 출처, 검증 과정, 셸 호출 여부, 실행 권한을 함께 확인해야 정확한 진단이 가능합니다.

점검 시 확인할 항목은 다음과 같습니다.

  • 외부 입력값이 명령어에 직접 포함되는가?
  • sh -c 또는 cmd.exe /c를 사용하고 있는가?
  • 실행 파일과 인수가 하나의 문자열로 결합되는가?
  • 허용 목록 방식의 입력값 검증이 적용됐는가?
  • 입력값이 실행 파일 경로나 옵션을 결정하는가?
  • 외부 명령어를 표준 API로 대체할 수 있는가?
  • 애플리케이션이 과도한 권한으로 실행되는가?
  • 명령 실행 결과와 오류가 사용자에게 노출되는가?
  • 실행시간과 출력 크기를 제한하고 있는가?

취약점 테스트는 반드시 자신이 소유하거나 명시적으로 허가받은 시스템에서만 수행해야 합니다. 운영 중인 외부 시스템에 임의의 명령을 입력하는 행위는 서비스 장애나 법적 문제로 이어질 수 있습니다.

자주 묻는 질문

특수문자를 제거하면 OS 명령어 삽입을 막을 수 있나요?

완전한 대응 방법으로 보기 어렵습니다. 운영체제와 셸에 따라 명령어 해석 방식이 다르고, 차단 목록에 빠진 문자나 새로운 우회 방식이 존재할 수 있습니다. 명령 실행을 제거하거나 셸을 사용하지 않고 인수를 분리하는 방법이 우선입니다.

ProcessBuilder는 안전한가요?

실행 파일과 인수를 분리하고 셸을 호출하지 않는다면 위험을 줄일 수 있습니다. 그러나 외부 입력값으로 실행 파일을 결정하거나 sh -c에 문자열을 전달하면 ProcessBuilder를 사용해도 취약할 수 있습니다.

입력값을 정규표현식으로 검사하면 충분한가요?

입력값 검증은 중요한 방어 수단이지만 단독으로 의존해서는 안 됩니다. 외부 명령 제거, 셸 미사용, 인수 분리, 최소 권한을 함께 적용해야 합니다.

OS 명령어 삽입과 코드 인젝션은 같은 취약점인가요?

관련은 있지만 실행 대상이 다릅니다. OS 명령어 삽입은 운영체제나 셸이 명령을 실행하도록 만드는 취약점이고, 코드 인젝션은 프로그래밍 언어의 코드나 표현식이 동적으로 해석되는 과정에서 발생합니다.

마무리

OS 명령어 삽입 취약점은 외부 입력값이 운영체제 명령어의 일부로 해석될 때 발생합니다. 단순한 문자열 결합 한 줄이 서버 내부 파일 노출이나 시스템 명령 실행으로 이어질 수 있어 위험도가 높습니다.

가장 안전한 대응 방법은 외부 명령어 호출을 제거하고 언어나 프레임워크가 제공하는 API로 기능을 구현하는 것입니다. 외부 프로그램이 꼭 필요하다면 셸을 거치지 않고 실행 파일과 인수를 분리하며, 입력값은 허용 목록으로 엄격하게 제한해야 합니다.

여기에 서비스 계정의 최소 권한, 실행시간 제한, 오류 메시지 관리, 로그 모니터링을 함께 적용하면 취약점이 발생했을 때의 피해 범위도 줄일 수 있습니다.

코드를 점검할 때는 실행 함수의 이름만 찾는 데서 끝내지 말고, 외부 입력값이 명령 실행 지점까지 어떤 과정을 거쳐 도달하는지 확인해야 합니다. 이 데이터 흐름을 따라가는 습관이 OS 명령어 삽입 취약점을 발견하는 가장 현실적인 방법입니다.

핵심 요약: 외부 입력값을 운영체제 명령어에 직접 연결하지 말고, 가능한 경우 안전한 내부 API로 대체해야 합니다. 외부 실행이 필요하면 셸을 제거하고 실행 파일과 인수를 분리한 뒤 허용 목록 검증과 최소 권한을 적용해야 합니다.

핵심 키워드: OS 명령어 삽입, 명령어 인젝션, OS Command Injection, Runtime.exec 취약점, ProcessBuilder 보안, 시큐어 코딩

태그: 웹 보안, 웹 취약점, OS 명령어 삽입, 명령어 인젝션, 자바 보안, 시큐어 코딩

댓글 남기기