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

인증이 필요 없던 명령이, 파일을 읽어내는 통로가 됐다

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())로 위임되고, ParserPropertiesprivate 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)로 권한을 확인합니다. 단, 이 명령이 HelpCommandWhoAmICommand인 경우는 예외입니다 — "도움말을 보거나 내가 누구인지 확인하는 것 정도는 익명 사용자도 할 수 있어야 한다"는 합리적인 설계입니다.

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 공격 과정 요약

공격자 요청 · help/who-am-i + '@파일경로' 인자 인증 없이 CLI HTTP/WebSocket 엔드포인트로 전송 ① 요청이 인가 분기에 먼저 도달 CLICommand.main() · 인가 검사 분기 HelpCommand/WhoAmICommand는 Jenkins.READ 확인을 건너뜀 ② 권한 확인 없이 파서 생성 단계로 진입 핵심 지점 getCmdLineParser() → args4j 기본 설정 생성 @-파일 확장(atSyntax)이 켜진 채 파서 생성(패치 전) ③ atSyntax 기본값이 켜진 채로 파싱 시작(패치 전) parseArgument() → expandAtFiles() 호출 파일 존재만 확인 후 전체 내용을 줄 단위로 읽음 ④ 파일 내용이 인자 토큰으로 편입된 뒤 매칭 실패 옵션/인자 매칭 실패 → CmdLineException 예외 메시지에 매칭 안 된 인자값(파일 내용) 포함 ⑤ 실패 메시지에 담긴 파일 내용이 응답으로 반환 에러 메시지로 파일 내용 노출 · 반복 시 후속 RCE 조건 성립 일부 조건 충족 시 · Remote Code Execution
공격 흐름도 (직접 작성)

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차 분석 기반임을 밝힙니다.