Enhancement to return a per-token sha256 response hash from callback listeners to confirm out-of-band hits

This commit is contained in:
Tony West committed 2026-10-06 23:16:43 -04:00
1 parent 66cbe42207
commit 8214c8e804
19 files changed
+501 -16

No files matched your search

+5
View File
@@ -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
}
+29
View File
@@ -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)
+97
View File
@@ -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()
}
+1
View File
@@ -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)
+17
View File
@@ -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.
+9
View File
@@ -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
+7 -1
View File
@@ -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")
+4 -1
View File
@@ -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) {
+22 -5
View File
@@ -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) {
+14 -5
View File
@@ -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.
+6 -1
View File
@@ -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()
+16
View File
@@ -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)
+9 -3
View File
@@ -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,
}
}
+249
View File
@@ -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:
}
}
+1
View File
@@ -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{})
+2
View File
@@ -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),
+1
View File
@@ -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 = [
+11
View File
@@ -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>
))}
+1
View File
@@ -6,6 +6,7 @@ export interface CallbackToken {
token: string
createdAt: string
hitCount: number
expectedHash: string
}
export interface CallbackInteraction {