Fix upstream CONNECT handshake

This commit is contained in:
2026-04-11 11:44:09 +03:00
parent 918a98068b
commit 89490085aa
2 changed files with 399 additions and 19 deletions

View File

@@ -6,6 +6,7 @@ using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -156,18 +157,17 @@ class Program
await upstream.ConnectAsync(proxyUri.Host, proxyUri.Port);
var upstreamStream = upstream.GetStream();
var connectResp = await PerformUpstreamConnectHandshake(
upstreamStream,
host,
port,
proxyUri.Host,
connectionId);
string connectReq =
$"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n{UPSTREAM_PROXY_AUTH_HEADER}\r\n";
await upstreamStream.WriteAsync(Encoding.ASCII.GetBytes(connectReq));
var resp = await ReadHeaders(upstreamStream, $"conn:{connectionId} upstream-connect-response");
int statusCode = ParseStatusCode(resp?.Raw);
if (resp == null || statusCode != 200)
int statusCode = ParseStatusCode(connectResp?.Raw);
if (connectResp == null || statusCode != 200)
{
var status = resp?.Raw?.Split("\r\n", StringSplitOptions.None).FirstOrDefault() ?? "<no response>";
var status = connectResp?.Raw?.Split("\r\n", StringSplitOptions.None).FirstOrDefault() ?? "<no response>";
Log.Error($"[conn:{connectionId}] upstream CONNECT rejected ({statusCode}): {status}");
await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 502 Bad Gateway\r\n\r\n"));
return;
@@ -220,12 +220,10 @@ class Program
{
try
{
var buffer = new byte[8192];
int read = await stream.ReadAsync(buffer);
if (read <= 0) return null;
string raw = Encoding.ASCII.GetString(buffer, 0, read);
var rawBytes = await ReadHeaderBytes(stream);
if (rawBytes == null || rawBytes.Length == 0)
return null;
string raw = Encoding.ASCII.GetString(rawBytes);
var lines = raw.Split("\r\n");
var first = lines[0].Split(' ', StringSplitOptions.RemoveEmptyEntries);
@@ -240,7 +238,7 @@ class Program
Method = first[0],
Target = first[1],
Raw = raw,
RawBytes = buffer.Take(read).ToArray(),
RawBytes = rawBytes,
HeaderLength = GetHeaderLength(raw)
};
@@ -264,6 +262,41 @@ class Program
}
}
static async Task<byte[]> ReadHeaderBytes(Stream stream)
{
const int maxHeaderBytes = 64 * 1024;
var data = new List<byte>(4096);
var chunk = new byte[2048];
while (data.Count < maxHeaderBytes)
{
int read = await stream.ReadAsync(chunk, 0, chunk.Length);
if (read <= 0)
break;
data.AddRange(chunk.AsSpan(0, read).ToArray());
if (ContainsHeaderTerminator(data))
break;
}
return data.Count == 0 ? null : data.ToArray();
}
static bool ContainsHeaderTerminator(List<byte> data)
{
if (data.Count < 4) return false;
int n = data.Count;
for (int i = Math.Max(0, n - 4096); i <= n - 4; i++)
{
if (data[i] == (byte)'\r'
&& data[i + 1] == (byte)'\n'
&& data[i + 2] == (byte)'\r'
&& data[i + 3] == (byte)'\n')
return true;
}
return false;
}
// ================= STREAM =================
static async Task Pump(Stream from, Stream to, string context)
@@ -402,6 +435,355 @@ class Program
return $"{domain}\\{user}";
return user ?? string.Empty;
}
static async Task<HttpRequest> PerformUpstreamConnectHandshake(
NetworkStream upstreamStream,
string targetHost,
int targetPort,
string proxyHost,
long connectionId)
{
// 1) First CONNECT without auth (browser-like flow).
await SendConnectRequest(upstreamStream, targetHost, targetPort, null);
var resp = await ReadHeaders(upstreamStream, $"conn:{connectionId} upstream-connect-response-1");
int code = ParseStatusCode(resp?.Raw);
if (code == 200 || resp == null)
return resp;
var challenges = ParseProxyAuthenticateValues(resp.Raw);
if (code != 407 || challenges.Count == 0)
return resp;
// 2) Basic fallback.
if (!string.IsNullOrEmpty(UPSTREAM_PROXY_AUTH_HEADER))
{
await SendConnectRequest(upstreamStream, targetHost, targetPort, UPSTREAM_PROXY_AUTH_HEADER.TrimEnd('\r', '\n'));
var basicResp = await ReadHeaders(upstreamStream, $"conn:{connectionId} upstream-connect-response-basic");
if (ParseStatusCode(basicResp?.Raw) == 200)
return basicResp;
resp = basicResp ?? resp;
challenges = ParseProxyAuthenticateValues(resp.Raw);
}
// 3) Negotiate/NTLM over SSPI (as browsers do for AD proxies).
bool hasNegotiate = challenges.Any(c => c.StartsWith("Negotiate", StringComparison.OrdinalIgnoreCase));
bool hasNtlm = challenges.Any(c => c.StartsWith("NTLM", StringComparison.OrdinalIgnoreCase));
if (!hasNegotiate && !hasNtlm)
return resp;
string scheme = hasNegotiate ? "Negotiate" : "NTLM";
using var sspi = new SspiProxyAuthenticator(hasNegotiate ? "Negotiate" : "NTLM", proxyHost);
string inToken = ExtractSchemeToken(challenges, scheme);
for (int step = 0; step < 4; step++)
{
string outToken = sspi.NextToken(inToken);
if (string.IsNullOrEmpty(outToken))
break;
await SendConnectRequest(
upstreamStream,
targetHost,
targetPort,
$"Proxy-Authorization: {scheme} {outToken}");
var authResp = await ReadHeaders(upstreamStream, $"conn:{connectionId} upstream-connect-response-{scheme}-{step + 1}");
int authCode = ParseStatusCode(authResp?.Raw);
if (authCode == 200)
return authResp;
if (authCode != 407 || authResp == null)
return authResp ?? resp;
challenges = ParseProxyAuthenticateValues(authResp.Raw);
inToken = ExtractSchemeToken(challenges, scheme);
resp = authResp;
}
return resp;
}
static async Task SendConnectRequest(NetworkStream stream, string host, int port, string proxyAuthHeader)
{
var sb = new StringBuilder();
sb.Append($"CONNECT {host}:{port} HTTP/1.1\r\n");
sb.Append($"Host: {host}:{port}\r\n");
sb.Append("Proxy-Connection: Keep-Alive\r\n");
if (!string.IsNullOrWhiteSpace(proxyAuthHeader))
sb.Append(proxyAuthHeader).Append("\r\n");
sb.Append("\r\n");
await stream.WriteAsync(Encoding.ASCII.GetBytes(sb.ToString()));
}
static List<string> ParseProxyAuthenticateValues(string rawHeaders)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(rawHeaders))
return result;
foreach (var line in rawHeaders.Split("\r\n", StringSplitOptions.None))
{
if (line.StartsWith("Proxy-Authenticate:", StringComparison.OrdinalIgnoreCase))
{
var value = line.Substring("Proxy-Authenticate:".Length).Trim();
if (value.Length > 0)
result.Add(value);
}
}
return result;
}
static string ExtractSchemeToken(List<string> values, string scheme)
{
foreach (var v in values)
{
if (!v.StartsWith(scheme, StringComparison.OrdinalIgnoreCase))
continue;
var parts = v.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2)
return parts[1].Trim();
return null;
}
return null;
}
}
sealed class SspiProxyAuthenticator : IDisposable
{
const int SECPKG_CRED_OUTBOUND = 2;
const int SECURITY_NATIVE_DREP = 0x00000010;
const int ISC_REQ_CONNECTION = 0x00000800;
const int SECBUFFER_VERSION = 0;
const int SECBUFFER_TOKEN = 2;
const uint SEC_I_CONTINUE_NEEDED = 0x00090312;
const uint SEC_E_OK = 0x00000000;
SecHandle _cred;
SecHandle _ctx;
bool _credAcquired;
bool _ctxInitialized;
readonly string _package;
readonly string _targetSpn;
public SspiProxyAuthenticator(string package, string proxyHost)
{
_package = package;
_targetSpn = $"HTTP/{proxyHost}";
AcquireCredentials();
}
public string NextToken(string incomingBase64Token)
{
byte[] inBytes = null;
if (!string.IsNullOrWhiteSpace(incomingBase64Token))
{
try { inBytes = Convert.FromBase64String(incomingBase64Token); }
catch { inBytes = null; }
}
var outBuffer = new SecBuffer
{
cbBuffer = 0,
BufferType = SECBUFFER_TOKEN,
pvBuffer = IntPtr.Zero
};
var outDesc = new SecBufferDesc();
IntPtr outBufPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
try
{
Marshal.StructureToPtr(outBuffer, outBufPtr, false);
outDesc.ulVersion = SECBUFFER_VERSION;
outDesc.cBuffers = 1;
outDesc.pBuffers = outBufPtr;
uint attrs;
FILETIME expiry;
uint status;
IntPtr inDescPtr = IntPtr.Zero;
IntPtr inBufPtr = IntPtr.Zero;
IntPtr inTokenPtr = IntPtr.Zero;
try
{
if (inBytes != null && inBytes.Length > 0)
{
inTokenPtr = Marshal.AllocHGlobal(inBytes.Length);
Marshal.Copy(inBytes, 0, inTokenPtr, inBytes.Length);
var inBuf = new SecBuffer
{
cbBuffer = inBytes.Length,
BufferType = SECBUFFER_TOKEN,
pvBuffer = inTokenPtr
};
inBufPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
Marshal.StructureToPtr(inBuf, inBufPtr, false);
var inDesc = new SecBufferDesc
{
ulVersion = SECBUFFER_VERSION,
cBuffers = 1,
pBuffers = inBufPtr
};
inDescPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBufferDesc>());
Marshal.StructureToPtr(inDesc, inDescPtr, false);
}
if (_ctxInitialized)
{
status = InitializeSecurityContext(
ref _cred,
ref _ctx,
_targetSpn,
ISC_REQ_CONNECTION,
0,
SECURITY_NATIVE_DREP,
inDescPtr,
0,
out _ctx,
out outDesc,
out attrs,
out expiry);
}
else
{
status = InitializeSecurityContext(
ref _cred,
IntPtr.Zero,
_targetSpn,
ISC_REQ_CONNECTION,
0,
SECURITY_NATIVE_DREP,
inDescPtr,
0,
out _ctx,
out outDesc,
out attrs,
out expiry);
}
}
finally
{
if (inTokenPtr != IntPtr.Zero) Marshal.FreeHGlobal(inTokenPtr);
if (inBufPtr != IntPtr.Zero) Marshal.FreeHGlobal(inBufPtr);
if (inDescPtr != IntPtr.Zero) Marshal.FreeHGlobal(inDescPtr);
}
_ctxInitialized = true;
if (status != SEC_E_OK && status != SEC_I_CONTINUE_NEEDED)
return null;
var finalOut = Marshal.PtrToStructure<SecBuffer>(outBufPtr);
if (finalOut.cbBuffer <= 0 || finalOut.pvBuffer == IntPtr.Zero)
return null;
var token = new byte[finalOut.cbBuffer];
Marshal.Copy(finalOut.pvBuffer, token, 0, token.Length);
FreeContextBuffer(finalOut.pvBuffer);
return Convert.ToBase64String(token);
}
finally
{
Marshal.FreeHGlobal(outBufPtr);
}
}
void AcquireCredentials()
{
FILETIME expiry;
uint status = AcquireCredentialsHandle(
null,
_package,
SECPKG_CRED_OUTBOUND,
IntPtr.Zero,
IntPtr.Zero,
IntPtr.Zero,
IntPtr.Zero,
out _cred,
out expiry);
_credAcquired = status == SEC_E_OK;
}
public void Dispose()
{
if (_ctxInitialized)
DeleteSecurityContext(ref _ctx);
if (_credAcquired)
FreeCredentialsHandle(ref _cred);
}
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
static extern uint AcquireCredentialsHandle(
string pszPrincipal,
string pszPackage,
int fCredentialUse,
IntPtr pvLogonId,
IntPtr pAuthData,
IntPtr pGetKeyFn,
IntPtr pvGetKeyArgument,
out SecHandle phCredential,
out FILETIME ptsExpiry);
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
static extern uint InitializeSecurityContext(
ref SecHandle phCredential,
IntPtr phContext,
string pszTargetName,
int fContextReq,
int Reserved1,
int TargetDataRep,
IntPtr pInput,
int Reserved2,
out SecHandle phNewContext,
out SecBufferDesc pOutput,
out uint pfContextAttr,
out FILETIME ptsExpiry);
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
static extern uint InitializeSecurityContext(
ref SecHandle phCredential,
ref SecHandle phContext,
string pszTargetName,
int fContextReq,
int Reserved1,
int TargetDataRep,
IntPtr pInput,
int Reserved2,
out SecHandle phNewContext,
out SecBufferDesc pOutput,
out uint pfContextAttr,
out FILETIME ptsExpiry);
[DllImport("secur32.dll", SetLastError = false)]
static extern uint DeleteSecurityContext(ref SecHandle phContext);
[DllImport("secur32.dll", SetLastError = false)]
static extern uint FreeCredentialsHandle(ref SecHandle phCredential);
[DllImport("secur32.dll", SetLastError = false)]
static extern uint FreeContextBuffer(IntPtr pvContextBuffer);
[StructLayout(LayoutKind.Sequential)]
struct SecHandle
{
public IntPtr dwLower;
public IntPtr dwUpper;
}
[StructLayout(LayoutKind.Sequential)]
struct SecBuffer
{
public int cbBuffer;
public int BufferType;
public IntPtr pvBuffer;
}
[StructLayout(LayoutKind.Sequential)]
struct SecBufferDesc
{
public int ulVersion;
public int cBuffers;
public IntPtr pBuffers;
}
}
// ================= WINHTTP =================

View File

@@ -12,12 +12,10 @@ if not exist "%EXE%" (
set "UPSTREAM_PROXY=proxy.syktsu.ru:3128"
set "UPSTREAM_USER=%USERNAME%"
set "UPSTREAM_PASS="
set "UPSTREAM_ALLOW_EMPTY_PASSWORD=0"
echo [run] UPSTREAM_PROXY=%UPSTREAM_PROXY%
echo [run] UPSTREAM_USER=%UPSTREAM_USER%
echo [run] UPSTREAM_PASS is empty
echo [run] UPSTREAM_ALLOW_EMPTY_PASSWORD=%UPSTREAM_ALLOW_EMPTY_PASSWORD%
echo [run] Starting proxy...
"%EXE%"