OS 커맨드 인젝션: 이미지 변환·PDF 생성 코드의 원인과 방어법
OS 커맨드 인젝션이 ImageMagick·ffmpeg·wkhtmltopdf 호출 코드에서 생기는 원인을 설명하고, exec·shell=True 코드를 인자 배열 방식으로 고친 예시와 점검 체크리스트를 함께 정리했습니다.
OS 커맨드 인젝션은 서버가 외부 프로그램을 실행할 때 사용자 입력이 셸 명령 문자열에 섞여 들어가 개발자가 의도하지 않은 명령까지 실행되는 취약점입니다. 스타트업 코드에서 가장 흔한 출발점은 ping 테스트 페이지가 아닙니다. 썸네일을 만드는 convert, 영상을 인코딩하는 ffmpeg, 견적서를 PDF로 뽑는 wkhtmltopdf를 child_process.exec()나 subprocess.run(..., shell=True)로 부르는 코드입니다. 파일명, 해상도, 포맷, URL 가운데 하나라도 사용자에게서 온다면 점검해야 합니다.
해결 원칙은 단순합니다. 셸을 거치지 말고, 실행 파일과 인자를 배열로 따로 넘기세요. 여기에 인자 주입(-로 시작하는 값) 차단, 서버가 정한 파일 경로 사용, 도구별 보안 설정, 실행 환경 격리를 더하면 됩니다. 아래에서 취약한 코드와 고친 코드를 나란히 놓고 설명합니다.
이 글은 Node.js와 Python 백엔드를 기준으로 썼지만 원리는 Java의 Runtime.exec(String), PHP의 shell_exec, Go의 exec.Command("sh", "-c", ...)에도 똑같이 적용됩니다.
왜 문자열 명령이 위험한가: 셸이 끼어드는 순간
exec("convert " + filename + " out.png")처럼 명령을 문자열 하나로 만들면, Node.js는 이 문자열을 /bin/sh -c에 넘깁니다. 셸은 ;, &&, |, `, $(), 리다이렉션 같은 메타문자를 해석합니다. 그래서 파일명에 이런 문자가 들어 있으면 그 뒤가 별도 명령으로 실행됩니다. Node.js 공식 문서도 exec 계열 함수에 검증되지 않은 사용자 입력을 넘기지 말라고 경고합니다.
반대로 execFile("convert", [filename, "out.png"])처럼 인자를 배열로 넘기면 셸이 실행되지 않습니다. 운영체제가 convert 프로그램을 직접 실행하고, filename은 그 안에 무엇이 들어 있든 인자 하나로만 전달됩니다. OWASP OS Command Injection Defense Cheat Sheet도 1순위 방어로 "OS 명령을 아예 쓰지 않고 라이브러리 함수 사용"을, 그다음으로 "명령과 인자를 분리해 전달하는 구조"를 권합니다.
판단 기준을 한 줄로 정리하면 이렇습니다.
- 문자열 하나로 명령을 만든다 → 셸이 해석한다 → 취약할 수 있다
- 실행 파일 + 인자 배열로 넘긴다 → 셸이 없다 → 명령 주입은 막히지만 인자 주입은 따로 봐야 한다
Node.js: exec를 execFile·spawn으로 바꾸기
취약한 코드: 썸네일 생성
// ❌ 취약: 사용자 파일명과 크기가 셸 문자열에 그대로 들어간다
const { exec } = require('child_process');
app.post('/thumbnail', (req, res) => {
const { filename, width } = req.body; // 예: "photo.jpg", "300"
const cmd = `convert uploads/${filename} -resize ${width}x thumbs/${filename}.png`;
exec(cmd, (err) => {
if (err) return res.status(500).end();
res.json({ ok: true });
});
});
filename이나 width에 셸 메타문자가 들어가면 convert와 상관없는 명령이 이어서 실행될 수 있습니다. 템플릿 리터럴로 깔끔하게 써도 결국 문자열 하나라는 점은 같습니다.
고친 코드
// ✅ 셸 없이 실행 + 입력 검증 + 서버가 정한 경로
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const path = require('node:path');
const execFileAsync = promisify(execFile);
const ALLOWED_WIDTHS = new Set([150, 300, 600]);
app.post('/thumbnail', async (req, res) => {
const width = Number(req.body.width);
if (!ALLOWED_WIDTHS.has(width)) return res.status(400).end();
// 사용자 파일명 대신 업로드 시 DB에 저장한 UUID로 경로를 찾는다
const file = await db.files.findOwned(req.user.id, req.body.fileId);
if (!file) return res.status(404).end();
const input = path.join('/data/uploads', `${file.uuid}`);
const output = path.join('/data/thumbs', `${file.uuid}.png`);
await execFileAsync('/usr/bin/convert', [
`jpg:${input}`, // 입력 포맷을 명시해 확장자·내용 기반 추측을 막는다
'-resize', `${width}x`,
`png:${output}`,
], { timeout: 10_000, maxBuffer: 1024 * 1024 });
res.json({ ok: true });
});
바뀐 점은 네 가지입니다.
exec→execFile: 셸을 거치지 않습니다.spawn도 같습니다.- 숫자 값은 허용 목록으로 검증합니다. 자유 입력을 받을 이유가 없는 값입니다.
- 사용자가 보낸 파일명을 경로로 쓰지 않습니다. 업로드할 때 서버가 만든 UUID를 씁니다. 그러면 경로 조작과
-로 시작하는 파일명 문제가 함께 사라집니다. 업로드 단계 설계는 파일 업로드 취약점 글을 참고하세요. - 실행 파일은 절대 경로로 고정하고
timeout을 둡니다.
주의: execFile에 shell: true를 붙이면 원점
// ❌ 배열을 넘겨도 shell: true면 인자가 공백으로 이어 붙여져 셸이 해석한다
spawn('convert', [input, output], { shell: true });
Node.js는 shell: true와 인자 배열을 함께 넘기는 패턴을 DEP0190으로 지정해 폐기 예정으로 두고 있습니다. 인자가 이스케이프되지 않고 공백으로 이어 붙여지기 때문입니다. 코드 리뷰에서 shell: true가 보이면 왜 필요한지부터 확인하세요.
Python: shell=True를 리스트 인자로 바꾸기
취약한 코드: 영상 변환 워커
# ❌ 취약: f-string으로 만든 명령을 셸에 넘긴다
import subprocess
def transcode(src_name: str, bitrate: str):
cmd = f"ffmpeg -i /data/in/{src_name} -b:v {bitrate} /data/out/{src_name}.mp4"
subprocess.run(cmd, shell=True, check=True)
os.system(cmd)이나 os.popen(cmd)도 똑같이 셸을 거칩니다.
고친 코드
# ✅ 리스트 인자, shell=False(기본값), 입력 검증, 프로토콜 제한
import re
import subprocess
from pathlib import Path
BITRATE_RE = re.compile(r"^[0-9]{3,5}k$") # 예: 800k, 2500k
IN_DIR = Path("/data/in")
OUT_DIR = Path("/data/out")
def transcode(file_uuid: str, bitrate: str) -> None:
if not BITRATE_RE.fullmatch(bitrate):
raise ValueError("invalid bitrate")
src = IN_DIR / file_uuid # 서버가 만든 UUID만 사용
dst = OUT_DIR / f"{file_uuid}.mp4"
subprocess.run(
[
"/usr/bin/ffmpeg",
"-nostdin",
"-protocol_whitelist", "file", # 로컬 파일 외 프로토콜 차단
"-i", str(src),
"-b:v", bitrate,
"-y", str(dst),
],
check=True,
timeout=300,
capture_output=True,
)
Python 공식 문서의 Security Considerations 항목에 따르면 subprocess는 셸을 암묵적으로 호출하지 않습니다. 셸을 명시적으로 켜면 공백과 메타문자를 올바르게 따옴표 처리할 책임은 호출하는 코드에 있습니다. shlex.quote()는 셸이 꼭 필요할 때 쓰는 마지막 수단입니다. 기본값은 리스트 인자여야 합니다. shlex.split()로 쪼갠 뒤에도 shell=True를 그대로 두는 것은 고친 것이 아닙니다.
인자 배열로도 막히지 않는 것: 인자 주입과 도구 자체 기능
셸을 없애면 ;로 명령을 이어 붙이는 공격은 막힙니다. 그래도 실무에서 자주 놓치는 문제가 두 가지 남습니다.
1) 인자 주입: 값이 옵션으로 해석되는 경우
사용자 값이 -로 시작하면 대상 프로그램이 이를 파일명이 아닌 옵션으로 읽을 수 있습니다. 셸이 없어도 생기는 문제입니다. OWASP 치트시트는 옵션 끝을 알리는 -- 구분자를 쓰라고 권합니다. 다만 ImageMagick·ffmpeg처럼 --를 일관되게 지원하지 않는 도구도 있습니다. 가장 확실한 방법은 사용자 문자열을 경로나 옵션 값으로 직접 쓰지 않는 것입니다. 위 예시처럼 UUID 경로를 쓰고, 포맷·크기·품질은 허용 목록으로 받으세요.
2) 도구가 원래 가진 강력한 기능
| 도구 | 주의할 기능 | 방어 |
|---|---|---|
| ImageMagick | 파일 내용·접두사로 코더(포맷 처리기)를 고름. 외부 위임 프로그램, URL·스크립트 계열 코더 | policy.xml로 불필요한 코더 비활성화, 입력 포맷 접두사 명시 |
| ffmpeg | 입력에 http, concat 등 다양한 프로토콜 사용 가능 | -protocol_whitelist file, 사용자 URL을 -i에 직접 넣지 않기 |
| wkhtmltopdf | HTML 안의 JS·로컬 파일·내부망 요청 | 사용자 HTML 직접 렌더링 금지, 로컬 파일 접근 비활성화, 대체 도구 검토 |
ImageMagick은 7.1.1-16부터 open·limited·secure·websafe 같은 대표 정책을 함께 배포합니다. 기본값은 가장 느슨한 open이므로, 공개 서비스에서는 secure나 websafe 계열 정책을 기준으로 필요한 포맷만 열어 두는 편이 안전합니다. 적용된 정책은 identify -list policy로 확인할 수 있습니다.
<!-- policy.xml 예시: 필요한 포맷만 허용하는 방식 -->
<policymap>
<policy domain="delegate" rights="none" pattern="*" />
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read|write" pattern="{JPEG,PNG,WEBP,GIF}" />
<policy domain="resource" name="memory" value="256MiB"/>
<policy domain="resource" name="time" value="30"/>
</policymap>
wkhtmltopdf는 GitHub 저장소가 2023년 1월에 보관(archived) 처리되어 더 이상 유지보수되지 않습니다. 공식 다운로드 페이지에도 신뢰할 수 없는 HTML에는 쓰지 말라는 경고가 있습니다. 사용자가 입력한 내용이 HTML 템플릿에 들어간다면 반드시 이스케이프하세요. 장기적으로는 유지보수되는 헤드리스 브라우저 기반 렌더러로 옮기는 것을 검토하세요. 렌더러가 내부망 주소에 요청을 보내는 문제는 커맨드 인젝션이 아니라 SSRF에 가깝습니다. 이 부분은 SSRF 취약점 글에서 다뤘습니다.
프로세스를 아예 안 쓰는 선택지와 실행 환경 격리
OWASP가 1순위로 권하는 방어는 외부 명령을 호출하지 않는 것입니다. 이미지 리사이즈와 포맷 변환만 한다면 Node.js에서는 libvips 기반 sharp, Python에서는 Pillow 같은 라이브러리로 대부분 해결됩니다. 라이브러리에도 파서 취약점은 있을 수 있으므로 의존성 버전 관리는 계속 필요합니다.
외부 명령이 꼭 필요하다면 뚫렸을 때의 피해 범위를 줄이세요.
- 별도 워커로 분리: API 서버가 아니라 큐를 소비하는 변환 전용 컨테이너에서 실행
- 비루트 사용자: 컨테이너 안에서도 root로 돌리지 않기
- 네트워크 차단: 변환 워커는 외부·내부망으로 나가는 트래픽이 필요 없는 경우가 대부분
- 읽기 전용 파일시스템 + 작업 디렉터리만 쓰기 허용
- 시크릿 미주입: DB 비밀번호, 클라우드 키를 변환 워커 환경변수에 넣지 않기
- 시간·메모리 제한:
timeout, 컨테이너 리소스 제한, ImageMagickresource정책
우리 코드 점검 체크리스트
먼저 저장소에서 외부 명령 호출 지점을 찾습니다.
# Node.js
grep -rnE "child_process|\bexec\(|execSync|spawn\(|shell:\s*true" src/
# Python
grep -rnE "shell=True|os\.system|os\.popen|subprocess\." app/
# 래퍼 라이브러리 사용 여부(내부에서 셸 문자열을 만드는지 확인 필요)
grep -rnE "imagemagick|gm\(|fluent-ffmpeg|wkhtmltopdf|pdfkit" .
찾은 호출 지점마다 아래를 확인하세요.
- 명령이 문자열 하나로 만들어지는가, 배열로 넘어가는가
shell: true/shell=True가 있는가, 있다면 정말 필요한가- 인자 가운데 사용자 입력에서 온 값이 있는가(파일명, 포맷, 크기, URL, 워터마크 문구)
- 파일 경로는 서버가 만든 이름인가
- 숫자·포맷 값은 허용 목록이나 엄격한 정규식으로 검증하는가
-로 시작하는 값이 옵션으로 해석될 여지가 없는가- ImageMagick
policy.xml, ffmpeg 프로토콜 제한 같은 도구별 설정이 있는가 - 래퍼 라이브러리가 내부에서 셸을 쓰는지 소스나 문서로 확인했는가
- 실행 환경이 비루트·네트워크 제한·시크릿 없음 상태인가
- 타임아웃과 리소스 제한이 있는가
정적 분석 도구(Semgrep, Bandit, ESLint 보안 플러그인)는 1~2번을 잘 잡습니다. 하지만 값이 어디서 왔는지, 래퍼가 내부에서 셸을 쓰는지는 사람이 흐름을 따라가며 봐야 하는 경우가 많습니다. 의존성 쪽 점검은 오픈소스 라이브러리 취약점 점검 글과 함께 보시면 좋습니다.
자주 묻는 질문
입력값에서 ; | & 같은 특수문자를 제거하면 충분하지 않나요?
권하지 않습니다. 셸마다 해석하는 문자가 다르고, 줄바꿈이나 $() 같은 경우를 빠뜨리기 쉽습니다. 거부 목록은 공격 탐지에는 쓸모가 있지만 주된 방어가 되기는 어렵습니다. 인자 배열로 바꾸고, 값은 허용 목록으로 검증하는 것이 근본적인 해결입니다.
관리자만 쓰는 내부 기능인데도 고쳐야 하나요?
우선순위는 낮출 수 있어도 고치는 편이 좋습니다. 관리자 계정이 탈취되거나 CSRF 같은 다른 취약점과 엮이면 서버 명령 실행으로 이어질 수 있습니다. 대부분 exec를 execFile로 바꾸는 정도라 수정 비용도 작습니다.
파이프(|)나 와일드카드(*)가 필요해서 셸을 써야 하는데요?
대부분 셸 없이 구현할 수 있습니다. 파이프는 프로세스 두 개를 spawn/Popen으로 띄워 stdout과 stdin을 연결하고, 와일드카드는 언어의 glob으로 목록을 만든 뒤 배열 인자로 넘기면 됩니다. 정말 셸이 필요하다면 명령 문자열은 상수로 고정하세요. 사용자 값은 shlex.quote() 등으로 처리한 뒤 위치 인자로만 넘겨야 합니다.
정리
- OS 커맨드 인젝션의 핵심 원인은 사용자 입력이 섞인 명령 문자열을 셸이 해석하는 것입니다.
exec,shell=True,os.system이 1차 점검 대상입니다. - 실행 파일과 인자를 배열로 넘기고, 파일 경로는 서버가 정하고, 나머지 값은 허용 목록으로 검증하세요.
- 셸을 없앤 뒤에도 인자 주입과 ImageMagick·ffmpeg·wkhtmltopdf 자체의 기능은 도구별 설정과 실행 환경 격리로 따로 막아야 합니다.
- 직접 점검이 어렵다면 무료 분석 신청으로 외부 명령 호출 지점을 함께 살펴볼 수 있습니다.
참고 자료
함께 읽으면 좋은 글
FastAPI 보안 가이드: 배포 전 점검할 설정 실수 7가지
FastAPI 보안 점검에서 자주 나오는 7가지를 정리했습니다. /docs 노출, Depends 인증 누락, 응답 모델 미지정, CORS, uvicorn 프록시 헤더, 의존성 취약점을 확인 명령과 수정 코드로 다룹니다.
오픈 리다이렉트 취약점: OAuth 토큰 탈취 경로와 검증법
오픈 리다이렉트 취약점이 피싱을 넘어 OAuth 인가 코드·토큰 탈취로 이어지는 원리, startsWith·정규식 검증이 우회되는 패턴, returnUrl을 허용 목록으로 검증하는 코드와 점검 체크리스트를 정리했습니다.
Express 보안 설정 가이드: 배포 전 고칠 미들웨어 실수 7가지
Express 보안 설정에서 자주 틀리는 trust proxy, 미들웨어 순서, 본문 크기 제한, 에러 스택 노출 등 7가지를 잘못된 코드와 고친 코드로 비교해 배포 전 점검 기준을 제시합니다.