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 LTS | 2.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)만 호출했습니다.
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()를 호출해, 옵션·인자를 실제로 매칭하기 전에 @로 시작하는 모든 인자를 먼저 파일로 치환합니다.
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인 경우는 예외입니다 — "도움말을 보거나 내가 누구인지 확인하는 것 정도는 익명 사용자도 할 수 있어야 한다"는 합리적인 설계입니다.
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 공격 과정 요약
- 공격자가 인증 없이 Jenkins CLI 진입점(HTTP 또는 WebSocket)에 help 또는 who-am-i 명령과, '@'+읽고 싶은 파일 경로 형태의 인자를 실어 요청을 보냅니다.
- CLICommand.main()이 요청을 받아 해당 명령 객체를 만들고, 인가 검사 분기에서 HelpCommand·WhoAmICommand이므로 권한 확인을 건너뜁니다.
- getCmdLineParser()가 args4j 파서를 기본 설정(@-파일 확장 켜짐)으로 생성합니다.
- parseArgument() 시작 부분의 expandAtFiles()가 '@'로 시작하는 인자를 발견하고, 접근 통제 없이 해당 경로 파일 전체를 줄 단위로 읽어 인자 목록에 펼쳐 넣습니다.
- 치환된 각 줄이 이후 옵션/인자 매칭 루프에 들어가는데, 명령이 기대하는 형식과 맞지 않으면 그 줄의 내용을 담은 CmdLineException이 발생합니다.
- 이 예외 메시지가 CLI 클라이언트로 그대로 반환되어, 공격자는 파일의 앞부분 내용을 화면에서 읽습니다. 권한이 있는 계정이거나 조건이 맞는 다른 명령을 쓰면 더 많은 줄을 읽어낼 수 있습니다.
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취약점 패치
왜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 이상으로 업그레이드합니다.
- 업그레이드가 당장 어렵다면, 벤더 권고대로 CLI 접근 자체를 비활성화하는 것이 가장 확실한 임시 차단선입니다(Jenkins 재시작 없이 적용 가능). CLI를 쓰지 않는 환경이라도 이 워크어라운드는 단기용으로만 유지하고, 결국 버전을 올려야 합니다.
- 부득이 구버전을 유지해야 한다면, 시스템 프로퍼티
hudson.cli.CLICommand.allowAtSyntax를true로 되돌리지 않는 것(즉 패치가 적용하는 기본값 false를 그대로 두는 것)이 최소 조건입니다 — 이 값을 켜는 것은 패치 이전 상태로 되돌리는 것과 같습니다. - 직접 만든 CLI 명령이나 args4j를 쓰는 커스텀 플러그인이 있다면,
CmdLineParser를 생성하는 지점에서ParserProperties.defaults().withAtSyntax(false)를 명시적으로 넘기는지 점검합니다 — 코어 패치는 코어 자신의 두 호출부만 고쳤을 뿐, args4j 라이브러리의 기본값 자체는 바뀌지 않았습니다(§11). - 탐지 관점. CLI 엔드포인트로 들어오는 요청 인자에
@로 시작하는 값이 실리는 패턴, 특히 인증 없이도 호출되는help·who-am-i명령에 그런 인자가 함께 오는 패턴을 로그·WAF에서 이상 신호로 관찰합니다 — 정상적인 CLI 사용에서는 흔치 않은 형태입니다.
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차 분석 기반임을 밝힙니다.
- Jenkins 공식 보안 공지 — 2024-01-24 (SECURITY-3314 등) · 이 글이 재구성한 원 분석, 취약점 설명·공격 시나리오·수정/워크어라운드 공식 근거
- NVD — CVE-2024-23897 상세 (CVSS 9.8) · CWE-22/CWE-27, 공식 CVSS 벡터·발행일(2024-01-24)
- GitHub Advisory Database — GHSA-6f9g-cxwr-q5jr · 영향 버전·패치 버전 정리
- Jenkins 오픈소스 — 패치 커밋 554f0378 · CLICommand.java/CLIRegisterer.java에 withAtSyntax(false) 옵트아웃 추가 + 회귀 테스트
- args4j 오픈소스 — CmdLineParser.java (태그 args4j-site-2.33) · expandAtFiles()/parseArgument() 실제 소스
- args4j 오픈소스 — ParserProperties.java (태그 args4j-site-2.33) · atSyntax 기본값(true) 정의부
- GitHub Advisory Database — GHSA-vph5-2q33-7r9h (CVE-2024-23899, Git server 플러그인) · 같은 근본원인의 플러그인 변형, §11 패치완전성 논의 근거
- GitHub Advisory Database — GHSA-qjpf-2jhx-3758 (CVE-2024-23904, Log Command 플러그인) · 같은 근본원인의 또 다른 플러그인 변형, 공지 시점 미패치