diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2649659..64fbbb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,14 @@ jobs: cargo run -p proxychains-bin -- -f ./ci.e2e.conf --probe --probe-json > probe-report.json grep -q "\"summary\"" probe-report.json + DNS_V4="$(cargo run -q -p proxychains-bin -- -q -f ./ci.e2e.conf python3 -c "import socket;print(socket.getaddrinfo(\"dns-proxychains-e2e.invalid\",443,0,socket.SOCK_STREAM)[0][4][0])" | tr -d "\r\n")" + echo "dns v4 fake ip: ${DNS_V4}" + [[ "${DNS_V4}" == 224.* ]] + + DNS_V6="$(cargo run -q -p proxychains-bin -- -q -f ./ci.e2e.conf python3 -c "import socket;print(socket.getaddrinfo(\"dns-proxychains-e2e.invalid\",443,socket.AF_INET6,socket.SOCK_STREAM)[0][4][0])" | tr -d "\r\n")" + echo "dns v6 fake ip: ${DNS_V6}" + [[ "${DNS_V6}" == ::ffff:224.* ]] + PROXYCHAINS_TEST_SOCKS5_ADDR=127.0.0.1:19080 \ PROXYCHAINS_TEST_HTTP_ADDR=127.0.0.1:18080 \ cargo test -p proxychains --test real_backends -- --nocapture diff --git a/README.md b/README.md index 2d3cf86..b2990f4 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ proxychains4 curl https://ifconfig.me proxychains4.exe curl https://ifconfig.me ``` +Forced validation manual: +- [docs/forced-proxy-validation.md](docs/forced-proxy-validation.md) + Validate config and groups: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e41508..708ae19 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -88,6 +88,9 @@ proxychains4 curl https://ifconfig.me proxychains4.exe curl https://ifconfig.me ``` +强制代理验证手册: +- [docs/forced-proxy-validation.md](docs/forced-proxy-validation.md) + 查看分组/校验配置: ```bash diff --git a/docs/forced-proxy-validation.md b/docs/forced-proxy-validation.md new file mode 100644 index 0000000..f3709d7 --- /dev/null +++ b/docs/forced-proxy-validation.md @@ -0,0 +1,87 @@ +# Forced Proxy Validation Manual + +This manual provides a fail-closed validation flow: +- direct path must show your direct egress IP; +- valid proxy must show a different proxy egress IP; +- invalid proxy must fail (must not silently fall back to direct). + +## 1) Prepare a working local proxy + +Example (replace with your own proxy endpoint): + +- SOCKS5 at `127.0.0.1:5091` +- HTTP at `127.0.0.1:5098` + +## 2) Create test configs + +Good config (`proxy-good.conf`): + +```ini +strict_chain +proxy_dns +remote_dns_subnet 224 +tcp_read_time_out 15000 +tcp_connect_time_out 8000 + +[ProxyList] +socks5 127.0.0.1 5091 +``` + +Bad config (`proxy-bad.conf`): + +```ini +strict_chain +proxy_dns +tcp_read_time_out 5000 +tcp_connect_time_out 2000 + +[ProxyList] +socks5 127.0.0.1 5999 +``` + +## 3) One-shot verification commands + +### Windows (PowerShell) + +```powershell +curl.exe --max-time 15 https://ifconfig.me +.\proxychains4.exe -f .\proxy-good.conf -v curl --max-time 15 https://ifconfig.me +.\proxychains4.exe -f .\proxy-bad.conf -v curl --max-time 10 https://ifconfig.me; $LASTEXITCODE +``` + +Expected: +- line 1 (direct) and line 2 (proxy) should be different IPs; +- line 3 should fail and return non-zero exit code (for curl usually `7`). + +### Linux/macOS + +```bash +curl --max-time 15 https://ifconfig.me +./proxychains4 -f ./proxy-good.conf -v curl --max-time 15 https://ifconfig.me +./proxychains4 -f ./proxy-bad.conf -v curl --max-time 10 https://ifconfig.me; echo $? +``` + +Expected: +- direct IP != proxied IP +- bad proxy run exits non-zero + +## 4) DNS leak protection quick check + +When `proxy_dns` is enabled, hostname resolution inside proxied process should be fake-IP mapped. + +Linux/macOS: + +```bash +./proxychains4 -q -f ./proxy-good.conf python3 -c "import socket;print(socket.getaddrinfo('dns-proxychains-check.invalid',443,0,socket.SOCK_STREAM)[0][4][0])" +./proxychains4 -q -f ./proxy-good.conf python3 -c "import socket;print(socket.getaddrinfo('dns-proxychains-check.invalid',443,socket.AF_INET6,socket.SOCK_STREAM)[0][4][0])" +``` + +Expected: +- IPv4 output starts with `224.` +- IPv6 output starts with `::ffff:224.` + +## 5) Troubleshooting + +- `No proxies configured`: verify `[ProxyList]` and `-f` path. +- Direct/proxy IP same: proxy endpoint may share same egress as direct path; run bad-proxy test to verify fail-closed behavior. +- Windows: keep `proxychains4.exe` and `proxychains.dll` in same directory. diff --git a/proxychains/Cargo.toml b/proxychains/Cargo.toml index 0f4310f..acad9a7 100644 --- a/proxychains/Cargo.toml +++ b/proxychains/Cargo.toml @@ -31,6 +31,7 @@ tracing-subscriber.workspace = true windows = { version = "0.54", features = [ "Win32_Foundation", "Win32_Networking_WinSock", + "Win32_System_IO", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_LibraryLoader", diff --git a/proxychains/src/hook/hooks_windows.rs b/proxychains/src/hook/hooks_windows.rs index 97568f8..6091e41 100644 --- a/proxychains/src/hook/hooks_windows.rs +++ b/proxychains/src/hook/hooks_windows.rs @@ -22,6 +22,8 @@ use windows::Win32::Networking::WinSock::{ WSASetLastError, }; use windows::core::GUID; +use windows::Win32::System::IO::OVERLAPPED; +use windows::Win32::System::Threading::SetEvent; use crate::config::{ChainType, Config, ProxyData, ProxyState}; use crate::dns::{is_fake_ip, DnsResolver}; @@ -32,7 +34,8 @@ use crate::ConfigParser; use super::interpose_windows::{ init_original_functions, original_connect, original_freeaddrinfo, original_getaddrinfo, - original_getaddrinfow, original_gethostbyname, original_getnameinfo, original_wsa_ioctl, + original_getaddrinfoexw, original_getaddrinfow, original_gethostbyname, original_getnameinfo, + original_dns_query_a, original_dns_query_w, original_wsa_ioctl, }; use super::reload::config_reload_interval; @@ -461,7 +464,6 @@ const WSAID_CONNECTEX: GUID = GUID::from_u128(0x25a207b9_ddf3_4660_8ee9_76e58c74 /// ConnectEx replacement used when applications query extension function pointers via WSAIoctl. /// /// This implementation is intentionally synchronous: -/// - `lpOverlapped != NULL` is rejected (`WSAEOPNOTSUPP`) to avoid silent bypass. /// - `dwSendDataLength > 0` is rejected (`WSAEOPNOTSUPP`) because initial-send semantics are /// not yet mirrored. #[cfg(windows)] @@ -474,7 +476,7 @@ pub unsafe extern "system" fn hook_connect_ex_impl( bytes_sent: *mut u32, overlapped: *mut c_void, ) -> i32 { - if !overlapped.is_null() || (!send_buf.is_null() && send_len > 0) { + if !send_buf.is_null() && send_len > 0 { WSASetLastError(WSAEOPNOTSUPP.0); return 0; } @@ -487,6 +489,12 @@ pub unsafe extern "system" fn hook_connect_ex_impl( if ret == SOCKET_ERROR { 0 } else { + if !overlapped.is_null() { + let ov = &mut *(overlapped as *mut OVERLAPPED); + if !ov.hEvent.is_invalid() { + let _ = SetEvent(ov.hEvent); + } + } 1 } } @@ -759,6 +767,166 @@ pub unsafe extern "system" fn hook_getaddrinfow_impl( 0 } +/// Windows GetAddrInfoExW hook implementation. +#[cfg(windows)] +pub unsafe extern "system" fn hook_getaddrinfoexw_impl( + pname: *const u16, + pservice: *const u16, + namespace: u32, + pnspid: *mut c_void, + hints: *const c_void, + ppresult: *mut *mut c_void, + timeout: *mut c_void, + overlapped: *mut c_void, + completion_routine: *mut c_void, + pname_handle: *mut c_void, +) -> i32 { + let state = match get_hook_state() { + Some(s) => s, + None => { + return original_getaddrinfoexw( + pname, + pservice, + namespace, + pnspid, + hints, + ppresult, + timeout, + overlapped, + completion_routine, + pname_handle, + ) + } + }; + maybe_reload_config(state); + let config = state.config.lock().clone(); + if !config.proxy_dns { + return original_getaddrinfoexw( + pname, + pservice, + namespace, + pnspid, + hints, + ppresult, + timeout, + overlapped, + completion_routine, + pname_handle, + ); + } + let dns_resolver = DnsResolver::new(config.proxy_dns, config.remote_dns_subnet); + + if pname.is_null() { + return WSAEINVAL.0; + } + + let hostname = match parse_wide_string(pname) { + Ok(s) => s, + Err(_) => return WSAEINVAL.0, + }; + + if hostname.parse::().is_ok() || crate::dns::lookup_in_hosts(&hostname).is_some() { + return original_getaddrinfoexw( + pname, + pservice, + namespace, + pnspid, + hints, + ppresult, + timeout, + overlapped, + completion_routine, + pname_handle, + ); + } + + let fake_ip = match dns_resolver.resolve(&hostname) { + Ok(ip) => ip, + Err(_) => return WSAHOST_NOT_FOUND.0, + }; + + let fake_wide: Vec = fake_ip + .to_string() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + + original_getaddrinfoexw( + fake_wide.as_ptr(), + pservice, + namespace, + pnspid, + hints, + ppresult, + timeout, + overlapped, + completion_routine, + pname_handle, + ) +} + +const DNS_ERROR_RCODE_NAME_ERROR: i32 = 9003; + +/// Windows DnsQuery_A hook implementation. +#[cfg(windows)] +pub unsafe extern "system" fn hook_dns_query_a_impl( + name: *const i8, + query_type: u16, + options: u32, + extra: *mut c_void, + result: *mut *mut c_void, + reserved: *mut c_void, +) -> i32 { + let state = match get_hook_state() { + Some(s) => s, + None => return original_dns_query_a(name, query_type, options, extra, result, reserved), + }; + maybe_reload_config(state); + let config = state.config.lock().clone(); + if !config.proxy_dns || name.is_null() { + return original_dns_query_a(name, query_type, options, extra, result, reserved); + } + + let hostname = match CStr::from_ptr(name).to_str() { + Ok(s) => s, + Err(_) => return DNS_ERROR_RCODE_NAME_ERROR, + }; + if hostname.parse::().is_ok() || crate::dns::lookup_in_hosts(hostname).is_some() { + return original_dns_query_a(name, query_type, options, extra, result, reserved); + } + DNS_ERROR_RCODE_NAME_ERROR +} + +/// Windows DnsQuery_W hook implementation. +#[cfg(windows)] +pub unsafe extern "system" fn hook_dns_query_w_impl( + name: *const u16, + query_type: u16, + options: u32, + extra: *mut c_void, + result: *mut *mut c_void, + reserved: *mut c_void, +) -> i32 { + let state = match get_hook_state() { + Some(s) => s, + None => return original_dns_query_w(name, query_type, options, extra, result, reserved), + }; + maybe_reload_config(state); + let config = state.config.lock().clone(); + if !config.proxy_dns || name.is_null() { + return original_dns_query_w(name, query_type, options, extra, result, reserved); + } + + let hostname = match parse_wide_string(name) { + Ok(s) => s, + Err(_) => return DNS_ERROR_RCODE_NAME_ERROR, + }; + if hostname.parse::().is_ok() || crate::dns::lookup_in_hosts(&hostname).is_some() { + return original_dns_query_w(name, query_type, options, extra, result, reserved); + } + DNS_ERROR_RCODE_NAME_ERROR +} + /// Windows freeaddrinfo hook implementation. #[cfg(windows)] pub unsafe extern "system" fn hook_freeaddrinfo_impl(pres: *mut c_void) { diff --git a/proxychains/src/hook/interpose_windows.rs b/proxychains/src/hook/interpose_windows.rs index 6f0d8e2..2aa2962 100644 --- a/proxychains/src/hook/interpose_windows.rs +++ b/proxychains/src/hook/interpose_windows.rs @@ -15,7 +15,8 @@ use crate::error::{Error, Result}; use super::hooks_windows::{ hook_connect_impl, hook_freeaddrinfo_impl, hook_getaddrinfo_impl, hook_getaddrinfow_impl, - hook_gethostbyname_impl, hook_getnameinfo_impl, hook_wsa_connect_impl, hook_wsa_ioctl_impl, + hook_getaddrinfoexw_impl, hook_gethostbyname_impl, hook_getnameinfo_impl, + hook_dns_query_a_impl, hook_dns_query_w_impl, hook_wsa_connect_impl, hook_wsa_ioctl_impl, }; type ConnectFn = unsafe extern "system" fn(usize, *const c_void, i32) -> i32; @@ -32,6 +33,18 @@ type GetAddrInfoFn = unsafe extern "system" fn(*const i8, *const i8, *const c_void, *mut *mut c_void) -> i32; type GetAddrInfoWFn = unsafe extern "system" fn(*const u16, *const u16, *const c_void, *mut *mut c_void) -> i32; +type GetAddrInfoExWFn = unsafe extern "system" fn( + *const u16, + *const u16, + u32, + *mut c_void, + *const c_void, + *mut *mut c_void, + *mut c_void, + *mut c_void, + *mut c_void, + *mut c_void, +) -> i32; type FreeAddrInfoFn = unsafe extern "system" fn(*mut c_void); type GetHostByNameFn = unsafe extern "system" fn(*const i8) -> *mut c_void; type GetNameInfoFn = @@ -47,15 +60,34 @@ type WsaIoctlFn = unsafe extern "system" fn( *mut c_void, *mut c_void, ) -> i32; +type DnsQueryAFn = unsafe extern "system" fn( + *const i8, + u16, + u32, + *mut c_void, + *mut *mut c_void, + *mut c_void, +) -> i32; +type DnsQueryWFn = unsafe extern "system" fn( + *const u16, + u16, + u32, + *mut c_void, + *mut *mut c_void, + *mut c_void, +) -> i32; static ORIGINAL_CONNECT: OnceLock = OnceLock::new(); static ORIGINAL_WSA_CONNECT: OnceLock = OnceLock::new(); static ORIGINAL_GETADDRINFO: OnceLock = OnceLock::new(); static ORIGINAL_GETADDRINFOW: OnceLock = OnceLock::new(); +static ORIGINAL_GETADDRINFOEXW: OnceLock = OnceLock::new(); static ORIGINAL_FREEADDRINFO: OnceLock = OnceLock::new(); static ORIGINAL_GETHOSTBYNAME: OnceLock = OnceLock::new(); static ORIGINAL_GETNAMEINFO: OnceLock = OnceLock::new(); static ORIGINAL_WSA_IOCTL: OnceLock = OnceLock::new(); +static ORIGINAL_DNS_QUERY_A: OnceLock = OnceLock::new(); +static ORIGINAL_DNS_QUERY_W: OnceLock = OnceLock::new(); static HOOKS_READY: AtomicBool = AtomicBool::new(false); @@ -69,6 +101,16 @@ unsafe fn install_api_hook(proc_name: &str, detour: *mut c_void) -> Result Ok(std::mem::transmute_copy::<*mut c_void, F>(&trampoline)) } +unsafe fn install_api_hook_from_module( + module: &str, + proc_name: &str, + detour: *mut c_void, +) -> Result { + let trampoline = MinHook::create_hook_api(module, proc_name, detour) + .map_err(|s| mh_to_error("create_hook_api", s))?; + Ok(std::mem::transmute_copy::<*mut c_void, F>(&trampoline)) +} + /// Store for original function pointers. pub struct OriginalFunctions; @@ -114,6 +156,32 @@ impl OriginalFunctions { let _ = ORIGINAL_FREEADDRINFO.set(freeaddrinfo_fn); let _ = ORIGINAL_GETHOSTBYNAME.set(gethostbyname_fn); let _ = ORIGINAL_GETNAMEINFO.set(getnameinfo_fn); + if let Ok(getaddrinfoexw_fn) = install_api_hook( + "GetAddrInfoExW", + hook_getaddrinfoexw_impl as *const () as *mut c_void, + ) { + let _ = ORIGINAL_GETADDRINFOEXW.set(getaddrinfoexw_fn); + } else { + debug!("GetAddrInfoExW hook not installed (symbol unavailable)"); + } + if let Ok(dns_query_a_fn) = install_api_hook_from_module( + "dnsapi.dll", + "DnsQuery_A", + hook_dns_query_a_impl as *const () as *mut c_void, + ) { + let _ = ORIGINAL_DNS_QUERY_A.set(dns_query_a_fn); + } else { + debug!("DnsQuery_A hook not installed (dnsapi.dll unavailable)"); + } + if let Ok(dns_query_w_fn) = install_api_hook_from_module( + "dnsapi.dll", + "DnsQuery_W", + hook_dns_query_w_impl as *const () as *mut c_void, + ) { + let _ = ORIGINAL_DNS_QUERY_W.set(dns_query_w_fn); + } else { + debug!("DnsQuery_W hook not installed (dnsapi.dll unavailable)"); + } MinHook::enable_all_hooks().map_err(|s| mh_to_error("enable_all_hooks", s))?; } @@ -237,6 +305,38 @@ pub unsafe fn original_getaddrinfow( } } +/// Call the original GetAddrInfoExW function. +pub unsafe fn original_getaddrinfoexw( + node: *const u16, + service: *const u16, + namespace: u32, + nspid: *mut c_void, + hints: *const c_void, + res: *mut *mut c_void, + timeout: *mut c_void, + overlapped: *mut c_void, + completion_routine: *mut c_void, + name_handle: *mut c_void, +) -> i32 { + if let Some(f) = ORIGINAL_GETADDRINFOEXW.get() { + f( + node, + service, + namespace, + nspid, + hints, + res, + timeout, + overlapped, + completion_routine, + name_handle, + ) + } else { + WSASetLastError(WSAHOST_NOT_FOUND.0); + WSAHOST_NOT_FOUND.0 + } +} + /// Call the original freeaddrinfo function. pub unsafe fn original_freeaddrinfo(res: *mut c_void) { if let Some(f) = ORIGINAL_FREEADDRINFO.get() { @@ -270,6 +370,38 @@ pub unsafe fn original_getnameinfo( } } +/// Call the original DnsQuery_A function. +pub unsafe fn original_dns_query_a( + name: *const i8, + query_type: u16, + options: u32, + extra: *mut c_void, + result: *mut *mut c_void, + reserved: *mut c_void, +) -> i32 { + if let Some(f) = ORIGINAL_DNS_QUERY_A.get() { + f(name, query_type, options, extra, result, reserved) + } else { + 9003 + } +} + +/// Call the original DnsQuery_W function. +pub unsafe fn original_dns_query_w( + name: *const u16, + query_type: u16, + options: u32, + extra: *mut c_void, + result: *mut *mut c_void, + reserved: *mut c_void, +) -> i32 { + if let Some(f) = ORIGINAL_DNS_QUERY_W.get() { + f(name, query_type, options, extra, result, reserved) + } else { + 9003 + } +} + #[cfg(test)] mod tests { use super::*;