|
| 1 | +package callback |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "bytes" |
| 6 | + "context" |
| 7 | + "crypto/rand" |
| 8 | + "crypto/tls" |
| 9 | + "encoding/base64" |
| 10 | + "encoding/hex" |
| 11 | + "encoding/json" |
| 12 | + "fmt" |
| 13 | + "log" |
| 14 | + "net" |
| 15 | + "strings" |
| 16 | + "time" |
| 17 | + |
| 18 | + "github.com/BishopFox/joro/internal/event" |
| 19 | +) |
| 20 | + |
| 21 | +const ( |
| 22 | + ftpMaxLine = 4096 |
| 23 | + ftpIdleTimeout = 2 * time.Minute |
| 24 | + ftpMaxCommands = 100 |
| 25 | + ftpMaxPaths = 20 // bound per-session memory |
| 26 | +) |
| 27 | + |
| 28 | +// FTPServer listens for FTP connections and records them as callback |
| 29 | +// interactions. It is a capture-only server — it never opens a data channel, |
| 30 | +// completes a transfer, or authenticates a user. The correlation token is |
| 31 | +// expected in the USER argument or a path argument (e.g. CWD/RETR), which are |
| 32 | +// captured before any data transfer would occur. |
| 33 | +type FTPServer struct { |
| 34 | + store *Store |
| 35 | + broadcast chan<- any |
| 36 | + bindAddr string |
| 37 | + plainPort int |
| 38 | + tlsPort int |
| 39 | + tlsCfg *tls.Config |
| 40 | + plainListener net.Listener |
| 41 | + tlsListener net.Listener |
| 42 | +} |
| 43 | + |
| 44 | +// NewFTPServer creates an FTP capture server. plainPort=0 disables the plain |
| 45 | +// listener; tlsPort=0 disables the implicit-TLS (FTPS) listener. tlsCfg must be |
| 46 | +// non-nil if tlsPort>0. |
| 47 | +func NewFTPServer(store *Store, broadcast chan<- any, bindAddr string, |
| 48 | + plainPort, tlsPort int, tlsCfg *tls.Config) *FTPServer { |
| 49 | + return &FTPServer{ |
| 50 | + store: store, |
| 51 | + broadcast: broadcast, |
| 52 | + bindAddr: bindAddr, |
| 53 | + plainPort: plainPort, |
| 54 | + tlsPort: tlsPort, |
| 55 | + tlsCfg: tlsCfg, |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +// Start opens the configured listeners and accepts connections until ctx is |
| 60 | +// cancelled. |
| 61 | +func (s *FTPServer) Start(ctx context.Context) error { |
| 62 | + errCh := make(chan error, 2) |
| 63 | + |
| 64 | + if s.plainPort > 0 { |
| 65 | + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.bindAddr, s.plainPort)) |
| 66 | + if err != nil { |
| 67 | + return fmt.Errorf("ftp plain listen: %w", err) |
| 68 | + } |
| 69 | + s.plainListener = ln |
| 70 | + go s.acceptLoop(ln, false, errCh) |
| 71 | + } |
| 72 | + |
| 73 | + if s.tlsPort > 0 { |
| 74 | + if s.tlsCfg == nil { |
| 75 | + return fmt.Errorf("ftps requires a TLS config") |
| 76 | + } |
| 77 | + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.bindAddr, s.tlsPort)) |
| 78 | + if err != nil { |
| 79 | + return fmt.Errorf("ftps listen: %w", err) |
| 80 | + } |
| 81 | + s.tlsListener = ln |
| 82 | + go s.acceptLoop(ln, true, errCh) |
| 83 | + } |
| 84 | + |
| 85 | + go func() { |
| 86 | + <-ctx.Done() |
| 87 | + if s.plainListener != nil { |
| 88 | + s.plainListener.Close() //nolint:errcheck |
| 89 | + } |
| 90 | + if s.tlsListener != nil { |
| 91 | + s.tlsListener.Close() //nolint:errcheck |
| 92 | + } |
| 93 | + }() |
| 94 | + |
| 95 | + return <-errCh |
| 96 | +} |
| 97 | + |
| 98 | +func (s *FTPServer) acceptLoop(ln net.Listener, implicitTLS bool, errCh chan<- error) { |
| 99 | + for { |
| 100 | + conn, err := ln.Accept() |
| 101 | + if err != nil { |
| 102 | + errCh <- err |
| 103 | + return |
| 104 | + } |
| 105 | + go s.handleConnection(conn, implicitTLS) |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +// ftpSession holds per-connection state. |
| 110 | +type ftpSession struct { |
| 111 | + user string |
| 112 | + pass string |
| 113 | + pathArgs []string |
| 114 | + transcript bytes.Buffer |
| 115 | + isTLS bool |
| 116 | + sawUser bool |
| 117 | +} |
| 118 | + |
| 119 | +func (s *FTPServer) handleConnection(conn net.Conn, implicitTLS bool) { |
| 120 | + // Containment: this parses untrusted network input, so a panic must drop |
| 121 | + // the single connection rather than crash the process. |
| 122 | + defer func() { _ = recover() }() |
| 123 | + defer conn.Close() //nolint:errcheck |
| 124 | + |
| 125 | + if implicitTLS { |
| 126 | + tlsConn := tls.Server(conn, s.tlsCfg) |
| 127 | + if err := tlsConn.SetDeadline(time.Now().Add(ftpIdleTimeout)); err != nil { |
| 128 | + return |
| 129 | + } |
| 130 | + if err := tlsConn.Handshake(); err != nil { |
| 131 | + return |
| 132 | + } |
| 133 | + conn = tlsConn |
| 134 | + } |
| 135 | + |
| 136 | + sess := &ftpSession{isTLS: implicitTLS} |
| 137 | + // Record on exit (defer-guarded once) so we capture even clients that |
| 138 | + // disconnect without QUIT. |
| 139 | + defer s.record(conn, sess) |
| 140 | + |
| 141 | + rw := bufio.NewReadWriter(bufio.NewReaderSize(conn, ftpMaxLine+2), bufio.NewWriter(conn)) |
| 142 | + s.writeLine(rw, sess, "220 joro FTP ready") |
| 143 | + |
| 144 | + cmds := 0 |
| 145 | + for { |
| 146 | + conn.SetReadDeadline(time.Now().Add(ftpIdleTimeout)) //nolint:errcheck |
| 147 | + line, err := s.readLine(rw) |
| 148 | + if err != nil { |
| 149 | + return |
| 150 | + } |
| 151 | + sess.transcript.WriteString("C: ") |
| 152 | + sess.transcript.WriteString(line) |
| 153 | + sess.transcript.WriteString("\r\n") |
| 154 | + |
| 155 | + cmds++ |
| 156 | + if cmds > ftpMaxCommands { |
| 157 | + s.writeLine(rw, sess, "421 Too many commands") |
| 158 | + return |
| 159 | + } |
| 160 | + |
| 161 | + verb, rest := splitVerb(line) |
| 162 | + switch strings.ToUpper(verb) { |
| 163 | + case "USER": |
| 164 | + sess.user = rest |
| 165 | + sess.sawUser = true |
| 166 | + s.writeLine(rw, sess, "331 Password required") |
| 167 | + case "PASS": |
| 168 | + sess.pass = rest |
| 169 | + s.writeLine(rw, sess, "230 Login successful") |
| 170 | + case "SYST": |
| 171 | + s.writeLine(rw, sess, "215 UNIX Type: L8") |
| 172 | + case "FEAT": |
| 173 | + s.writeLines(rw, sess, []string{"211-Features:", "211 End"}) |
| 174 | + case "PWD", "XPWD": |
| 175 | + s.writeLine(rw, sess, `257 "/" is current directory`) |
| 176 | + case "TYPE": |
| 177 | + s.writeLine(rw, sess, "200 Type set to "+rest) |
| 178 | + case "OPTS": |
| 179 | + s.writeLine(rw, sess, "200 OK") |
| 180 | + case "NOOP": |
| 181 | + s.writeLine(rw, sess, "200 OK") |
| 182 | + case "CDUP": |
| 183 | + s.writeLine(rw, sess, "250 OK") |
| 184 | + case "CWD", "XCWD", "MKD", "XMKD", "RMD", "XRMD", "DELE", "SIZE", "MDTM", |
| 185 | + "RETR", "STOR", "STOU", "APPE", "LIST", "NLST", "MLSD", "MLST": |
| 186 | + s.addPath(sess, rest) |
| 187 | + // Refuse the data transfer; the path argument is already captured. |
| 188 | + switch strings.ToUpper(verb) { |
| 189 | + case "CWD", "XCWD", "CDUP": |
| 190 | + s.writeLine(rw, sess, "250 OK") |
| 191 | + case "MKD", "XMKD": |
| 192 | + s.writeLine(rw, sess, `257 "`+rest+`" created`) |
| 193 | + case "RMD", "XRMD", "DELE": |
| 194 | + s.writeLine(rw, sess, "250 OK") |
| 195 | + case "SIZE": |
| 196 | + s.writeLine(rw, sess, "213 0") |
| 197 | + case "MDTM": |
| 198 | + s.writeLine(rw, sess, "213 20200101000000") |
| 199 | + default: |
| 200 | + s.writeLine(rw, sess, "425 Can't open data connection") |
| 201 | + } |
| 202 | + case "PASV", "EPSV", "PORT", "EPRT": |
| 203 | + s.writeLine(rw, sess, "502 Command not implemented") |
| 204 | + case "AUTH": |
| 205 | + s.writeLine(rw, sess, "502 Command not implemented") |
| 206 | + case "QUIT": |
| 207 | + s.writeLine(rw, sess, "221 Bye") |
| 208 | + return |
| 209 | + default: |
| 210 | + s.writeLine(rw, sess, "502 Command not implemented") |
| 211 | + } |
| 212 | + } |
| 213 | +} |
| 214 | + |
| 215 | +func (s *FTPServer) addPath(sess *ftpSession, p string) { |
| 216 | + p = strings.TrimSpace(p) |
| 217 | + if p != "" && len(sess.pathArgs) < ftpMaxPaths { |
| 218 | + sess.pathArgs = append(sess.pathArgs, p) |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +func (s *FTPServer) readLine(rw *bufio.ReadWriter) (string, error) { |
| 223 | + line, err := rw.Reader.ReadString('\n') |
| 224 | + if err != nil { |
| 225 | + return "", err |
| 226 | + } |
| 227 | + if len(line) > ftpMaxLine { |
| 228 | + return "", fmt.Errorf("ftp: line too long") |
| 229 | + } |
| 230 | + return strings.TrimRight(line, "\r\n"), nil |
| 231 | +} |
| 232 | + |
| 233 | +func (s *FTPServer) writeLine(rw *bufio.ReadWriter, sess *ftpSession, line string) { |
| 234 | + sess.transcript.WriteString("S: ") |
| 235 | + sess.transcript.WriteString(line) |
| 236 | + sess.transcript.WriteString("\r\n") |
| 237 | + rw.WriteString(line + "\r\n") //nolint:errcheck |
| 238 | + rw.Flush() //nolint:errcheck |
| 239 | +} |
| 240 | + |
| 241 | +func (s *FTPServer) writeLines(rw *bufio.ReadWriter, sess *ftpSession, lines []string) { |
| 242 | + for _, l := range lines { |
| 243 | + sess.transcript.WriteString("S: ") |
| 244 | + sess.transcript.WriteString(l) |
| 245 | + sess.transcript.WriteString("\r\n") |
| 246 | + rw.WriteString(l + "\r\n") //nolint:errcheck |
| 247 | + } |
| 248 | + rw.Flush() //nolint:errcheck |
| 249 | +} |
| 250 | + |
| 251 | +// record correlates and stores the session. It runs once via defer; it no-ops |
| 252 | +// if no USER was seen (bare TCP probe) or no token correlates. |
| 253 | +func (s *FTPServer) record(conn net.Conn, sess *ftpSession) { |
| 254 | + if !sess.sawUser { |
| 255 | + return |
| 256 | + } |
| 257 | + |
| 258 | + candidates := append([]string{sess.user}, sess.pathArgs...) |
| 259 | + candidates = append(candidates, sess.transcript.String()) |
| 260 | + token, err := CorrelateAny(s.store, candidates...) |
| 261 | + if err != nil { |
| 262 | + return |
| 263 | + } |
| 264 | + |
| 265 | + headers, _ := json.Marshal(map[string]string{ |
| 266 | + "user": sess.user, |
| 267 | + "pass": sess.pass, |
| 268 | + "path_args": strings.Join(sess.pathArgs, ", "), |
| 269 | + "tls": fmt.Sprintf("%t", sess.isTLS), |
| 270 | + }) |
| 271 | + |
| 272 | + sourceIP, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) |
| 273 | + id := make([]byte, 16) |
| 274 | + rand.Read(id) //nolint:errcheck |
| 275 | + |
| 276 | + interaction := &Interaction{ |
| 277 | + ID: hex.EncodeToString(id), |
| 278 | + TokenID: token.ID, |
| 279 | + Token: token.Token, |
| 280 | + Type: "ftp", |
| 281 | + SourceIP: sourceIP, |
| 282 | + Timestamp: time.Now().UTC(), |
| 283 | + Headers: string(headers), |
| 284 | + RawRequest: base64.StdEncoding.EncodeToString(sess.transcript.Bytes()), |
| 285 | + } |
| 286 | + if err := s.store.RecordInteraction(interaction); err != nil { |
| 287 | + log.Printf("callback ftp: record interaction: %v", err) |
| 288 | + return |
| 289 | + } |
| 290 | + s.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction} |
| 291 | +} |
0 commit comments