infiltr8 Lab
Home/ CVE/ CI/CD·빌드 서버/ Jenkins CLI 인자 파서 임의 파일 읽기
CVE · CI/CD·빌드 서버 · RCE · CVE-2024-23897
심층 분석 연구 분석

CVE-2024-23897 (Jenkins CLI 임의 파일 읽기) 분석

Jenkins 내장 명령줄 인터페이스(CLI)의 인자 파서에서 발생한 CVSS 9.8 임의 파일 읽기 취약점(CVE-2024-23897)입니다. 인증 없이도 실행되는 두 명령과, 그 인자 파싱 단계에 기본으로 켜져 있던 '파일 대신 읽기' 기능이 만나는 지점을 실제 오픈소스 코드와 패치 커밋을 대조해 분석합니다.

이 글의 코드 발췌(CLICommand.java 패치 전/후, args4j의 CmdLineParser.java·ParserProperties.java)는 jenkinsci/jenkins와 kohsuke/args4j 오픈소스의 실제 소스를 고정 커밋 SHA 기준으로 직접 열어 대조했으며, 패치 diff(커밋 554f0378, 부모 de450967)도 GitHub API로 받은 실제 diff와 패치 전/후 파일을 각각 직접 비교해 확인했습니다. CVSS·CWE·발행일은 NVD API(JSON)에서, 취약 버전·안전 버전·연쇄 공격 시나리오·관련 플러그인 CVE 정보는 Jenkins 공식 보안 공지(2024-01-24)를 직접 열어 수집했습니다. 다만 '인자 매칭 실패 시 발생하는 예외 메시지가 CLI 클라이언트(HTTP/WebSocket)로 그대로 전달되어 파일 내용이 화면에 노출된다'는 전송 계층까지의 마지막 연결 고리는 이 세션에서 원격 CLI 프로토콜 처리 코드까지 직접 추적하지 않았고, 공식 공지문의 서술과 args4j의 예외 생성 코드(인자 값을 메시지에 담아 던짐)를 근거로 재구성했습니다 — 그 부분은 2차 분석 기반임을 밝힙니다.

infiltr8 team 직접 분석 #arbitrary-file-read#cli-parser#insecure-default
무엇Jenkins 내장 CLI의 인자 파서(args4j)가 기본으로 지원하는 '@파일' 확장 기능 때문에, 인증 없이도 Jenkins 컨트롤러의 임의 파일을 읽어낼 수 있는 취약점입니다.
원리인가 검사를 일부러 건너뛰도록 설계된 두 명령(help, who-am-i)도 인자 파싱 단계까지는 건너뛰지 못합니다. 그 파싱 단계에서 @로 시작하는 인자를 파일 내용으로 통째로 치환하는 기능이 기본으로 켜져 있었습니다.
핵심패치는 getCmdLineParser()가 args4j에 withAtSyntax(false)를 명시적으로 지시하도록 바꾼 것 — 코어의 딱 두 호출부만 고쳤고, 같은 라이브러리를 쓰는 다른 플러그인들은 각자 별도 CVE로 남았습니다.

01개요

Jenkins는 스크립트나 셸에서 서버를 조작할 수 있도록 내장 명령줄 인터페이스(CLI)를 제공합니다. 이 CLI가 명령 인자를 해석할 때 쓰는 args4j 라이브러리에는, 인자 앞에 @를 붙이면 그 뒤의 경로를 파일로 열어 내용을 그대로 인자 목록에 밀어 넣는 편의 기능(@-파일 확장)이 있습니다. 이 기능이 기본으로 켜진 채 Jenkins 2.441 이하·LTS 2.426.2 이하에 그대로 남아 있었고, 인증 없이 실행 가능한 CLI 명령을 통해 컨트롤러 파일시스템의 임의 파일을 읽어낼 수 있었습니다(CVE-2024-23897, Jenkins 자체 식별자 SECURITY-3314).

Jenkins 보안팀은 이 공지에서 "기존에는 임의 파일 읽기를 기밀성(confidentiality)에만 영향을 주는 문제로 평가해 왔지만, 이번 조사로 확인된 공격 경로들 때문에 앞으로 이런 유형의 취약점은 기밀성·무결성·가용성 세 지표 모두에 높은 점수를 매기겠다"고 명시했습니다. 실제로 이 파일 읽기 하나로 리소스 루트 URL·"Remember me" 쿠키·CSRF 크럼(crumb)·빌드 로그 저장 XSS 등을 통해 암호화 비밀키를 복원하고, 조건이 맞으면 최종적으로 원격 코드 실행(RCE)까지 이어지는 경로들을 공지문에서 직접 나열하고 있습니다 — 이 CVSS 9.8이라는 점수는 벤더 스스로 이 연쇄를 근거로 재산정한 것입니다.

CVSS: 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, NVD)

관련 식별자: CVE-2024-23897(SECURITY-3314, 이 글의 대상) · CVE-2024-23898(SECURITY-3315, CLI WebSocket 엔드포인트의 CSWSH — 같은 날 공지, 별개 취약점) · CVE-2024-23899(SECURITY-3319, Git server 플러그인의 같은 근본원인 변형) · CVE-2024-23904(SECURITY-3334, Log Command 플러그인의 같은 근본원인 변형, 공지 시점 미패치). 뒤 세 개는 §11에서 다룹니다.

02영향받는 버전

대상취약 버전안전 버전
Jenkins (weekly)2.441 이하2.442 이상
Jenkins LTS2.426.2 이하2.426.3 이상 (2.440.1 포함)

03배경 — 명령줄 파서에게 '@파일'은 오래된 편의 기능이다

Jenkins CLI는 java -jar jenkins-cli.jar로 로컬에서 실행하거나, HTTP·WebSocket 채널을 통해 컨트롤러와 통신하는 방식으로 씁니다. 어느 경로든 최종적으로 컨트롤러 쪽의 hudson.cli.CLICommand 클래스가 요청을 받아, 명령 이름에 맞는 커맨드 객체를 만들고 인자 문자열 배열을 넘겨줍니다.

이 인자 배열을 실제 자바 필드(@Option, @Argument 애너테이션이 붙은 값)로 채워 넣는 역할은 Jenkins 코드가 아니라 외부 라이브러리 args4j가 합니다. javacgcc 같은 전통적인 커맨드라인 도구들처럼, args4j도 인자 목록이 너무 길어질 때를 대비해 @경로 형태의 인자를 만나면 그 파일을 열어 줄 단위로 읽고, 그 내용을 원래 인자 목록 자리에 그대로 펼쳐 넣는 기능을 지원합니다.

여기서 짚어야 할 건 이 기능의 기본값입니다. args4j 2.33 기준 ParserProperties는 이 @-확장을 기본으로 켠 채 시작합니다 — 별도로 끄지 않으면 켜져 있다는 뜻입니다. 로컬 셸에서 신뢰된 사용자가 자기 자신을 위해 CLI 도구를 쓰는 상황을 전제로 하면 합리적인 기본값입니다. Jenkins의 CLI가 원격 HTTP/WebSocket 채널로도 열려 있어, 이 인자를 보내는 쪽이 '신뢰된 로컬 사용자'가 아닐 수 있다는 사실이 다음 절의 출발점입니다.

04취약점 발생 원인

이 취약점은 서로 다른 계층에 있는 세 가지 설계 판단이 순서대로 겹쳐 성립합니다. args4j의 파일 확장 로직(W2)과 Jenkins의 인가 우회 명령(W3)은 각각 따로 보면 합리적인 설계입니다. 문제는 Jenkins가 이 라이브러리를 원격 노출 컨텍스트로 가져오면서 기본값을 명시적으로 끄지 않았다는 것(W1)입니다.

1"라이브러리 기본값을 그대로 물려받았다"

핵심

getCmdLineParser()가 args4j의 단일-인자 생성자를 쓰는데, 이 생성자는 내부적으로 @-파일 확장이 켜진 기본 설정(ParserProperties.defaults())을 그대로 씁니다.

Jenkins의 모든 CLI 명령은 CLICommand#getCmdLineParser()를 통해 args4j의 CmdLineParser 인스턴스를 받습니다. 패치 전에는 이 메서드가 단순히 new CmdLineParser(this)만 호출했습니다.

protected CmdLineParser getCmdLineParser() { return new CmdLineParser(this); }

Jenkins 오픈소스 실제 소스 — CLICommand.java · getCmdLineParser() (패치 전, 커밋 de450967 = 패치 커밋 554f0378의 부모). 단일-인자 생성자가 args4j 기본 설정을 그대로 씁니다.

args4j 소스를 보면 이 단일-인자 생성자는 this(bean, ParserProperties.defaults())로 위임되고, ParserProperties는 private boolean atSyntax = true;로 @-확장을 기본으로 켜 둡니다(args4j 2.33, ParserProperties.java 19행). Jenkins 쪽에서 이 기본값을 명시적으로 끄는 코드는 어디에도 없었습니다.

왜 이렇게 됐나이 시점의 설계 관심사는 "어떤 커맨드 클래스든 args4j 파서를 하나 만들어 준다"는 편의였습니다. args4j가 어떤 기본값으로 동작하는지, 그 기본값이 네트워크 노출 환경에서도 안전한지는 이 메서드가 책임질 질문으로 다뤄지지 않았습니다.

2"파일 확장은 접근 통제 없이, 옵션 매칭보다 먼저 끝난다"

핵심

expandAtFiles()는 파일 존재 여부만 확인하고, 요청자가 이 파일을 읽어도 되는지는 전혀 묻지 않은 채 전체 내용을 인자 목록에 펼쳐 넣습니다.

args4j의 parseArgument()는 시작하자마자(parserProperties.getAtSyntax()가 참이면) expandAtFiles()를 호출해, 옵션·인자를 실제로 매칭하기 전에 @로 시작하는 모든 인자를 먼저 파일로 치환합니다.

private String[] expandAtFiles(String args[]) throws CmdLineException { List<String> result = new ArrayList<String>(); for (String arg : args) { if (arg.startsWith("@")) { File file = new File(arg.substring(1)); if (!file.exists()) throw new CmdLineException(this,Messages.NO_SUCH_FILE,file.getPath()); try { result.addAll(readAllLines(file)); } catch (IOException ex) { throw new CmdLineException(this, "Failed to parse "+file,ex); } } else { result.add(arg); } } return result.toArray(new String[result.size()]); }

args4j 오픈소스 실제 소스 — CmdLineParser.java · expandAtFiles() (태그 args4j-site-2.33, 커밋 07a34b7). 552행이 경로를 그대로 여는 지점, 556행이 접근 통제 없이 전체 내용을 읽는 지점입니다.

expandAtFiles() 내부는 단순합니다. @ 뒤의 문자열을 그대로 File 경로로 만들고, file.exists()만 확인한 뒤 readAllLines()로 파일 전체를 줄 단위 문자열 목록으로 읽어 인자 배열에 이어 붙입니다. "이 프로세스가 파일시스템에서 읽을 수 있는 파일인가"만 기준이고, "이 요청을 보낸 쪽이 이 파일을 읽어도 되는 사용자인가"라는 인가 기준은 이 함수 어디에도 없습니다.

왜 이렇게 됐나args4j는 로컬 커맨드라인 도구를 염두에 둔 범용 파서 라이브러리입니다. "이 프로세스를 실행하는 사람과 이 인자를 넣는 사람이 같다"는 전제 위에서는 파일 접근 통제를 파서 레벨에 둘 이유가 없습니다. Jenkins처럼 원격 사용자가 인자를 네트워크로 주입하는 상황은 이 라이브러리의 설계 범위 밖입니다.

3"인가 검사를 건너뛰는 명령도, 인자 파싱까지는 건너뛰지 못한다"

핵심

help·who-am-i 명령은 익명 사용자도 쓸 수 있도록 의도적으로 권한 확인을 생략하지만, 그다음 줄의 parseArgument() 호출은 이 분기와 무관하게 항상 실행됩니다.

CLICommand#main()은 명령을 실제로 실행하기 전에 Jenkins.get().checkPermission(Jenkins.READ)로 권한을 확인합니다. 단, 이 명령이 HelpCommand나 WhoAmICommand인 경우는 예외입니다 — "도움말을 보거나 내가 누구인지 확인하는 것 정도는 익명 사용자도 할 수 있어야 한다"는 합리적인 설계입니다.

old = sc.getAuthentication(); sc.setAuthentication(auth = getTransportAuthentication2()); if (!(this instanceof HelpCommand || this instanceof WhoAmICommand)) Jenkins.get().checkPermission(Jenkins.READ); p.parseArgument(args.toArray(new String[0]));

Jenkins 오픈소스 실제 소스 — CLICommand.java · main() 내부 인가 검사 분기(패치 전, 커밋 de450967). 246~248행이 핵심입니다.

그런데 이 인가 분기는 p.parseArgument(args.toArray(new String[0])) 호출 앞에 있을 뿐, 이 호출 자체를 막지는 않습니다. 즉 권한 확인을 건너뛴 익명 요청도 인자 파싱 단계까지는 그대로 도달하고, 그 파싱 단계 안에서 W1·W2가 발화합니다 — 명령이 실제로 무엇을 하는지(도움말을 출력하든, 사용자 이름을 알려주든)는 이 시점에서 아직 상관이 없습니다.

왜 이렇게 됐나권한 확인과 인자 파싱은 같은 메서드 안에서 순서대로 실행되는 별개의 단계입니다. "이 명령은 권한이 없어도 된다"는 판단이 "이 명령의 인자 파싱도 안전하다"는 판단을 자동으로 포함하지 않는데, 코드 구조상 그 둘이 같은 길목에 있다 보니 하나를 열어주면 다른 하나도 함께 열립니다.

분석 결론

무게 중심은 W1(Jenkins가 args4j 기본값을 옵트아웃하지 않은 것)입니다.

W2(args4j의 파일 확장 로직)는 그 자체로는 결함이 아닙니다 — 로컬 신뢰 모델에서는 합리적입니다. W3(help/who-am-i의 인가 우회)도 그 자체로는 의도된 UX입니다. 이 둘은 원래 서로 다른 문제를 풀기 위한 설계였고, W1이 없었다면 만날 일이 없었습니다.

한 문장으로 묶으면 이렇습니다. 로컬 신뢰 컨텍스트를 전제로 설계된 라이브러리 기본값을, 원격 노출 컨텍스트로 가져오면서 명시적으로 다시 검토하지 않았습니다. 그 틈에서 '권한 없이 실행되는 경로'와 '파일을 대신 읽어주는 기능'이 우연히 만났습니다.

05공격 과정

5.1 공격 과정 요약

공격 흐름도
공격 흐름도 (직접 작성)

5.2 상세 공격 과정

예를 들어 공격자가 인증 없이 who-am-i 명령을 호출하면서 인자로 @/path/to/file(플레이스홀더, 실제 절대경로 자리)을 넘긴다고 가정합니다. WhoAmICommand는 이 인가 분기에서 권한 확인을 건너뛰므로, 요청은 곧바로 parseArgument()까지 도달합니다.

expandAtFiles()는 이 인자가 @로 시작하는 것을 보고, 뒤의 경로를 그대로 파일로 열어 각 줄을 별도 인자로 치환합니다. who-am-i는 원래 위치 인자를 받지 않는 명령이므로, 치환된 첫 줄이 곧바로 "이 명령은 이런 인자를 허용하지 않는다" 류의 CmdLineException을 유발하고, 그 예외 메시지 안에 매칭에 실패한 값 — 즉 파일의 첫 줄 — 이 그대로 담깁니다.

이 시점부터는 어떤 CLI 명령을 골랐는지, 그 명령이 위치 인자를 몇 개까지 받아주는지에 따라 몇 줄까지 읽어낼 수 있는지가 달라집니다. 벤더 공지는 "플러그인이 없는 최신 Jenkins에서 첫 3줄까지 읽어낼 수 있는 방법을 확인했다"고 밝히고 있으며, Overall/Read 권한이 있는 계정이라면 인가 검사 자체를 통과하므로 파일 전체를 읽어낼 수 있습니다. 실제 작동하는 명령 조합이나 페이로드 문자열은 이 글에 싣지 않습니다.

5.3 미흡점 ↔ 공격 흐름 매핑

미흡점(§4)발화 지점(§5.2)여는 프리미티브→ 다음으로 잇는 것
W1 — args4j 기본값 미조정getCmdLineParser() 호출(§8.2 3단계)atSyntax=true인 기본 파서 생성파일 확장 로직이 켜진 채로 다음 단계 진입
W2 — expandAtFiles의 접근 통제 부재expandAtFiles()/readAllLines() 호출(§8.2 4단계)'@경로' 인자를 접근 통제 없이 파일 내용으로 치환파일 내용이 파싱 대상 토큰으로 편입
W3 — help/who-am-i의 인가 우회인가 검사 분기(§8.2 2단계)익명 요청도 권한 확인 없이 파싱 단계까지 도달무인증 상태에서도 W1·W2 경로가 전부 발화
(귀결)옵션/인자 매칭 실패 예외(§8.2 5단계)매칭 안 된 인자(=파일 내용 일부)가 예외 메시지에 노출CLI 클라이언트 응답으로 파일 내용 반환 — 암호화 비밀키 등 확보 시 후속 RCE 경로로 연결

06취약점 패치

CLICommand.java (+ declarative/CLIRegisterer.java)·getCmdLineParser() / CLIRegisterer 내부 파서 생성부·커밋 554f0378 · 부모 de450967 (Jenkins 2.442 / LTS 2.426.3·2.440.1에 동일 수정 반영)
protected CmdLineParser getCmdLineParser() { return new CmdLineParser(this); ParserProperties properties = ParserProperties.defaults().withAtSyntax(ALLOW_AT_SYNTAX); return new CmdLineParser(this, properties); }

왜getCmdLineParser()가 args4j의 기본 설정(§7 W1)을 그대로 쓰는 것이 '@'-파일 확장을 무조건 켜 둔 근본 자리였기 때문입니다.

어떻게새 정적 필드 ALLOW_AT_SYNTAX(시스템 프로퍼티 hudson.cli.CLICommand.allowAtSyntax, 기본값 false)를 추가하고, getCmdLineParser()가 이 값을 ParserProperties.defaults().withAtSyntax(...)로 args4j에 명시적으로 전달하도록 바꿨습니다 — 결과적으로 '@'-파일 확장의 기본값이 켜짐에서 꺼짐으로 뒤집힙니다. 같은 패턴이 @CLIMethod 방식 명령을 처리하는 CLIRegisterer.java에도 동일하게 적용됐습니다. 벤더는 이 시스템 프로퍼티를 true로 되돌려 옛 동작을 복원하는 것을, 관리자가 아닌 사용자도 접근 가능한 네트워크에서는 강하게 만류합니다.

비고같은 커밋에 새 테스트 Security3314Test.java가 추가되어, connect-node(CLICommand 경로)와 disable-job(CLIRegisterer 경로) 두 명령에 '@파일'을 인자로 넘겼을 때 파일 내용이 더 이상 인자로 파싱되지 않는지(대신 '@경로' 문자열 자체가 그대로 오류 메시지에 남는지) 회귀 검증합니다.

07대응방안

패치가 근본 해결입니다. §영향받는 버전 표에 따라 weekly는 2.442 이상, LTS는 2.426.3 또는 2.440.1 이상으로 업그레이드합니다.

08왜 이게 반복되는가

이 취약점의 핵심은 코드 로직의 실수가 아니라 기본값이 어떤 신뢰 경계를 전제로 만들어졌는지를 다시 묻지 않은 것입니다. args4j의 '@파일' 확장은 로컬 셸에서 자기 자신을 위해 긴 인자 목록을 넘기려는 사용자를 염두에 둔 기능이고, 그 전제에서는 파일 접근 통제가 필요 없다는 판단이 합리적입니다. 그 라이브러리를 네트워크 너머의 요청을 받는 서비스에 그대로 가져다 쓰면서, '이 기본값이 여기서도 안전한가'를 다시 묻지 않은 것이 문제의 전부입니다. 저희가 Struts 파일 업로드 분석(CVE-2023-50164)에서 본 것과도 결이 비슷합니다 — 그때는 자료구조의 '같음' 기준이 계층마다 달랐고, 이번엔 '신뢰된 사용자'라는 전제가 계층마다 달랐습니다. 둘 다 개별 코드는 각자 정상인데, 그 코드들을 이어 붙이는 경계에서 전제가 어긋났습니다.

패치완전성 관점에서 보면 이 사건은 흥미로운 패치갭 사례이기도 합니다. 코어 패치는 CLICommand.java·CLIRegisterer.java, 즉 Jenkins 코어가 스스로 만든 두 호출부만 고쳤습니다. args4j 라이브러리 자체의 기본값은 여전히 켜져 있습니다. 그 결과, 같은 라이브러리를 자기 나름의 방식으로 쓰는 플러그인들은 코어 패치의 보호를 받지 못했습니다 — 실제로 같은 공지에서 Git server 플러그인(CVE-2024-23899, Overall/Read 권한이 있는 사용자가 파일 앞 두 줄을 읽어낼 수 있었습니다)과 Log Command 플러그인(CVE-2024-23904, 공지 시점까지 패치가 없었습니다)이 완전히 같은 근본원인으로 별도 CVE를 받았습니다. '핵심 컴포넌트를 고쳤다'와 '같은 근본원인을 쓰는 모든 코드를 고쳤다'는 다른 문장이라는 걸 보여주는 사례입니다. 같은 날 공지된 CVE-2024-23898(CLI WebSocket 엔드포인트의 크로스사이트 웹소켓 하이재킹)은 별개의 취약점이지만, 두 취약점이 겹치면 인가 우회의 범위가 더 넓어진다는 점에서 함께 읽을 가치가 있습니다.

이 한 틈은 고쳐졌지만, '로컬 신뢰 전제를 갖고 태어난 기본값을 네트워크로 그대로 옮기지 않았는가'라는 질문은 다른 라이브러리, 다른 서비스에서 또 나옵니다 — 그래서 저희는 도구가 아니라 이 질문을 남깁니다.

09결론

인증이 필요 없도록 설계된 CLI 명령이, 인가 검사 다음 단계인 인자 파싱까지는 보호하지 못했습니다. 그 파싱 단계에 기본으로 켜져 있던 '@파일' 확장 기능이 겹치면서, 무인증 임의 파일 읽기가 성립했습니다.

이 CVE의 표준판(요약본)은 아직 별도로 작성되지 않았습니다.

참고 자료

이 글의 코드 발췌(CLICommand.java 패치 전/후, args4j의 CmdLineParser.java·ParserProperties.java)는 jenkinsci/jenkins와 kohsuke/args4j 오픈소스의 실제 소스를 고정 커밋 SHA 기준으로 직접 열어 대조했으며, 패치 diff(커밋 554f0378, 부모 de450967)도 GitHub API로 받은 실제 diff와 패치 전/후 파일을 각각 직접 비교해 확인했습니다. CVSS·CWE·발행일은 NVD API(JSON)에서, 취약 버전·안전 버전·연쇄 공격 시나리오·관련 플러그인 CVE 정보는 Jenkins 공식 보안 공지(2024-01-24)를 직접 열어 수집했습니다. 다만 '인자 매칭 실패 시 발생하는 예외 메시지가 CLI 클라이언트(HTTP/WebSocket)로 그대로 전달되어 파일 내용이 화면에 노출된다'는 전송 계층까지의 마지막 연결 고리는 이 세션에서 원격 CLI 프로토콜 처리 코드까지 직접 추적하지 않았고, 공식 공지문의 서술과 args4j의 예외 생성 코드(인자 값을 메시지에 담아 던짐)를 근거로 재구성했습니다 — 그 부분은 2차 분석 기반임을 밝힙니다.