From c5462369c719f78ec15179079ab0f5f27d9f36e6 Mon Sep 17 00:00:00 2001 From: Harrison-Wells-Cyber Date: Thu, 23 Jul 2026 07:50:47 -0700 Subject: [PATCH] Serialize agent secure frame construction --- README.md | 13 ++++- agent/PSProxy.Agent/PSProxy.Agent.cs | 76 +++++++++++++++++++------ cmd/psproxy-server/main.go | 84 +++++++++++++++++++++++++++- cmd/psproxy-server/main_test.go | 71 +++++++++++++++++++++++ 4 files changed, 223 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 73bd6f7..7a79920 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,13 @@ the original destination with `SO_ORIGINAL_DST`, sends that host:port through th encrypted tunnel, and the Windows agent opens the target with a normal outbound `TcpClient` from its network position. +The server owns its configured `--listen`/`--port` socket while it is running. +By default that is `0.0.0.0:443`, so another web server or reverse proxy cannot +bind port 443 on the same IP at the same time. If you need another service on +443, either bind PS-Proxy to a different address with `--listen`, run PS-Proxy on +a different external port with `--port`, or put a fronting load balancer/reverse +proxy on 443 and forward PS-Proxy traffic to its own backend port. + The server prints a one-time command: ```powershell @@ -148,7 +155,11 @@ ldapsearch -x -H ldap://127.0.0.1:1389 -D 'user@example.local' -W -b 'DC=example Transparent redirect mode is the recommended test-environment workflow right now. The fixed-target relay remains useful when you want a single local port mapped to a single target for debugging. Use `--max-streams` to cap concurrent proxied TCP -connections during high-concurrency tools such as NetExec; the default is 256. When `--dns-listen` and `--dns-target` are set together, the server exposes a UDP DNS listener and forwards raw DNS queries through the enrolled agent to the internal DNS server reachable from the agent host. +connections during high-concurrency tools such as NetExec; the default is 256. +When `--dns-listen` and `--dns-target` are set together, the server exposes UDP +and TCP DNS listeners on the same local address and forwards raw DNS queries +through the enrolled agent to the internal DNS server reachable from the agent +host. ## Security notes diff --git a/agent/PSProxy.Agent/PSProxy.Agent.cs b/agent/PSProxy.Agent/PSProxy.Agent.cs index 6cb7091..044d079 100644 --- a/agent/PSProxy.Agent/PSProxy.Agent.cs +++ b/agent/PSProxy.Agent/PSProxy.Agent.cs @@ -95,6 +95,9 @@ namespace PSProxy.Agent using (var rsa = new RSACryptoServiceProvider()) { rsa.ImportParameters(ParseRsaPublicKey(pubDer)); + // .NET Framework RSACryptoServiceProvider supports OAEP only as + // SHA-1 with the default empty OAEP label. The server accepts + // this format for PowerShell 5.1 agent compatibility. encSecret = rsa.Encrypt(secret, true); } string hello = "HELLO " + B64Url(encSecret) + " " + B64Url(nonce) + "\n"; @@ -196,6 +199,7 @@ namespace PSProxy.Agent udp.Send(payload, payload.Length); IPEndPoint ep = null; byte[] resp = udp.Receive(ref ep); + if (IsDnsTruncated(resp)) resp = QueryDnsOverTcp(host, dstPort, payload); SendFrame(new Frame(sid, FrameDNSReply, resp)); } } @@ -206,6 +210,35 @@ namespace PSProxy.Agent } } + private static bool IsDnsTruncated(byte[] resp) + { + return resp != null && resp.Length >= 4 && (resp[2] & 0x02) != 0; + } + + private static byte[] QueryDnsOverTcp(string host, int dstPort, byte[] payload) + { + using (var tcp = new TcpClient()) + { + tcp.NoDelay = true; + ConnectWithTimeout(tcp, host, dstPort, 5000); + tcp.ReceiveTimeout = 5000; + tcp.SendTimeout = 5000; + using (NetworkStream stream = tcp.GetStream()) + { + byte[] len = new byte[2]; + WriteU16BE(len, 0, payload.Length); + stream.Write(len, 0, len.Length); + stream.Write(payload, 0, payload.Length); + stream.Flush(); + + byte[] respLen = ReadExact(stream, 2); + int n = ReadU16BE(respLen, 0); + if (n == 0) throw new IOException("empty DNS TCP response"); + return ReadExact(stream, n); + } + } + } + private void PumpTargetToServer(StreamCtx ctx) { byte[] buf = new byte[32768]; @@ -243,24 +276,26 @@ namespace PSProxy.Agent { if (f.Payload == null) f.Payload = new byte[0]; if (f.Payload.Length > MaxPayload) throw new InvalidOperationException("frame payload too large"); - byte[] plainFrame = EncodePlainFrame(f); - byte[] seq = U64BE(sendSeq); - byte[] plain = Concat(seq, plainFrame); - byte[] padded = Pkcs7Pad(plain, 16); - byte[] iv = RandomBytes(16); - byte[] ct; - using (var aes = Aes.Create()) - { - aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.None; aes.Key = encKey; aes.IV = iv; - using (var enc = aes.CreateEncryptor()) { ct = enc.TransformFinalBlock(padded, 0, padded.Length); } - } - byte[] body = Concat(iv, ct); - byte[] tag; - using (var h = new HMACSHA256(macKey)) { tag = h.ComputeHash(Concat(seq, body)); } - byte[] rec = Concat(body, tag); - byte[] len = new byte[4]; WriteI32BE(len, 0, rec.Length); lock (sendLock) { + // The sequence number is part of both plaintext and HMAC input, so + // frame construction must be serialized with the write/increment. + byte[] plainFrame = EncodePlainFrame(f); + byte[] seq = U64BE(sendSeq); + byte[] plain = Concat(seq, plainFrame); + byte[] padded = Pkcs7Pad(plain, 16); + byte[] iv = RandomBytes(16); + byte[] ct; + using (var aes = Aes.Create()) + { + aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.None; aes.Key = encKey; aes.IV = iv; + using (var enc = aes.CreateEncryptor()) { ct = enc.TransformFinalBlock(padded, 0, padded.Length); } + } + byte[] body = Concat(iv, ct); + byte[] tag; + using (var h = new HMACSHA256(macKey)) { tag = h.ComputeHash(Concat(seq, body)); } + byte[] rec = Concat(body, tag); + byte[] len = new byte[4]; WriteI32BE(len, 0, rec.Length); tls.Write(len, 0, len.Length); tls.Write(rec, 0, rec.Length); tls.Flush(); sendSeq++; } @@ -305,12 +340,17 @@ namespace PSProxy.Agent } private byte[] ReadExact(int n) + { + return ReadExact(tls, n); + } + + private static byte[] ReadExact(Stream stream, int n) { byte[] b = new byte[n]; int off = 0; while (off < n) { - int got = tls.Read(b, off, n - off); + int got = stream.Read(b, off, n - off); if (got <= 0) throw new EndOfStreamException(); off += got; } @@ -381,6 +421,8 @@ namespace PSProxy.Agent private static int ReadLen(byte[] b, ref int o) { if (o >= b.Length) throw new CryptographicException("asn1 len"); int v = b[o++]; if ((v & 0x80) == 0) return v; int n = v & 0x7f; if (n < 1 || n > 4 || o + n > b.Length) throw new CryptographicException("asn1 len"); int len = 0; for (int i = 0; i < n; i++) len = (len << 8) | b[o++]; return len; } private static void WriteI32BE(byte[] b, int o, int v) { b[o] = (byte)(v >> 24); b[o + 1] = (byte)(v >> 16); b[o + 2] = (byte)(v >> 8); b[o + 3] = (byte)v; } private static int ReadI32BE(byte[] b, int o) { return ((int)b[o] << 24) | ((int)b[o + 1] << 16) | ((int)b[o + 2] << 8) | (int)b[o + 3]; } + private static void WriteU16BE(byte[] b, int o, int v) { b[o] = (byte)(v >> 8); b[o + 1] = (byte)v; } + private static int ReadU16BE(byte[] b, int o) { return ((int)b[o] << 8) | (int)b[o + 1]; } private static void WriteU64BE(byte[] b, int o, ulong v) { for (int i = 7; i >= 0; i--) { b[o + i] = (byte)v; v >>= 8; } } private static ulong ReadU64BE(byte[] b, int o) { ulong v = 0; for (int i = 0; i < 8; i++) v = (v << 8) | b[o + i]; return v; } } diff --git a/cmd/psproxy-server/main.go b/cmd/psproxy-server/main.go index 79ef21e..f9c2189 100644 --- a/cmd/psproxy-server/main.go +++ b/cmd/psproxy-server/main.go @@ -5,10 +5,12 @@ import ( "crypto/hmac" "crypto/rand" "crypto/rsa" + "crypto/sha1" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/binary" "encoding/hex" "encoding/json" "encoding/pem" @@ -446,8 +448,18 @@ func serveDNSRelay(listenAddr string, server *TunnelServer) { if err != nil { log.Fatalf("dns relay listen failed: %v", err) } - log.Printf("dns relay listening on udp://%s", listenAddr) - buf := make([]byte, 4096) + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + _ = conn.Close() + log.Fatalf("dns tcp relay listen failed: %v", err) + } + log.Printf("dns relay listening on udp://%s and tcp://%s", listenAddr, listenAddr) + go serveDNSTCP(ln, server) + serveDNSUDP(conn, server) +} + +func serveDNSUDP(conn *net.UDPConn, server *TunnelServer) { + buf := make([]byte, 65535) for { n, client, err := conn.ReadFromUDP(buf) if err != nil { @@ -466,6 +478,53 @@ func serveDNSRelay(listenAddr string, server *TunnelServer) { } } +func serveDNSTCP(ln net.Listener, server *TunnelServer) { + for { + conn, err := ln.Accept() + if err != nil { + log.Printf("dns tcp relay accept failed: %v", err) + return + } + go handleDNSTCPConn(conn, server) + } +} + +func handleDNSTCPConn(conn net.Conn, server *TunnelServer) { + defer conn.Close() + for { + var lenb [2]byte + if _, err := io.ReadFull(conn, lenb[:]); err != nil { + if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + log.Printf("dns tcp relay read length failed: %v", err) + } + return + } + n := int(binary.BigEndian.Uint16(lenb[:])) + if n == 0 { + return + } + query := make([]byte, n) + if _, err := io.ReadFull(conn, query); err != nil { + log.Printf("dns tcp relay read query failed: %v", err) + return + } + resp, err := server.QueryDNS(query) + if err != nil { + log.Printf("dns tcp relay query failed: %v", err) + return + } + if len(resp) > 65535 { + log.Printf("dns tcp relay response too large: %d", len(resp)) + return + } + binary.BigEndian.PutUint16(lenb[:], uint16(len(resp))) + if _, err := conn.Write(append(lenb[:], resp...)); err != nil { + log.Printf("dns tcp relay write failed: %v", err) + return + } + } +} + func serveTransparentRelay(listenAddr string, server *TunnelServer) { ln, err := net.Listen("tcp4", listenAddr) if err != nil { @@ -693,6 +752,25 @@ func handleAgent(conn net.Conn, br *bufio.Reader, server *TunnelServer) { server.ClearSession(a) } +func decryptSessionSecret(key *rsa.PrivateKey, encSecret []byte) ([]byte, error) { + label := []byte("PS-Proxy PSP1 session") + secret, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, key, encSecret, label) + if err == nil { + return secret, nil + } + + // RSACryptoServiceProvider.Encrypt(..., true), used by the .NET Framework + // PowerShell agent, means OAEP-SHA1 with the default empty OAEP label. Keep + // accepting that wire format so already-staged agents can enroll. + for _, legacyLabel := range [][]byte{nil, label} { + legacySecret, legacyErr := rsa.DecryptOAEP(sha1.New(), rand.Reader, key, encSecret, legacyLabel) + if legacyErr == nil { + return legacySecret, nil + } + } + return nil, err +} + func serverHandshake(conn net.Conn, br *bufio.Reader, key *rsa.PrivateKey) (*protocol.SecureCodec, error) { line, err := br.ReadString('\n') if err != nil { @@ -713,7 +791,7 @@ func serverHandshake(conn net.Conn, br *bufio.Reader, key *rsa.PrivateKey) (*pro if len(clientNonce) != 32 { return nil, errors.New("invalid client nonce") } - secret, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, key, encSecret, []byte("PS-Proxy PSP1 session")) + secret, err := decryptSessionSecret(key, encSecret) if err != nil { return nil, err } diff --git a/cmd/psproxy-server/main_test.go b/cmd/psproxy-server/main_test.go index f03a38c..5947b4a 100644 --- a/cmd/psproxy-server/main_test.go +++ b/cmd/psproxy-server/main_test.go @@ -5,9 +5,12 @@ import ( "crypto/hmac" "crypto/rand" "crypto/rsa" + "crypto/sha1" "crypto/sha256" "crypto/x509" "encoding/base64" + "encoding/binary" + "io" "net" "strings" "testing" @@ -84,6 +87,55 @@ func TestTunnelServerQueryDNS(t *testing.T) { } } +func TestServeDNSTCPForwardsLengthPrefixedQuery(t *testing.T) { + server := NewTunnelServer(staging.NewStore(time.Minute), 2) + agent, peer := net.Pipe() + defer peer.Close() + sess := NewAgentSession(agent, bufio.NewReader(agent), server.maxStreams) + defer sess.Close() + server.SetSession(sess) + go sess.Run() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go serveDNSTCP(ln, server) + + go func() { + f, err := protocol.ReadFrame(peer) + if err != nil { + return + } + if string(f.Payload) != "dns-query" { + return + } + _ = protocol.WriteFrame(peer, protocol.Frame{StreamID: f.StreamID, Type: protocol.FrameDNSReply, Payload: []byte("dns-response")}) + }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + var lenb [2]byte + binary.BigEndian.PutUint16(lenb[:], uint16(len("dns-query"))) + if _, err := conn.Write(append(lenb[:], []byte("dns-query")...)); err != nil { + t.Fatal(err) + } + if _, err := io.ReadFull(conn, lenb[:]); err != nil { + t.Fatal(err) + } + resp := make([]byte, binary.BigEndian.Uint16(lenb[:])) + if _, err := io.ReadFull(conn, resp); err != nil { + t.Fatal(err) + } + if string(resp) != "dns-response" { + t.Fatalf("unexpected dns tcp response: %q", resp) + } +} + func TestValidateRoutes(t *testing.T) { if err := validateRoutes([]string{"10.0.0.0/24", "192.168.1.10/32"}); err != nil { t.Fatalf("valid routes rejected: %v", err) @@ -131,6 +183,25 @@ func TestSingleListenerAcceptReturnsEOFAfterFirstConn(t *testing.T) { t.Fatal("second accept should return EOF") } } +func TestDecryptSessionSecretAcceptsAgentSHA1OAEP(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 3072) + if err != nil { + t.Fatal(err) + } + secret := []byte("0123456789abcdef0123456789abcdef") + enc, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, &key.PublicKey, secret, nil) + if err != nil { + t.Fatal(err) + } + got, err := decryptSessionSecret(key, enc) + if err != nil { + t.Fatal(err) + } + if string(got) != string(secret) { + t.Fatalf("got %q want %q", got, secret) + } +} + func TestServerSecureHandshakeEncryptedFrame(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 3072) if err != nil {