이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
JDK 21 업그레이드 작업 도중 SonarQube Command Injection 룰 S2076이 12건 떴다. 전부 야간 배치에서 외부 시스템과 파일을 주고받기 위해 openssl, sftp, cp 같은 셸 명령을 호출하는 코드였다. 금융권 레거시에서는 인증서 변환이나 EDI 파일 이관용으로 흔히 쓰인다.
로직 변경 없이 SonarQube Command Injection을 잡으려고 시도한 3가지와 실제로 통과시킨 해결 코드를 정리한다.

레거시 배치에서 발견된 패턴
가장 많이 본 패턴은 Runtime.getRuntime().exec("sh -c ...")다. 이렇게 쓰면 인자 안에 변수를 문자열로 합쳐 넣고, 그 안에 외부 입력이 섞이는 순간 끝난다.
// 거래처에서 받은 인증서를 PEM으로 변환
String keyPath = props.getProperty("partner.key.path"); // 외부 시스템에서 내려준 경로
String outPath = "/data/keys/" + partnerId + ".pem";
Runtime.getRuntime().exec(
"sh -c \"openssl rsa -in " + keyPath + " -out " + outPath + "\""
);
partnerId나 keyPath 어디 한 곳이라도 외부 입력에서 오면, ; rm -rf / 같은 명령이 그대로 합쳐진다. SonarQube Command Injection 룰은 이런 흐름을 그대로 잡아낸다.
시도 1. 메타문자 블랙리스트 — 실패
가장 먼저 떠올린 게 위험한 문자를 거르는 sanitize였다.
private static String sanitize(String s) {
return s.replaceAll("[;&|`$\\\\]", "");
}
String safeKey = sanitize(keyPath);
Runtime.getRuntime().exec("sh -c \"openssl rsa -in " + safeKey + " ...\"");
S2076는 그대로 떠 있었다. 이유는 두 가지다. 첫째, $()나 줄바꿈, 공백 트릭 같은 우회 패턴이 너무 많다. 둘째, SonarQube는 셸로 들어가는 입력 흐름 자체를 의심한다. 알려진 sanitizer가 아니면 인정해주지 않는다.
시도 2. 정규식 화이트리스트 — 부분 실패
입력을 강하게 제한해 봤다.
private static final Pattern SAFE_PATH = Pattern.compile("^[a-zA-Z0-9._/-]+$");
if (!SAFE_PATH.matcher(keyPath).matches()) {
throw new IllegalArgumentException("invalid path");
}
Runtime.getRuntime().exec("sh -c \"openssl rsa -in " + keyPath + " ...\"");
실제 공격 가능성은 줄어들지만, S2076은 여전히 남는다. 룰이 보는 건 입력이 아니라 셸 인터프리터에 문자열을 넘긴다는 사실 자체다. sh -c "..." 형태로 한 덩어리 문자열을 던지는 한, 화이트리스트를 끼워 넣어도 안 사라진다.
시도 3. Runtime.exec(String[]) — 부분 통과
인자를 배열로 분리하면 셸을 거치지 않는다는 걸 알고 바꿔봤다.
String[] cmd = {
"openssl", "rsa",
"-in", keyPath,
"-out", outPath
};
Runtime.getRuntime().exec(cmd);
이 시점에서 S2076은 사라졌다. 하지만 같은 메서드 안에서 다른 호출이 여전히 String 한 덩어리로 exec를 부르고 있으면 그쪽은 그대로 뜬다. 그리고 Runtime.exec는 표준 출력/에러 스트림을 직접 비워주지 않으면 자식 프로세스가 hang에 걸리는 고질적 문제도 있다. 통과는 됐지만 운영상 권장 형태가 아니다.
최종 해결 — ProcessBuilder + 절대경로
운영까지 고려해서 정착시킨 패턴은 ProcessBuilder다.
private static final Path OPENSSL = Paths.get("/usr/bin/openssl");
private static final Path ALLOWED_DIR = Paths.get("/data/inbox").toAbsolutePath().normalize();
public void convertKey(Path keyPath, Path outPath) throws IOException, InterruptedException {
Path normalized = keyPath.toAbsolutePath().normalize();
if (!normalized.startsWith(ALLOWED_DIR)) {
throw new IllegalArgumentException("path not allowed");
}
ProcessBuilder pb = new ProcessBuilder(
OPENSSL.toString(), "rsa",
"-in", keyPath.toString(),
"-out", outPath.toString()
);
pb.redirectErrorStream(true);
Process p = pb.start();
try (var in = p.getInputStream()) {
in.transferTo(OutputStream.nullOutputStream());
}
int code = p.waitFor();
if (code != 0) {
throw new IOException("openssl exit=" + code);
}
}
S2076 통과의 핵심은 세 가지다.
- 셸을 거치지 않는다. ProcessBuilder는 인자 배열을 그대로 OS에 전달하므로
;이나$()가 해석되지 않는다. - 실행 파일은 절대경로 상수로 박는다.
PATH환경변수에 의존하지 않으니 PATH 변조 공격이 차단되고, SonarQube 룰도 의심을 거둔다. - 경로 인자는 prefix 검증으로 가둔다. 외부 입력으로 들어온
keyPath를 절대경로로 정규화한 뒤 허용 디렉터리 안에 있는지 확인한다.
표준 출력 처리와 waitFor까지 같이 챙겨야 운영 중 hang을 피할 수 있다. SonarQube Command Injection 룰은 통과했지만, 실제 사고를 막는 건 이 마지막 두 줄이다.
정리
SonarQube Command Injection 대응의 본질은 “셸을 거치지 않는다”는 한 줄로 요약된다. sh -c 형태로 문자열을 합쳐 넘기는 순간 어떤 sanitize도 룰을 통과시키지 못한다. ProcessBuilder + 인자 배열 + 절대경로 + 경로 prefix 검증, 이 조합이 정답이었다.
이전 편 SonarQube SQL Injection (S3649)도 본질은 같았다. 외부 입력을 인터프리터에 문자열로 합치지 말 것. 다음 편은 출력 쪽 인터프리터 문제인 SonarQube XSS (S5131)를 다룬다.
룰 본문은 SonarSource 공식 룰 페이지(S2076)에서 확인할 수 있다.