Skip to content

Commit 20f8cf1

Browse files
committed
Added native LDAP and FTP callback listeners for listener mode
1 parent 5fe7d1e commit 20f8cf1

7 files changed

Lines changed: 1032 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Three modes:
1818
Ports & paths:
1919
- Proxy `:8080` (`--proxy-port`), UI/API `:9090` (`--ui-port`)
2020
- Data dir `~/.joro/` — CA cert/key + `callbacks.db`
21-
- Listener: DNS `:53` (`--dns-port`), HTTP `:80` (`--http-port`), HTTPS `:443` (`--https-port`, `0` to disable), SMTP `:25` (`--smtp-port`, `0` to disable), SMTPS `:465` (`--smtps-port`, `0` to disable), domain via `--domain` or UI, optional external TLS cert via `--tls-cert` + `--tls-key` (both required; replaces the auto-generated self-signed leaf, shared by HTTPS and SMTPS/STARTTLS)
21+
- Listener: DNS `:53` (`--dns-port`), HTTP `:80` (`--http-port`), HTTPS `:443` (`--https-port`, `0` to disable), SMTP `:25` (`--smtp-port`, `0` to disable), SMTPS `:465` (`--smtps-port`, `0` to disable), FTP `:21` (`--ftp-port`, `0` to disable), FTPS `:0` (`--ftps-port`, implicit TLS, disabled by default), LDAP `:389` (`--ldap-port`, `0` to disable), LDAPS `:0` (`--ldaps-port`, implicit TLS, disabled by default), domain via `--domain` or UI, optional external TLS cert via `--tls-cert` + `--tls-key` (both required; replaces the auto-generated self-signed leaf, shared by HTTPS/SMTPS/FTPS/LDAPS and STARTTLS)
2222

2323
---
2424

@@ -249,7 +249,9 @@ Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendo
249249
- **Two-level scope filtering.** L1 (CONNECT): host pattern only — out-of-scope hosts tunneled raw without MITM. L2 (request): host + method + path after TLS termination — out-of-scope requests forwarded without capture/intercept. Disabled by default; enabled with no rules blocks everything (safe default). Exclude rules override include rules.
250250
- **Listener mode is mutually exclusive with proxy mode.** `--listener` starts DNS + HTTP callback servers + reduced API/UI. No CA, proxy, or intercept. Data in `~/.joro/callbacks.db`.
251251
- **Token entropy:** 12 hex chars = 48 bits. Correlated by leftmost subdomain label.
252-
- **Privileged ports need root/capabilities on Linux.** DNS `:53`, SMTP `:25`, HTTP `:80`, HTTPS `:443`, SMTPS `:465` are all <1024. `setcap cap_net_bind_service=+ep ./joro` or iptables redirect; or use the `--{dns,http,https,smtp,smtps}-port` flags to pick unprivileged ports.
252+
- **Callback listeners are capture-only and pure-stdlib.** DNS uses `miekg/dns`; HTTP/SMTP/FTP/LDAP use only `net`/`bufio`/`crypto/tls` (no third-party protocol libs — supply-chain risk). `internal/callback/{ftp,ldap}.go` clone the `SMTPServer` shape (struct + `Start(ctx)` + `acceptLoop` + per-conn goroutine + optional implicit-TLS via shared `*tls.Config`). **FTP** is a fake server that captures USER/PASS + path args and refuses the data channel (`PASV`/`PORT` → `502`); it never opens a second socket or completes a transfer. **LDAP** hand-rolls a minimal BER TLV reader (`readTLV`/`readRawMessage` in `ldap.go` — *not* `encoding/asn1`, which is too DER-strict) to pull the bind DN / search baseObject (where JNDI/Log4Shell payloads land), then replies with canned success `BindResponse`/`SearchResultDone` echoing the messageID. Both `handleConnection`s open with `defer recover()` (untrusted network input) and cap message/line sizes before allocating. New protocols reuse existing `Interaction` columns (`Type`/`SourceIP`/`RawRequest`/`Headers`) — **no schema change**; the frontend `Callbacks.tsx` already renders `ftp`/`ldap` badges + a generic detail view, so **no frontend change**.
253+
- **Correlation helpers (`internal/callback/token.go`).** `Correlate` (DNS subdomain label), `CorrelateSMTP` (email local-part then subdomain), and `CorrelateAny(store, candidates...)` — scans arbitrary captured strings for `[0-9a-fA-F]{16,}` runs and looks up the first 16 chars via `FindTokenByHex`. FTP/LDAP feed their captured fields (+ transcript/hex fallback) into `CorrelateAny`. **Limitation (interactsh parity):** a token present only in the *hostname* (resolved by DNS) and not in the LDAP/FTP payload can't be correlated from the connection itself — but the DNS lookup already records it under the `dns` type.
254+
- **Privileged ports need root/capabilities on Linux.** DNS `:53`, SMTP `:25`, HTTP `:80`, HTTPS `:443`, SMTPS `:465`, FTP `:21`, LDAP `:389`, FTPS `:990`, LDAPS `:636` are all <1024. `setcap cap_net_bind_service=+ep ./joro` or iptables redirect; or use the `--{dns,http,https,smtp,smtps,ftp,ftps,ldap,ldaps}-port` flags to pick unprivileged ports.
253255
- **`internal/event` package** holds shared `WSEvent` to avoid proxy↔callback import cycle.
254256
- **Upstream TLS is maximally permissive (`internal/proxy/tlsconfig.go`).** All connections to target servers use `newUpstreamTLSConfig()`: `InsecureSkipVerify` (we MITM, never validate), `MinVersion: TLS 1.0`, and an explicit `CipherSuites` list of every suite Go implements — `tls.CipherSuites()` **plus** `tls.InsecureCipherSuites()`. The explicit list is required because Go 1.22+ dropped the static-RSA key-exchange suites (`TLS_RSA_WITH_AES_*`) from the default ClientHello, so the default `tls.Config{InsecureSkipVerify:true}` gets `remote error: tls: handshake failure` against legacy servers that only accept those (the failure is at handshake, *before* cert verification, so `InsecureSkipVerify` doesn't help). This matches curl/OpenSSL reach. **Caveat:** Go's `crypto/tls` implements no finite-field DHE (`TLS_DHE_*`) suites, so a DHE-only server stays unreachable. Used by `transport.go`, `client.go`, `sender.go` (H1 + H2), `ws_relay.go`, `ws_manipulate.go` — add new upstream dials through this helper too.
255257
- **Match & Replace operates on raw bytes.** Splits raw dump at `\r\n\r\n`, applies header/body rules independently, then reparses. Cumulative in order. Supports `string` and `regex`. Targets: `request_header`, `request_body`, `response_header`, `response_body`, `ws_message`. **HTTP/1.1 and HTTP/2 have separate apply functions — keep them in sync.** The H1 path uses `applyRequestReplace`/`applyResponseReplace` (`internal/proxy/replace.go`); the H2 MITM path uses `applyRequestReplaceRaw`/`applyResponseReplaceRaw` (`internal/proxy/h2_mitm.go`) because h2 has no textual wire format and works on synthesized raw bytes. A fix to one path usually needs the same fix in the other — e.g. `stripBlankHeaderLines` (collapses blank lines from an empty replacement and drops colon-less orphan lines left by a name-only match) must wrap header-rule output in *both* `applyRequestReplace` and `applyRequestReplaceRaw`. The H2 *response* path reparses headers via `parseHeaderBlock` (a header map), so blank/orphan lines vanish there without the helper.

internal/callback/ftp.go

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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

Comments
 (0)