mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Enhancement to return a per-token sha256 response hash from callback listeners to confirm out-of-band hits
This commit is contained in:
1 parent
66cbe42207
commit
8214c8e804
19 files changed
+501
-16
No files matched your search
@@ -49,6 +49,11 @@ func (s *APIServer) resetDetectCursor(seq int) {
|
||||
if s.techEngine != nil {
|
||||
s.techEngine.ResetCursor(seq)
|
||||
}
|
||||
// The OAST confirmer forward-scans the same sequence numbering and drops its
|
||||
// harvested hash set on reset, so it resets here too.
|
||||
if s.oastEngine != nil {
|
||||
s.oastEngine.ResetCursor(seq)
|
||||
}
|
||||
if s.detectScanner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,6 +101,35 @@ func (s *APIServer) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, token)
|
||||
}
|
||||
|
||||
// handleVerifyCallback confirms that a value observed out of band (reflected in a
|
||||
// target response, read from a protocol field) is the response hash this server
|
||||
// derives for a token — so OAST tooling can validate a hit without reimplementing
|
||||
// the derivation. The derivation is public (sha256 of the token), so this leaks
|
||||
// nothing; it exists to spare clients the reimplementation and to return the
|
||||
// owning token's ID when the value matches a live token.
|
||||
func (s *APIServer) handleVerifyCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
valid := body.Token != "" && callback.ResponseHash(body.Token) == strings.TrimSpace(body.Value)
|
||||
resp := map[string]any{"valid": valid}
|
||||
if valid {
|
||||
if tok, err := s.cbStore.FindTokenByHex(body.Token); err == nil {
|
||||
resp["tokenId"] = tok.ID
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartOASTLoop launches the out-of-band confirmer and the domain refresher. Like
|
||||
// the echo and tech loops it runs unconditionally and no-ops per cycle while the
|
||||
// callback domain is unknown, so configuring a listener later needs no restart.
|
||||
func (s *APIServer) StartOASTLoop(ctx context.Context) {
|
||||
if s.oastEngine == nil {
|
||||
return
|
||||
}
|
||||
go s.oastEngine.Run(ctx)
|
||||
go s.refreshOASTDomain(ctx)
|
||||
}
|
||||
|
||||
// callbackDomainForOAST is the domain the confirmer harvests callback tokens
|
||||
// under. Read every cycle so a domain learned from the listener takes effect live.
|
||||
func (s *APIServer) callbackDomainForOAST() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.oastDomain
|
||||
}
|
||||
|
||||
// refreshOASTDomain keeps oastDomain current. A local --domain is authoritative
|
||||
// and needs no fetch; otherwise, when a listener URL is configured, it fills the
|
||||
// domain in from the listener's callback config on an interval. Best-effort: a
|
||||
// failed or unconfigured fetch simply leaves the confirmer idle.
|
||||
func (s *APIServer) refreshOASTDomain(ctx context.Context) {
|
||||
s.mu.RLock()
|
||||
local := s.cfg.CallbackDomain
|
||||
s.mu.RUnlock()
|
||||
if local != "" {
|
||||
return
|
||||
}
|
||||
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
s.fetchOASTDomain(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.fetchOASTDomain(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchOASTDomain reads the callback domain from the configured listener and
|
||||
// caches it. No-op when no listener URL is set.
|
||||
func (s *APIServer) fetchOASTDomain(ctx context.Context) {
|
||||
s.mu.RLock()
|
||||
listenerURL := s.settings.ListenerURL
|
||||
teamToken := s.settings.TeamToken
|
||||
teamNickname := s.settings.TeamNickname
|
||||
s.mu.RUnlock()
|
||||
if listenerURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
url := strings.TrimRight(listenerURL, "/") + "/api/v1/callbacks/config"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if teamToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+teamToken)
|
||||
}
|
||||
if teamNickname != "" {
|
||||
req.Header.Set("X-Joro-Nickname", teamNickname)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return
|
||||
}
|
||||
var cfg struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
if json.NewDecoder(resp.Body).Decode(&cfg) != nil || cfg.Domain == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.oastDomain = cfg.Domain
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -26,6 +26,7 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/callbacks/interactions", s.handleListInteractions)
|
||||
mux.HandleFunc("DELETE /api/v1/callbacks/interactions", s.handleClearInteractions)
|
||||
mux.HandleFunc("GET /api/v1/callbacks/config", s.handleGetCallbackConfig)
|
||||
mux.HandleFunc("POST /api/v1/callbacks/verify", s.handleVerifyCallback)
|
||||
|
||||
// XSS Hunter routes (both modes - handler logic differs per mode)
|
||||
mux.HandleFunc("GET /api/v1/xss/probes", s.handleListProbes)
|
||||
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
"github.com/BishopFox/joro/internal/mcp"
|
||||
"github.com/BishopFox/joro/internal/mythic"
|
||||
"github.com/BishopFox/joro/internal/notes"
|
||||
"github.com/BishopFox/joro/internal/oast"
|
||||
"github.com/BishopFox/joro/internal/plugins"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
"github.com/BishopFox/joro/internal/sliver"
|
||||
@@ -157,6 +158,11 @@ type APIServer struct {
|
||||
// only the checks a host's stack warrants. See internal/techfp.
|
||||
techEngine *techfp.Engine
|
||||
|
||||
// Out-of-band confirmation: correlates the callback server's response hash
|
||||
// back into captured traffic and mints confirmed CategoryOOB findings. Nil
|
||||
// outside proxy mode. See internal/oast.
|
||||
oastEngine *oast.Engine
|
||||
|
||||
// detectCtx is the server-lifetime context, so a rescan job can outlive the
|
||||
// HTTP request that started it. Guarded by mu.
|
||||
detectCtx context.Context
|
||||
@@ -228,6 +234,9 @@ type APIServer struct {
|
||||
settings Settings
|
||||
activeUserConfig string
|
||||
activeProjectConfig string
|
||||
// oastDomain is the callback domain the OAST confirmer harvests tokens under,
|
||||
// seeded from --domain and best-effort refreshed from the listener. Guarded by mu.
|
||||
oastDomain string
|
||||
lastSaveSig string // fingerprint of live state at last save (auto-save skip)
|
||||
|
||||
// pendingUserPluginStates / pendingProjectPluginStates preserve plugin
|
||||
@@ -344,6 +353,14 @@ func New(
|
||||
// it rides the one cursor-reset path (resetDetectCursor) the other passive
|
||||
// analyzers use.
|
||||
s.techEngine = techfp.NewEngine(techfp.NewStore(), store, hub.Broadcast())
|
||||
// Out-of-band confirmer. Writes into the same findings store; built here so it
|
||||
// rides the one cursor-reset path and pushes the shared summary. The token DB
|
||||
// lives in the listener process, so it harvests expected hashes from captured
|
||||
// callback hostnames under oastDomain (from --domain or refreshed later).
|
||||
s.oastDomain = cfg.CallbackDomain
|
||||
s.oastEngine = oast.NewEngine(
|
||||
store, detectFindings, s.callbackDomainForOAST,
|
||||
hub.Broadcast(), s.broadcastDetectSummary)
|
||||
// Active-scan rule set. The registry is built here (not a package global, per
|
||||
// the no-globals rule) so the api layer can import both activescan and its
|
||||
// rules without an import cycle.
|
||||
|
||||
@@ -91,6 +91,15 @@ func (d *DNSServer) handleDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
log.Printf("callback dns: record interaction: %v", err)
|
||||
}
|
||||
d.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
|
||||
|
||||
// A correlated TXT query is answered with the token's response hash, so
|
||||
// an OAST client can validate a hit via a plain TXT lookup.
|
||||
if q.Qtype == dns.TypeTXT {
|
||||
msg.Answer = append(msg.Answer, &dns.TXT{
|
||||
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60},
|
||||
Txt: []string{ResponseHash(token.Token)},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Respond with A record for A queries
|
||||
|
||||
@@ -163,7 +163,13 @@ func (s *FTPServer) handleConnection(conn net.Conn, implicitTLS bool) {
|
||||
case "USER":
|
||||
sess.user = rest
|
||||
sess.sawUser = true
|
||||
s.writeLine(rw, sess, "331 Password required")
|
||||
// When the username carries a token, echo its response hash in the reply
|
||||
// so an OAST client can validate the hit from the USER exchange alone.
|
||||
reply := "331 Password required"
|
||||
if tok, err := CorrelateAny(s.store, rest); err == nil {
|
||||
reply += " " + ResponseHash(tok.Token)
|
||||
}
|
||||
s.writeLine(rw, sess, reply)
|
||||
case "PASS":
|
||||
sess.pass = rest
|
||||
s.writeLine(rw, sess, "230 Login successful")
|
||||
|
||||
@@ -147,8 +147,11 @@ func (h *HTTPServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
h.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
|
||||
|
||||
// The correlated response is sha256(token), a value a probe can predict and
|
||||
// confirm (see ResponseHash). A miss returns the generic marker below, with no
|
||||
// token to hash — so an unknown probe still cannot use the body as an oracle.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "79cb7b990dd6d0d8eac756c92ccc04b6")
|
||||
fmt.Fprintln(w, ResponseHash(token.Token))
|
||||
}
|
||||
|
||||
func (h *HTTPServer) handleXSS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -198,7 +198,7 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
|
||||
sess.bindDN = string(name)
|
||||
sess.gotOp = true
|
||||
}
|
||||
s.writeResponse(conn, idBytes, ldapBindResponse)
|
||||
s.writeResponse(conn, idBytes, ldapBindResponse, s.responseHashFor(sess))
|
||||
return false
|
||||
|
||||
case ldapSearchRequest:
|
||||
@@ -208,7 +208,7 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
|
||||
sess.searchBase = string(base)
|
||||
sess.gotOp = true
|
||||
}
|
||||
s.writeResponse(conn, idBytes, ldapSearchDone)
|
||||
s.writeResponse(conn, idBytes, ldapSearchDone, s.responseHashFor(sess))
|
||||
return false
|
||||
|
||||
case ldapUnbindRequest:
|
||||
@@ -223,15 +223,32 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
|
||||
|
||||
// writeResponse builds and sends a minimal success LDAPMessage echoing the
|
||||
// given messageID INTEGER value. opTag is ldapBindResponse or ldapSearchDone.
|
||||
func (s *LDAPServer) writeResponse(conn net.Conn, idBytes []byte, opTag byte) {
|
||||
// LDAPResult: resultCode=success(0), matchedDN="", diagnosticMessage="".
|
||||
resultBody := []byte{0x0A, 0x01, 0x00, 0x04, 0x00, 0x04, 0x00}
|
||||
// diag, when non-empty, is placed in the diagnosticMessage field — the token's
|
||||
// response hash on a correlated request, which an OAST client can read there.
|
||||
func (s *LDAPServer) writeResponse(conn net.Conn, idBytes []byte, opTag byte, diag string) {
|
||||
// LDAPResult: resultCode=success(0), matchedDN="", diagnosticMessage=diag.
|
||||
resultBody := []byte{0x0A, 0x01, 0x00, 0x04, 0x00}
|
||||
resultBody = append(resultBody, ber(berOctetString, []byte(diag))...)
|
||||
op := ber(opTag, resultBody)
|
||||
inner := append(ber(berInteger, idBytes), op...)
|
||||
msg := ber(berSequence, inner)
|
||||
conn.Write(msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
// responseHashFor returns the response hash to place in an LDAP result's
|
||||
// diagnosticMessage when the request correlates to a token, or "" when none does.
|
||||
// It mirrors record's candidate order (search base, bind DN, then the raw bytes).
|
||||
func (s *LDAPServer) responseHashFor(sess *ldapSession) string {
|
||||
tok, err := CorrelateAny(s.store,
|
||||
sess.searchBase, sess.bindDN,
|
||||
hex.EncodeToString(sess.raw.Bytes()),
|
||||
)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ResponseHash(tok.Token)
|
||||
}
|
||||
|
||||
// record correlates and stores the session. Runs once via defer; no-ops if no
|
||||
// bind/search was seen or no token correlates.
|
||||
func (s *LDAPServer) record(conn net.Conn, sess *ldapSession) {
|
||||
|
||||
@@ -192,8 +192,13 @@ func (s *SMTPServer) handleConnection(conn net.Conn, implicitTLS bool) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.recordMessage(conn, sess, data)
|
||||
s.writeLine(rw, sess, "250 OK queued")
|
||||
queued := "250 OK queued"
|
||||
if tok := s.recordMessage(conn, sess, data); tok != nil {
|
||||
// A correlated recipient gets the token's response hash in the reply,
|
||||
// a protocol-legal field an OAST client can read to validate the hit.
|
||||
queued += " " + ResponseHash(tok.Token)
|
||||
}
|
||||
s.writeLine(rw, sess, queued)
|
||||
sess.from, sess.rcpts = "", nil
|
||||
case "RSET":
|
||||
sess.from, sess.rcpts = "", nil
|
||||
@@ -265,7 +270,10 @@ func (s *SMTPServer) writeLines(rw *bufio.ReadWriter, sess *smtpSession, lines [
|
||||
rw.Flush() //nolint:errcheck
|
||||
}
|
||||
|
||||
func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte) {
|
||||
// recordMessage correlates the session's recipients to a token, records the
|
||||
// interaction, and returns the matched token (nil if none) so the caller can
|
||||
// echo its response hash in the queued-OK reply.
|
||||
func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte) *Token {
|
||||
cfg, _ := s.store.GetConfig()
|
||||
domain := cfg.Domain
|
||||
|
||||
@@ -279,7 +287,7 @@ func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte
|
||||
}
|
||||
}
|
||||
if token == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
var hdrFrom, hdrTo, hdrSubject, body string
|
||||
@@ -322,9 +330,10 @@ func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte
|
||||
}
|
||||
if err := s.store.RecordInteraction(interaction); err != nil {
|
||||
log.Printf("callback smtp: record interaction: %v", err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
s.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
|
||||
return token
|
||||
}
|
||||
|
||||
// splitVerb returns the first whitespace-delimited token and the remainder.
|
||||
|
||||
@@ -12,6 +12,10 @@ type Token struct {
|
||||
Token string `json:"token"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
HitCount int `json:"hitCount"`
|
||||
// ResponseHash is the value the server returns on a correlated interaction,
|
||||
// for an operator or OAST client to match against. Derived from Token, not a
|
||||
// stored column — like HitCount, it is populated on read.
|
||||
ResponseHash string `json:"expectedHash"`
|
||||
}
|
||||
|
||||
// Interaction represents a recorded callback interaction (DNS or HTTP).
|
||||
@@ -58,7 +62,7 @@ func (s *Store) CreateToken(id, note, hex string) (*Token, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Token{ID: id, Note: note, Token: hex, CreatedAt: now}, nil
|
||||
return &Token{ID: id, Note: note, Token: hex, CreatedAt: now, ResponseHash: ResponseHash(hex)}, nil
|
||||
}
|
||||
|
||||
// ListTokens returns all tokens with their hit counts.
|
||||
@@ -81,6 +85,7 @@ func (s *Store) ListTokens() ([]Token, error) {
|
||||
if err := rows.Scan(&t.ID, &t.Note, &t.Token, &t.CreatedAt, &t.HitCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.ResponseHash = ResponseHash(t.Token)
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
|
||||
@@ -2,6 +2,7 @@ package callback
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
@@ -13,6 +14,21 @@ import (
|
||||
// run; we only ever use its first 16 chars.
|
||||
var hexRunRe = regexp.MustCompile("[0-9a-fA-F]{16,}")
|
||||
|
||||
// ResponseHash is the deterministic value the callback server returns on a
|
||||
// correlated interaction: the lowercase hex SHA-256 of the token's hex string.
|
||||
//
|
||||
// This is a stable external contract. An OAST client reproduces it with a plain
|
||||
// sha256 of the token it embedded — sha256("<token>") — so observing this value
|
||||
// (reflected in a target response, in a protocol field) proves the interaction
|
||||
// actually reached this server for that specific token, rather than being a
|
||||
// coincidental echo of the injected input. The input is the token's ASCII hex,
|
||||
// not its 8 raw bytes, because that hex is what appears in the subdomain, path,
|
||||
// or address a probe carries.
|
||||
func ResponseHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GenerateToken creates a new token with a random 16-char hex identifier.
|
||||
func GenerateToken(store *Store, name string) (*Token, error) {
|
||||
b := make([]byte, 8)
|
||||
|
||||
@@ -149,6 +149,12 @@ const (
|
||||
CategoryCommandInjection Category = "command-injection"
|
||||
CategoryOpenRedirect Category = "open-redirect"
|
||||
CategoryPathTraversal Category = "path-traversal"
|
||||
// CategoryOOB holds confirmed out-of-band interactions: a callback canary
|
||||
// (sha256 of an OAST token) the callback server computed and that then appeared
|
||||
// in captured traffic. It comes from internal/oast, not the passive engine —
|
||||
// the value is proof the target reached the callback server for that probe, so
|
||||
// a reflection of it is a server-side interaction rather than an echo of input.
|
||||
CategoryOOB Category = "oob"
|
||||
)
|
||||
|
||||
// Categories lists every category. See Severities.
|
||||
@@ -157,7 +163,7 @@ var Categories = []Category{
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
|
||||
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
|
||||
CategoryOpenRedirect, CategoryPathTraversal,
|
||||
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB,
|
||||
}
|
||||
|
||||
// Valid reports whether c is a known category.
|
||||
@@ -167,7 +173,7 @@ func (c Category) Valid() bool {
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
|
||||
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
|
||||
CategoryOpenRedirect, CategoryPathTraversal:
|
||||
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -180,7 +186,7 @@ func AllCategories() []Category {
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
|
||||
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
|
||||
CategoryOpenRedirect, CategoryPathTraversal,
|
||||
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Package oast confirms out-of-band interactions by correlating the callback
|
||||
// server's response hash back into captured traffic. When a target makes a
|
||||
// server-side request to Joro's callback listener, the listener returns
|
||||
// sha256(token) (callback.ResponseHash); if that value then appears in traffic
|
||||
// the proxy captured — reflected into a semi-blind response, say — it is proof
|
||||
// the interaction actually reached the callback server for that specific probe,
|
||||
// not a coincidental echo of the injected input.
|
||||
//
|
||||
// It is a separate package from internal/detect for the reason internal/anomaly
|
||||
// is: the detect engine judges one message at a time and its analyzers hold no
|
||||
// state, but this correlation needs the set of expected hashes, which is derived
|
||||
// from tokens seen across earlier captures. It is also a separate package from
|
||||
// internal/callback: the token store lives only in the listener process, while
|
||||
// captured traffic and detection live only in the proxy process, so the proxy
|
||||
// side cannot read the token DB and instead harvests tokens from the callback
|
||||
// hostnames operators injected through the proxy.
|
||||
//
|
||||
// Only the results land in detect: this package mints detect.Findings under
|
||||
// CategoryOOB through detect.FindingID and detect.Store.Upsert — the same path
|
||||
// internal/anomaly and internal/capreg use — which gives them the Detect table,
|
||||
// triage, project persistence, the dashboard, triggers and webhooks for free.
|
||||
// Findings are sticky: a confirmed interaction is evidence, so unlike anomaly's
|
||||
// outliers they are never retracted.
|
||||
package oast
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"maps"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/callback"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// cycleInterval is how often new captures are scanned for reflected hashes.
|
||||
const cycleInterval = 3 * time.Second
|
||||
|
||||
// batchSize bounds one SinceSeq pull.
|
||||
const batchSize = 500
|
||||
|
||||
// ruleID is the synthetic rule identity for these findings. It is not an engine
|
||||
// rule; detect's RuleEnabledFunc treats an unknown ID as enabled and the finding
|
||||
// detail carries the explanation.
|
||||
const ruleID = "oast.reflected"
|
||||
|
||||
// Engine scans captured traffic for reflected callback response hashes and mints
|
||||
// confirmed out-of-band findings into the detect store.
|
||||
type Engine struct {
|
||||
store *proxy.Store
|
||||
findings *detect.Store
|
||||
callbackDomain func() string
|
||||
broadcast chan<- any
|
||||
notify func() // pushes detect.summary after the store changes
|
||||
|
||||
mu sync.Mutex
|
||||
cursor int
|
||||
// expected maps a token's response hash to the token hex it was derived from.
|
||||
// It accumulates across cycles because a probe and its reflection can be
|
||||
// captured in different batches; token counts are small so it never prunes.
|
||||
expected map[string]string
|
||||
}
|
||||
|
||||
// NewEngine constructs the engine. callbackDomain is read every cycle so a domain
|
||||
// learned from the listener after startup takes effect without a restart; an
|
||||
// empty domain means there is nothing to harvest and the cycle no-ops.
|
||||
func NewEngine(store *proxy.Store, findings *detect.Store, callbackDomain func() string, broadcast chan<- any, notify func()) *Engine {
|
||||
return &Engine{
|
||||
store: store,
|
||||
findings: findings,
|
||||
callbackDomain: callbackDomain,
|
||||
broadcast: broadcast,
|
||||
notify: notify,
|
||||
expected: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Run scans on an interval until ctx is cancelled, mirroring the detect scanner.
|
||||
func (e *Engine) Run(ctx context.Context) {
|
||||
t := time.NewTicker(cycleInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
e.RunCycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResetCursor is called wherever proxy.Store rewrites its sequence numbering
|
||||
// (Clear zeroes it, a project load rewrites it), the same obligation detect's
|
||||
// scanner cursor has. It drops the harvested hash set: a rewrite invalidates the
|
||||
// forward-scan position those were gathered at.
|
||||
func (e *Engine) ResetCursor(seq int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.cursor = seq
|
||||
e.expected = map[string]string{}
|
||||
}
|
||||
|
||||
// RunCycle performs one forward-scan pass. Exported so a test or manual trigger
|
||||
// can drive it without the ticker.
|
||||
func (e *Engine) RunCycle() {
|
||||
if e.store == nil || e.findings == nil {
|
||||
return
|
||||
}
|
||||
domain := ""
|
||||
if e.callbackDomain != nil {
|
||||
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(e.callbackDomain())), ".")
|
||||
}
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
// `<label>.<domain>`, where the token is the first 16 hex of the label.
|
||||
hostRe := regexp.MustCompile(`(?i)([0-9a-f]{16})[0-9a-z_-]*\.` + regexp.QuoteMeta(domain))
|
||||
|
||||
e.mu.Lock()
|
||||
cursor := e.cursor
|
||||
e.mu.Unlock()
|
||||
|
||||
var batch []*proxy.CapturedRequest
|
||||
for {
|
||||
b := e.store.SinceSeq(cursor, batchSize)
|
||||
if len(b) == 0 {
|
||||
break
|
||||
}
|
||||
for _, cr := range b {
|
||||
if cr.Seq > cursor {
|
||||
cursor = cr.Seq
|
||||
}
|
||||
}
|
||||
batch = append(batch, b...)
|
||||
if len(b) < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// First pass: harvest every callback token injected in this batch so a
|
||||
// reflection captured in the same batch is already known.
|
||||
e.mu.Lock()
|
||||
for _, cr := range batch {
|
||||
for _, m := range hostRe.FindAllSubmatch(cr.ReqRaw, -1) {
|
||||
tok := strings.ToLower(string(m[1]))
|
||||
e.expected[callback.ResponseHash(tok)] = tok
|
||||
}
|
||||
}
|
||||
// Snapshot the hash set for the scan pass.
|
||||
expected := make(map[string]string, len(e.expected))
|
||||
maps.Copy(expected, e.expected)
|
||||
e.mu.Unlock()
|
||||
|
||||
// Second pass: look for any expected hash in the captured bytes.
|
||||
changed := false
|
||||
if len(expected) > 0 {
|
||||
for _, cr := range batch {
|
||||
if e.scan(cr, expected) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.cursor = cursor
|
||||
e.mu.Unlock()
|
||||
|
||||
if changed && e.notify != nil {
|
||||
e.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// scan checks one capture for a reflected hash and mints a finding on a match.
|
||||
// Reports whether a new finding was created.
|
||||
func (e *Engine) scan(cr *proxy.CapturedRequest, expected map[string]string) bool {
|
||||
created := false
|
||||
for hash, tok := range expected {
|
||||
h := []byte(hash)
|
||||
// The reflection is almost always in the response; the request is checked
|
||||
// too for store-and-forward echoes. A full-SHA-256 match has no realistic
|
||||
// false-positive rate, so either location is a confirmed interaction.
|
||||
if off := bytes.Index(cr.RespRaw, h); off >= 0 {
|
||||
if e.mint(cr, tok, hash, off, "response") {
|
||||
created = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if off := bytes.Index(cr.ReqRaw, h); off >= 0 {
|
||||
if e.mint(cr, tok, hash, off, "request") {
|
||||
created = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
// mint upserts and emits a confirmed out-of-band finding. Identity is keyed on
|
||||
// the token so repeated reflections of the same probe dedupe to one finding.
|
||||
func (e *Engine) mint(cr *proxy.CapturedRequest, token, hash string, offset int, part string) bool {
|
||||
now := time.Now()
|
||||
short := hash
|
||||
if len(short) > 16 {
|
||||
short = short[:16]
|
||||
}
|
||||
f := detect.Finding{
|
||||
ID: detect.FindingID(ruleID, cr.Host, token),
|
||||
RuleID: ruleID,
|
||||
RuleName: "Confirmed out-of-band interaction",
|
||||
Category: detect.CategoryOOB,
|
||||
Severity: detect.SeverityHigh,
|
||||
Confidence: detect.ConfidenceHigh,
|
||||
Target: detect.TargetMessage,
|
||||
Host: cr.Host,
|
||||
Method: cr.Method,
|
||||
URL: cr.URL,
|
||||
RequestID: cr.ID,
|
||||
Detail: "token " + token,
|
||||
Evidence: "callback canary " + short + "… reflected in " + part,
|
||||
EvidenceOffset: offset,
|
||||
EvidenceLength: len(hash),
|
||||
EvidencePart: part,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
}
|
||||
stored, isNew := e.findings.Upsert(f)
|
||||
e.emit(*stored, isNew)
|
||||
return isNew
|
||||
}
|
||||
|
||||
func (e *Engine) emit(f detect.Finding, isNew bool) {
|
||||
if e.broadcast == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case e.broadcast <- event.WSEvent{
|
||||
Type: "detect.finding",
|
||||
Data: map[string]any{"finding": f, "isNew": isNew},
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,7 @@ func runProxyMode(ctx context.Context, cfg config.Config) {
|
||||
apiSrv.StartAnomalyLoop(ctx)
|
||||
apiSrv.StartEchoLoop(ctx)
|
||||
apiSrv.StartTechLoop(ctx)
|
||||
apiSrv.StartOASTLoop(ctx)
|
||||
|
||||
// Start proxy server.
|
||||
proxyDone := make(chan struct{})
|
||||
|
||||
@@ -1611,6 +1611,8 @@ export const api = {
|
||||
clearInteractions: (tokenId?: string) =>
|
||||
req<unknown>('DELETE', `/callbacks/interactions${tokenId ? `?token_id=${tokenId}` : ''}`),
|
||||
getCallbackConfig: () => req<{ domain: string; responseIp: string }>('GET', '/callbacks/config'),
|
||||
verifyCallback: (token: string, value: string) =>
|
||||
req<{ valid: boolean; tokenId?: string }>('POST', '/callbacks/verify', { token, value }),
|
||||
|
||||
// XSS Hunter
|
||||
listProbes: () => req<XSSProbe[]>('GET', '/xss/probes', undefined, TEAM_POLL_TIMEOUT),
|
||||
|
||||
@@ -24,6 +24,7 @@ export const CATEGORY_OPTIONS = [
|
||||
{ key: 'command-injection', label: 'Command injection', title: 'OS command injection (timing, echoed nonce, or out-of-band callback)' },
|
||||
{ key: 'open-redirect', label: 'Open redirect', title: 'Unvalidated redirect to an attacker-controlled host' },
|
||||
{ key: 'path-traversal', label: 'Path traversal', title: 'Directory traversal / local file inclusion returning file contents' },
|
||||
{ key: 'oob', label: 'Out-of-band', title: 'Confirmed out-of-band interaction: a callback canary computed by the OAST server reflected into captured traffic' },
|
||||
]
|
||||
|
||||
export const TARGET_OPTIONS = [
|
||||
|
||||
@@ -737,6 +737,17 @@ export default function Callbacks() {
|
||||
Copy URL
|
||||
</button>
|
||||
</div>
|
||||
{t.expectedHash && (
|
||||
<div className="flex items-center justify-between mt-1" title="SHA-256 of the token; the value the server returns on a hit. Match it against a reflected response to confirm an out-of-band interaction.">
|
||||
<code className="text-[10px] text-content-muted truncate">hash {t.expectedHash.slice(0, 16)}…</code>
|
||||
<button
|
||||
onClick={() => copyText(t.expectedHash)}
|
||||
className="text-xs text-accent-secondary hover:text-accent-secondary-hover shrink-0"
|
||||
>
|
||||
Copy hash
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface CallbackToken {
|
||||
token: string
|
||||
createdAt: string
|
||||
hitCount: number
|
||||
expectedHash: string
|
||||
}
|
||||
|
||||
export interface CallbackInteraction {
|
||||
|
||||
Reference in new issue
Block a user