diff --git a/cmd/ck-client/ck-client.go b/cmd/ck-client/ck-client.go index 8c2067d..67f37c3 100644 --- a/cmd/ck-client/ck-client.go +++ b/cmd/ck-client/ck-client.go @@ -20,16 +20,9 @@ import ( var version string func pipe(dst io.ReadWriteCloser, src io.ReadWriteCloser) { - buf := make([]byte, 20480) for { - i, err := src.Read(buf) - if err != nil { - go dst.Close() - go src.Close() - return - } - _, err = dst.Write(buf[:i]) - if err != nil { + i, err := io.Copy(dst, src) + if err != nil || i == 0 { go dst.Close() go src.Close() return diff --git a/cmd/ck-server/ck-server.go b/cmd/ck-server/ck-server.go new file mode 100644 index 0000000..255af7f --- /dev/null +++ b/cmd/ck-server/ck-server.go @@ -0,0 +1,209 @@ +package main + +import ( + "flag" + "fmt" + "io" + "log" + "net" + "os" + "strings" + "time" + + mux "github.com/cbeuw/Cloak/internal/multiplex" + "github.com/cbeuw/Cloak/internal/server" + "github.com/cbeuw/Cloak/internal/util" +) + +var version string + +func pipe(dst io.ReadWriteCloser, src io.ReadWriteCloser) { + for { + i, err := io.Copy(dst, src) + if err != nil || i == 0 { + go dst.Close() + go src.Close() + return + } + } +} + +func dispatchConnection(conn net.Conn, sta *server.State) { + goWeb := func(data []byte) { + webConn, err := net.Dial("tcp", sta.WebServerAddr) + if err != nil { + log.Printf("Making connection to redirection server: %v\n", err) + go webConn.Close() + return + } + webConn.Write(data) + go pipe(webConn, conn) + go pipe(conn, webConn) + } + + buf := make([]byte, 1500) + + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + i, err := io.ReadAtLeast(conn, buf, 1) + if err != nil { + go conn.Close() + return + } + conn.SetReadDeadline(time.Time{}) + data := buf[:i] + ch, err := server.ParseClientHello(data) + if err != nil { + log.Printf("+1 non SS non (or malformed) TLS traffic from %v\n", conn.RemoteAddr()) + goWeb(data) + return + } + + isSS, SID := server.TouchStone(ch, sta) + if !isSS { + log.Printf("+1 non SS TLS traffic from %v\n", conn.RemoteAddr()) + goWeb(data) + return + } + + // TODO: verify SID + + reply := server.ComposeReply(ch) + _, err = conn.Write(reply) + if err != nil { + log.Printf("Sending reply to remote: %v\n", err) + go conn.Close() + return + } + + // Two discarded messages: ChangeCipherSpec and Finished + discardBuf := make([]byte, 1024) + for c := 0; c < 2; c++ { + _, err = util.ReadTillDrain(conn, discardBuf) + if err != nil { + log.Printf("Reading discarded message %v: %v\n", c, err) + go conn.Close() + return + } + } + + go func() { + var arrSID [32]byte + copy(arrSID[:], SID) + sesh := sta.GetSession(arrSID) + if sesh == nil { + sesh.AddConnection(conn) + } else { + sesh := mux.MakeSession(0, conn, util.MakeObfs(SID), util.MakeDeobfs(SID), util.ReadTillDrain) + sta.PutSession(arrSID, sesh) + } + go func() { + for { + newStream, err := sesh.AcceptStream() + if err != nil { + log.Printf("Failed to get new stream: %v", err) + } + ssConn, err := net.Dial("tcp", sta.SS_LOCAL_HOST+":"+sta.SS_LOCAL_PORT) + if err != nil { + log.Printf("Failed to connect to ssserver: %v", err) + } + go pipe(ssConn, newStream) + go pipe(newStream, ssConn) + } + }() + }() + +} + +func main() { + // Should be 127.0.0.1 to listen to ss-server on this machine + var localHost string + // server_port in ss config, same as remotePort in plugin mode + var localPort string + // server in ss config, the outbound listening ip + var remoteHost string + // Outbound listening ip, should be 443 + var remotePort string + var pluginOpts string + + log.SetFlags(log.LstdFlags | log.Lshortfile) + + if os.Getenv("SS_LOCAL_HOST") != "" { + localHost = os.Getenv("SS_LOCAL_HOST") + localPort = os.Getenv("SS_LOCAL_PORT") + remoteHost = os.Getenv("SS_REMOTE_HOST") + remotePort = os.Getenv("SS_REMOTE_PORT") + pluginOpts = os.Getenv("SS_PLUGIN_OPTIONS") + } else { + localAddr := flag.String("r", "", "localAddr: 127.0.0.1:server_port as set in SS config") + flag.StringVar(&remoteHost, "s", "0.0.0.0", "remoteHost: outbound listing ip, set to 0.0.0.0 to listen to everything") + flag.StringVar(&remotePort, "p", "443", "remotePort: outbound listing port, should be 443") + flag.StringVar(&pluginOpts, "c", "server.json", "pluginOpts: path to server.json or options seperated by semicolons") + askVersion := flag.Bool("v", false, "Print the version number") + printUsage := flag.Bool("h", false, "Print this message") + flag.Parse() + + if *askVersion { + fmt.Printf("ck-server %s\n", version) + return + } + + if *printUsage { + flag.Usage() + return + } + + if *localAddr == "" { + log.Fatal("Must specify localAddr") + } + localHost = strings.Split(*localAddr, ":")[0] + localPort = strings.Split(*localAddr, ":")[1] + log.Printf("Starting standalone mode, listening on %v:%v to ss at %v:%v\n", remoteHost, remotePort, localHost, localPort) + } + sta := &server.State{ + SS_LOCAL_HOST: localHost, + SS_LOCAL_PORT: localPort, + SS_REMOTE_HOST: remoteHost, + SS_REMOTE_PORT: remotePort, + Now: time.Now, + UsedRandom: map[[32]byte]int{}, + } + err := sta.ParseConfig(pluginOpts) + if err != nil { + log.Fatalf("Configuration file error: %v", err) + } + + go sta.UsedRandomCleaner() + + listen := func(addr, port string) { + listener, err := net.Listen("tcp", addr+":"+port) + log.Println("Listening on " + addr + ":" + port) + if err != nil { + log.Fatal(err) + } + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("%v", err) + continue + } + go dispatchConnection(conn, sta) + } + } + + // When listening on an IPv6 and IPv4, SS gives REMOTE_HOST as e.g. ::|0.0.0.0 + listeningIP := strings.Split(sta.SS_REMOTE_HOST, "|") + for i, ip := range listeningIP { + if net.ParseIP(ip).To4() == nil { + // IPv6 needs square brackets + ip = "[" + ip + "]" + } + + // The last listener must block main() because the program exits on main return. + if i == len(listeningIP)-1 { + listen(ip, sta.SS_REMOTE_PORT) + } else { + go listen(ip, sta.SS_REMOTE_PORT) + } + } + +} diff --git a/config/ckclient.json b/config/ckclient.json new file mode 100644 index 0000000..73af9f5 --- /dev/null +++ b/config/ckclient.json @@ -0,0 +1,6 @@ +{ + "ServerName":"www.bing.com", + "Key":"UNhY4JhezH9gQYqvDMWrWH9CwlcKiECVqejMrND2VFwEOF8c8XRX8iYVdjKW2BAfym2zppExMPteovDB/Q8phdD53FnH39tQ1daaVLn9+FIGOAdk+UZZ2aOt5jSK638YPg==", + "TicketTimeHint":3600, + "Browser":"chrome" +} diff --git a/config/ckserver.json b/config/ckserver.json new file mode 100644 index 0000000..bb4f0df --- /dev/null +++ b/config/ckserver.json @@ -0,0 +1,4 @@ +{ + "WebServerAddr":"204.79.197.200:443", + "Key":"H2pMM834RzkouOoRGNhbiQRnm4Ggy8sg+S6ve5yYfqUEOF8c8XRX8iYVdjKW2BAfym2zppExMPteovDB/Q8phdD53FnH39tQ1daaVLn9+FIGOAdk+UZZ2aOt5jSK638YPg==" +} diff --git a/internal/client/auth.go b/internal/client/auth.go index fd5a00a..67fa4d7 100644 --- a/internal/client/auth.go +++ b/internal/client/auth.go @@ -47,7 +47,7 @@ func MakeSessionTicket(sta *State) []byte { // Then the hmac is 32 bytes // // 65+56+32=153 - ct, _ := ecies.Encrypt(rand.Reader, sta.Pub, plain, nil, nil) + ct, _ := ecies.Encrypt(rand.Reader, sta.pub, plain, nil, nil) sessionTicket := make([]byte, 192) // The reason for ct[1:] is that, the first byte of ct is always 0x04 // This is specified in the section 4.3.6 of ANSI X9.62 (the uncompressed form). diff --git a/internal/client/state.go b/internal/client/state.go index 8302a48..0236fdc 100644 --- a/internal/client/state.go +++ b/internal/client/state.go @@ -27,7 +27,7 @@ type State struct { SS_REMOTE_PORT string Now func() time.Time SID []byte - Pub *ecies.PublicKey + pub *ecies.PublicKey TicketTimeHint int ServerName string MaskBrowser string @@ -88,10 +88,11 @@ func (sta *State) ParseConfig(conf string) (err error) { return errors.New("Failed to parse Key: " + err.Error()) } sta.SID = sid - sta.Pub = pub + sta.pub = pub return nil } +// Structure: [SID 32 bytes][marshalled public key] func parseKey(b64 string) ([]byte, *ecies.PublicKey, error) { b, err := base64.StdEncoding.DecodeString(b64) if err != nil { diff --git a/internal/server/TLS.go b/internal/server/TLS.go new file mode 100644 index 0000000..80d5576 --- /dev/null +++ b/internal/server/TLS.go @@ -0,0 +1,163 @@ +package server + +import ( + "encoding/binary" + "errors" + "time" + + "github.com/cbeuw/Cloak/internal/util" +) + +// ClientHello contains every field in a ClientHello message +type ClientHello struct { + handshakeType byte + length int + clientVersion []byte + random []byte + sessionIdLen int + sessionId []byte + cipherSuitesLen int + cipherSuites []byte + compressionMethodsLen int + compressionMethods []byte + extensionsLen int + extensions map[[2]byte][]byte +} + +func parseExtensions(input []byte) (ret map[[2]byte][]byte, err error) { + defer func() { + if r := recover(); r != nil { + err = errors.New("Malformed Extensions") + } + }() + pointer := 0 + totalLen := len(input) + ret = make(map[[2]byte][]byte) + for pointer < totalLen { + var typ [2]byte + copy(typ[:], input[pointer:pointer+2]) + pointer += 2 + length := util.BtoInt(input[pointer : pointer+2]) + pointer += 2 + data := input[pointer : pointer+length] + pointer += length + ret[typ] = data + } + return ret, err +} + +// AddRecordLayer adds record layer to data +func AddRecordLayer(input []byte, typ []byte, ver []byte) []byte { + length := make([]byte, 2) + binary.BigEndian.PutUint16(length, uint16(len(input))) + ret := make([]byte, 5+len(input)) + copy(ret[0:1], typ) + copy(ret[1:3], ver) + copy(ret[3:5], length) + copy(ret[5:], input) + return ret +} + +// PeelRecordLayer peels off the record layer +func PeelRecordLayer(data []byte) []byte { + ret := data[5:] + return ret +} + +// ParseClientHello parses everything on top of the TLS layer +// (including the record layer) into ClientHello type +func ParseClientHello(data []byte) (ret *ClientHello, err error) { + defer func() { + if r := recover(); r != nil { + err = errors.New("Malformed ClientHello") + } + }() + data = PeelRecordLayer(data) + pointer := 0 + // Handshake Type + handshakeType := data[pointer] + if handshakeType != 0x01 { + return ret, errors.New("Not a ClientHello") + } + pointer += 1 + // Length + length := util.BtoInt(data[pointer : pointer+3]) + pointer += 3 + if length != len(data[pointer:]) { + return ret, errors.New("Hello length doesn't match") + } + // Client Version + clientVersion := data[pointer : pointer+2] + pointer += 2 + // Random + random := data[pointer : pointer+32] + pointer += 32 + // Session ID + sessionIdLen := int(data[pointer]) + pointer += 1 + sessionId := data[pointer : pointer+sessionIdLen] + pointer += sessionIdLen + // Cipher Suites + cipherSuitesLen := util.BtoInt(data[pointer : pointer+2]) + pointer += 2 + cipherSuites := data[pointer : pointer+cipherSuitesLen] + pointer += cipherSuitesLen + // Compression Methods + compressionMethodsLen := int(data[pointer]) + pointer += 1 + compressionMethods := data[pointer : pointer+compressionMethodsLen] + pointer += compressionMethodsLen + // Extensions + extensionsLen := util.BtoInt(data[pointer : pointer+2]) + pointer += 2 + extensions, err := parseExtensions(data[pointer:]) + ret = &ClientHello{ + handshakeType, + length, + clientVersion, + random, + sessionIdLen, + sessionId, + cipherSuitesLen, + cipherSuites, + compressionMethodsLen, + compressionMethods, + extensionsLen, + extensions, + } + return +} + +func composeServerHello(ch *ClientHello) []byte { + var serverHello [10][]byte + serverHello[0] = []byte{0x02} // handshake type + serverHello[1] = []byte{0x00, 0x00, 0x4d} // length 77 + serverHello[2] = []byte{0x03, 0x03} // server version + serverHello[3] = util.PsudoRandBytes(32, time.Now().UnixNano()) // random + serverHello[4] = []byte{0x20} // session id length 32 + serverHello[5] = ch.sessionId // session id + serverHello[6] = []byte{0xc0, 0x30} // cipher suite TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + serverHello[7] = []byte{0x00} // compression method null + serverHello[8] = []byte{0x00, 0x05} // extensions length 5 + serverHello[9] = []byte{0xff, 0x01, 0x00, 0x01, 0x00} // extensions renegotiation_info + ret := []byte{} + for i := 0; i < 10; i++ { + ret = append(ret, serverHello[i]...) + } + return ret +} + +// ComposeReply composes the ServerHello, ChangeCipherSpec and Finished messages +// together with their respective record layers into one byte slice. The content +// of these messages are random and useless for this plugin +func ComposeReply(ch *ClientHello) []byte { + TLS12 := []byte{0x03, 0x03} + shBytes := AddRecordLayer(composeServerHello(ch), []byte{0x16}, TLS12) + ccsBytes := AddRecordLayer([]byte{0x01}, []byte{0x14}, TLS12) + finished := make([]byte, 64) + finished = util.PsudoRandBytes(40, time.Now().UnixNano()) + fBytes := AddRecordLayer(finished, []byte{0x16}, TLS12) + ret := append(shBytes, ccsBytes...) + ret = append(ret, fBytes...) + return ret +} diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..7a3c2d6 --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,55 @@ +package server + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "github.com/cbeuw/ecies" + "log" +) + +// input ticket, return SID +func decryptSessionTicket(pv *ecies.PrivateKey, ticket []byte) ([]byte, error) { + ciphertext := make([]byte, 153) + ciphertext[0] = 0x04 + copy(ciphertext[1:], ticket) + plaintext, err := pv.Decrypt(ciphertext, nil, nil) + if err != nil { + return nil, err + } + return plaintext[0:32], nil +} + +func validateRandom(random []byte, SID []byte, time int64) bool { + t := make([]byte, 8) + binary.BigEndian.PutUint64(t, uint64(time/12*60*60)) + rand := random[0:16] + preHash := make([]byte, 56) + copy(preHash[0:32], SID) + copy(preHash[32:40], t) + copy(preHash[40:56], rand) + h := sha256.New() + h.Write(preHash) + return bytes.Equal(h.Sum(nil)[0:16], random[16:32]) +} +func TouchStone(ch *ClientHello, sta *State) (bool, []byte) { + var random [32]byte + copy(random[:], ch.random) + used := sta.getUsedRandom(random) + if used != 0 { + log.Println("Replay! Duplicate random") + return false, nil + } + sta.putUsedRandom(random) + + SID, err := decryptSessionTicket(sta.pv, ch.extensions[[2]byte{0x00, 0x23}]) + if err != nil { + return false, nil + } + isSS := validateRandom(ch.random, SID, sta.Now().Unix()) + if !isSS { + return false, nil + } + + return true, SID +} diff --git a/internal/server/state.go b/internal/server/state.go new file mode 100644 index 0000000..2f3aabe --- /dev/null +++ b/internal/server/state.go @@ -0,0 +1,159 @@ +package server + +import ( + "crypto/elliptic" + "encoding/base64" + "encoding/json" + "io/ioutil" + "math/big" + "strings" + "sync" + "time" + + mux "github.com/cbeuw/Cloak/internal/multiplex" + "github.com/cbeuw/ecies" +) + +type rawConfig struct { + WebServerAddr string + Key string +} +type stateManager interface { + ParseConfig(string) error + SetAESKey(string) + PutUsedRandom([32]byte) +} + +// State type stores the global state of the program +type State struct { + WebServerAddr string + Now func() time.Time + SS_LOCAL_HOST string + SS_LOCAL_PORT string + SS_REMOTE_HOST string + SS_REMOTE_PORT string + UsedRandomM sync.RWMutex + UsedRandom map[[32]byte]int + pv *ecies.PrivateKey + + SessionsM sync.RWMutex + Sessions map[[32]byte]*mux.Session +} + +// semi-colon separated value. +func ssvToJson(ssv string) (ret []byte) { + unescape := func(s string) string { + r := strings.Replace(s, "\\\\", "\\", -1) + r = strings.Replace(r, "\\=", "=", -1) + r = strings.Replace(r, "\\;", ";", -1) + return r + } + lines := strings.Split(unescape(ssv), ";") + ret = []byte("{") + for _, ln := range lines { + if ln == "" { + break + } + sp := strings.SplitN(ln, "=", 2) + key := sp[0] + value := sp[1] + ret = append(ret, []byte("\""+key+"\":\""+value+"\",")...) + + } + ret = ret[:len(ret)-1] // remove the last comma + ret = append(ret, '}') + return ret +} + +// Structue: [D 32 bytes][marshalled public key] +func parseKey(b64 string) (*ecies.PrivateKey, error) { + b, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, err + } + + d := b[0:32] + marshalled := b[32:] + x, y := elliptic.Unmarshal(ecies.DefaultCurve, marshalled) + pub := ecies.PublicKey{ + X: x, + Y: y, + Curve: ecies.DefaultCurve, + Params: ecies.ParamsFromCurve(ecies.DefaultCurve), + } + + pv := &ecies.PrivateKey{ + PublicKey: pub, + D: new(big.Int).SetBytes(d), + } + return pv, nil +} + +// ParseConfig parses the config (either a path to json or in-line ssv config) into a State variable +func (sta *State) ParseConfig(conf string) (err error) { + var content []byte + if strings.Contains(conf, ";") && strings.Contains(conf, "=") { + content = ssvToJson(conf) + } else { + content, err = ioutil.ReadFile(conf) + if err != nil { + return err + } + } + + var preParse rawConfig + err = json.Unmarshal(content, &preParse) + if err != nil { + return err + } + + sta.WebServerAddr = preParse.WebServerAddr + pv, err := parseKey(preParse.Key) + sta.pv = pv + return nil +} + +func (sta *State) GetSession(SID [32]byte) *mux.Session { + sta.SessionsM.Lock() + defer sta.SessionsM.Unlock() + if sesh, ok := sta.Sessions[SID]; ok { + return sesh + } else { + return nil + } +} + +func (sta *State) PutSession(SID [32]byte, sesh *mux.Session) { + sta.SessionsM.Lock() + sta.Sessions[SID] = sesh + sta.SessionsM.Unlock() +} + +func (sta *State) getUsedRandom(random [32]byte) int { + sta.UsedRandomM.Lock() + defer sta.UsedRandomM.Unlock() + return sta.UsedRandom[random] + +} + +// PutUsedRandom adds a random field into map UsedRandom +func (sta *State) putUsedRandom(random [32]byte) { + sta.UsedRandomM.Lock() + sta.UsedRandom[random] = int(sta.Now().Unix()) + sta.UsedRandomM.Unlock() +} + +// UsedRandomCleaner clears the cache of used random fields every 12 hours +func (sta *State) UsedRandomCleaner() { + for { + time.Sleep(12 * time.Hour) + now := int(sta.Now().Unix()) + sta.UsedRandomM.Lock() + for key, t := range sta.UsedRandom { + if now-t > 12*3600 { + delete(sta.UsedRandom, key) + } + } + sta.UsedRandomM.Unlock() + } +} diff --git a/internal/util/obfs.go b/internal/util/obfs.go index 9e0e292..895e4dc 100644 --- a/internal/util/obfs.go +++ b/internal/util/obfs.go @@ -33,16 +33,17 @@ func MakeObfs(key []byte) func(*mux.Frame) []byte { binary.BigEndian.PutUint32(header[4:8], f.Seq) binary.BigEndian.PutUint32(header[8:12], f.ClosingStreamID) // header: [StreamID 4 bytes][Seq 4 bytes][ClosingStreamID 4 bytes] - plaintext := make([]byte, 12+len(f.Payload)-16) - copy(plaintext[0:12], header) - copy(plaintext[12:], f.Payload[16:]) - // plaintext: [header 12 bytes][Payload[16:]] + plainheader := make([]byte, 16) + copy(plainheader[0:12], header) + copy(plainheader[12:], []byte{0x00, 0x00, 0x00, 0x00}) + // plainheader: [header 12 bytes][0x00,0x00,0x00,0x00] iv := f.Payload[0:16] - ciphertext := encrypt(iv, key, plaintext) - obfsed := make([]byte, 16+len(ciphertext)) + cipherheader := encrypt(iv, key, plainheader) + obfsed := make([]byte, len(f.Payload)+12+4) copy(obfsed[0:16], iv) - copy(obfsed[16:], ciphertext) - // obfsed: [iv 16 bytes][ciphertext] + copy(obfsed[16:32], cipherheader) + copy(obfsed[32:], f.Payload[16:]) + // obfsed: [iv 16 bytes][cipherheader 16 bytes][payload w/o iv] ret := AddRecordLayer(obfsed, []byte{0x17}, []byte{0x03, 0x03}) return ret } @@ -52,14 +53,14 @@ func MakeObfs(key []byte) func(*mux.Frame) []byte { func MakeDeobfs(key []byte) func([]byte) *mux.Frame { deobfs := func(in []byte) *mux.Frame { peeled := PeelRecordLayer(in) - plaintext := decrypt(peeled[0:16], key, peeled[16:]) - // plaintext: [header 12 bytes][Payload[16:]] - streamID := binary.BigEndian.Uint32(plaintext[0:4]) - seq := binary.BigEndian.Uint32(plaintext[4:8]) - closingStreamID := binary.BigEndian.Uint32(plaintext[8:12]) - payload := make([]byte, len(plaintext)-12) + plainheader := decrypt(peeled[0:16], key, peeled[16:32]) + // plainheader: [header 12 bytes][0x00,0x00,0x00,0x00] + streamID := binary.BigEndian.Uint32(plainheader[0:4]) + seq := binary.BigEndian.Uint32(plainheader[4:8]) + closingStreamID := binary.BigEndian.Uint32(plainheader[8:12]) + payload := make([]byte, len(peeled)-12-4) copy(payload[0:16], peeled[0:16]) - copy(payload[16:], plaintext[12:]) + copy(payload[16:], peeled[32:]) ret := &mux.Frame{ StreamID: streamID, Seq: seq,