diff --git a/arcane/home/honeypot-dicompot/dicompot/aetitle.go b/arcane/home/honeypot-dicompot/dicompot/aetitle.go index 18ddde3e6..3d0e50225 100644 --- a/arcane/home/honeypot-dicompot/dicompot/aetitle.go +++ b/arcane/home/honeypot-dicompot/dicompot/aetitle.go @@ -22,18 +22,61 @@ package main // then 2-byte protocol version + 2 reserved + 16-byte CalledAETitle + // 16-byte CallingAETitle, always in that order regardless of how many // presentation-context items follow. +// +// #3155 (sensor-fidelity audit, tier 3b): three of the five reported +// fingerprint tells are reachable from here, below RunProviderForConn but +// above the raw socket, without forking the vendored state machine: +// - the Called AE Title is only ever logged, never checked, so any title +// is accepted and an unrecognized one gets silently dropped by the OS +// instead of a real A-ASSOCIATE-RJ -- see rejectAssociation below; a +// first PDU that isn't even a well-formed A-ASSOCIATE-RQ gets the same +// treatment via abortAssociation, since there's no AE title to reject; +// - the A-ASSOCIATE-AC statemachine.go builds never carries an +// Implementation Class UID or Implementation Version Name item, a gap +// a real PACS never has -- patched in on the way out by peekConn.Write; +// - Max PDU Length in that same AC is a hardcoded 4194304; peekConn.Write +// replaces it with the fixed maxPDULengthReceived instead, deliberately +// not mirrored from the SCU's own proposal -- echoing it back would +// itself be a probe oracle (send maxpdu=N, read N back in the AC). +// Tells 2 and 5 stay open; both live inside DIMSE handling the wrapper +// never touches (see the tracking issue linked from docs/SENSORS.md). import ( "bufio" + "bytes" "net" "strings" "time" + + "github.com/nsmfoo/dicompot/pdu" ) -const pduTypeAAssociateRq = 1 +const ( + pduTypeAAssociateRq = 1 + + // peekBufferSize bounds how much of the initial stream bufio buffers; + // only the first 42 bytes (the Called/Calling AE Title header) are ever + // peeked, this just keeps reads efficient once RunProviderForConn takes + // over. + peekBufferSize = 4096 + + // Implementation Class UID / Version Name reported in the patched + // A-ASSOCIATE-AC (#3155), consistent with the NexusAI Research GmbH + // persona this sensor claims elsewhere (see main.go's org/site config). + implementationClassUID = "1.2.826.0.1.3680043.9.4674.1.1" + implementationVersionName = "NEXUSAI_PACS_3.2" + + // maxPDULengthReceived is the Max PDU Length this persona advertises in + // the A-ASSOCIATE-AC, replacing upstream's hardcoded 4194304 (#3155). + // Fixed rather than mirrored from the SCU's proposal -- dcmtk storescp's + // own default, close to pynetdicom's (16382) and dcm4che's (16378). + maxPDULengthReceived = 16384 +) // peekConn delegates Read to the buffered reader that already peeked the -// handshake, so RunProviderForConn sees the exact same byte stream. +// handshake, so RunProviderForConn sees the exact same byte stream. Write is +// passed straight through, except that the first A-ASSOCIATE-AC written by +// upstream is patched per #3155 (see patchAssociateAc). type peekConn struct { net.Conn r *bufio.Reader @@ -41,15 +84,124 @@ type peekConn struct { func (p *peekConn) Read(b []byte) (int, error) { return p.r.Read(b) } +func (p *peekConn) Write(b []byte) (int, error) { + if len(b) >= 6 && b[0] == pdu.TypeAAssociateAc { + if patched, ok := patchAssociateAc(b); ok { + if _, err := p.Conn.Write(patched); err != nil { + return 0, err + } + return len(b), nil + } + } + return p.Conn.Write(b) +} + +// patchAssociateAc adds a persona-consistent Implementation Class UID and +// Version Name to the AC's User Information item if upstream omitted them +// (it always does today), and replaces Max PDU Length with the fixed +// maxPDULengthReceived regardless of upstream's own value (a hardcoded +// 4194304) or what the SCU proposed -- see maxPDULengthReceived for why this +// isn't mirrored from the SCU. Returns ok=false on anything unexpected, +// telling the caller to forward the original bytes untouched rather than +// risk a malformed handshake. +func patchAssociateAc(raw []byte) ([]byte, bool) { + decoded, err := pdu.ReadPDU(bytes.NewReader(raw), len(raw)) + if err != nil { + return nil, false + } + ac, ok := decoded.(*pdu.AAssociate) + if !ok || ac.Type != pdu.TypeAAssociateAc { + return nil, false + } + + var userInfo *pdu.UserInformationItem + for _, item := range ac.Items { + if ui, ok := item.(*pdu.UserInformationItem); ok { + userInfo = ui + break + } + } + if userInfo == nil { + return nil, false + } + + var haveClassUID, haveVersionName bool + for _, sub := range userInfo.Items { + switch v := sub.(type) { + case *pdu.UserInformationMaximumLengthItem: + v.MaximumLengthReceived = maxPDULengthReceived + case *pdu.ImplementationClassUIDSubItem: + haveClassUID = true + case *pdu.ImplementationVersionNameSubItem: + haveVersionName = true + } + } + if !haveClassUID { + userInfo.Items = append(userInfo.Items, &pdu.ImplementationClassUIDSubItem{Name: implementationClassUID}) + } + if !haveVersionName { + userInfo.Items = append(userInfo.Items, &pdu.ImplementationVersionNameSubItem{Name: implementationVersionName}) + } + + out, err := pdu.EncodePDU(ac) + if err != nil { + return nil, false + } + return out, true +} + +// rejectAssociation sends a real A-ASSOCIATE-RJ (permanent rejection, +// service-user source, called-AE-title-not-recognized -- PS3.8 Table 9-21 +// scopes that reason to service-user; dcmtk (ASC_SOURCE_SERVICEUSER + +// ASC_REASON_SU_CALLEDAETITLENOTRECOGNIZED) and pynetdicom (0x01/0x01/0x07) +// pair them the same way) and lets the caller close the connection. #3155: +// replaces the previous behavior of just letting an unrecognized Called AE +// Title fall through to RunProviderForConn, or dropping the connection with +// no PDU at all. +func rejectAssociation(conn net.Conn) error { + rj := &pdu.AAssociateRj{ + Result: pdu.ResultRejectedPermanent, + Source: pdu.SourceULServiceUser, + Reason: pdu.RejectReasonCalledAETitleNotRecognized, + } + out, err := pdu.EncodePDU(rj) + if err != nil { + return err + } + _, err = conn.Write(out) + return err +} + +// abortAssociation sends an A-ABORT (service-provider source, unrecognized +// PDU) and lets the caller close the connection. #3155: a first PDU that +// isn't a well-formed A-ASSOCIATE-RQ used to fall straight through to a +// silent TCP close; PS3.8 9.3.4's Sta2 "receive invalid PDU" transition +// raises an A-ABORT instead, and there's no parsed AE title here for +// rejectAssociation's A-ASSOCIATE-RJ to name. +func abortAssociation(conn net.Conn) error { + ab := &pdu.AAbort{ + Source: pdu.SourceULServiceProviderACSE, + Reason: pdu.AbortReasonUnexpectedPDU, + } + out, err := pdu.EncodePDU(ab) + if err != nil { + return err + } + _, err = conn.Write(out) + return err +} + // peekAETitles wraps conn and peeks (without consuming) the first 42 bytes // of the stream, extracting the Called/Calling AE Title if that prefix // looks like a well-formed A-ASSOCIATE-RQ header. Returns the wrapped conn -// -- pass this to RunProviderForConn, not the original -- and the two AE -// titles (empty if the peek came up short, timed out, or the PDU type -// doesn't match; an attacker skipping the handshake entirely is not this -// function's problem to solve). -func peekAETitles(conn net.Conn) (net.Conn, string, string) { - r := bufio.NewReaderSize(conn, 4096) +// -- pass this to RunProviderForConn, not the original -- the two AE titles +// (empty if the peek came up short or timed out, in which case malformed is +// also false: silence isn't a protocol violation), and malformed=true when +// bytes did arrive but the first PDU type isn't A-ASSOCIATE-RQ (#3155), +// which the caller should answer with abortAssociation rather than routing +// into RunProviderForConn. +func peekAETitles(conn net.Conn) (net.Conn, string, string, bool) { + r := bufio.NewReaderSize(conn, peekBufferSize) wrapped := &peekConn{Conn: conn, r: r} // #888: without a deadline here, a connection that never sends 42 bytes @@ -60,11 +212,17 @@ func peekAETitles(conn net.Conn) (net.Conn, string, string) { // handling isn't bound by a stale short deadline. conn.SetReadDeadline(time.Now().Add(5 * time.Second)) head, err := r.Peek(42) - conn.SetReadDeadline(time.Time{}) - if err != nil || head[0] != pduTypeAAssociateRq { - return wrapped, "", "" + if err != nil { + conn.SetReadDeadline(time.Time{}) + return wrapped, "", "", false + } + if head[0] != pduTypeAAssociateRq { + conn.SetReadDeadline(time.Time{}) + return wrapped, "", "", true } calledAE := strings.TrimSpace(string(head[10:26])) callingAE := strings.TrimSpace(string(head[26:42])) - return wrapped, calledAE, callingAE + + conn.SetReadDeadline(time.Time{}) + return wrapped, calledAE, callingAE, false } diff --git a/arcane/home/honeypot-dicompot/dicompot/aetitle_test.go b/arcane/home/honeypot-dicompot/dicompot/aetitle_test.go index afc7de57a..04460f75d 100644 --- a/arcane/home/honeypot-dicompot/dicompot/aetitle_test.go +++ b/arcane/home/honeypot-dicompot/dicompot/aetitle_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "io" "net" "testing" @@ -39,7 +40,10 @@ func TestPeekAETitlesExtractsFromRealPDU(t *testing.T) { client.Write(payload) }() - wrapped, calledAE, callingAE := peekAETitles(server) + wrapped, calledAE, callingAE, malformed := peekAETitles(server) + if malformed { + t.Error("malformed = true for a real A-ASSOCIATE-RQ") + } if calledAE != "ANY-SCP" { t.Errorf("calledAE = %q, want ANY-SCP", calledAE) } @@ -63,13 +67,21 @@ func TestPeekAETitlesTrimsPadding(t *testing.T) { payload := realAssociateRQ(t, "AE1", "AE2") // short titles, space-padded to 16 bytes go func() { client.Write(payload) }() - _, calledAE, callingAE := peekAETitles(server) + _, calledAE, callingAE, malformed := peekAETitles(server) if calledAE != "AE1" || callingAE != "AE2" { t.Errorf("got called=%q calling=%q, want AE1/AE2 (untrimmed padding?)", calledAE, callingAE) } + if malformed { + t.Error("malformed = true for a real A-ASSOCIATE-RQ") + } } -func TestPeekAETitlesIgnoresNonAssociatePDU(t *testing.T) { +// TestPeekAETitlesIgnoresShortNonAssociatePDU exercises the "peek came up +// short" path (err != nil from r.Peek(42)): an AReleaseRq alone is far short +// of 42 bytes, so this never reaches the PDU-type check at all. malformed +// must stay false here -- a short/closed connection isn't a protocol +// violation, just silence (#3155). +func TestPeekAETitlesIgnoresShortNonAssociatePDU(t *testing.T) { server, client := net.Pipe() defer client.Close() @@ -82,9 +94,36 @@ func TestPeekAETitlesIgnoresNonAssociatePDU(t *testing.T) { client.Close() // signal EOF: this PDU alone is far short of the 42 bytes peekAETitles wants }() - _, calledAE, callingAE := peekAETitles(server) + _, calledAE, callingAE, malformed := peekAETitles(server) if calledAE != "" || callingAE != "" { - t.Errorf("got called=%q calling=%q for a non-associate PDU, want both empty", calledAE, callingAE) + t.Errorf("got called=%q calling=%q for a short non-associate PDU, want both empty", calledAE, callingAE) + } + if malformed { + t.Error("malformed = true for a short read, want false (not a protocol violation, just silence)") + } +} + +// TestPeekAETitlesFlagsMalformedFirstPDU (#3155): 42+ bytes arrive, but the +// first PDU type isn't A-ASSOCIATE-RQ. This is the "malformed/garbage +// association" case that used to fall through to a silent TCP close -- +// peekAETitles must flag it so the caller sends an A-ABORT instead. +func TestPeekAETitlesFlagsMalformedFirstPDU(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + go func() { + // A-RELEASE-RQ (type 5) header, padded well past 42 bytes so the + // peek succeeds without a short read. + payload := append([]byte{5, 0, 0, 0, 0, 4, 0, 0, 0, 0}, make([]byte, 40)...) + client.Write(payload) + }() + + _, calledAE, callingAE, malformed := peekAETitles(server) + if !malformed { + t.Error("malformed = false for a non-A-ASSOCIATE-RQ first PDU, want true") + } + if calledAE != "" || callingAE != "" { + t.Errorf("got called=%q calling=%q for a malformed first PDU, want both empty", calledAE, callingAE) } } @@ -118,8 +157,207 @@ func TestPeekAETitlesHandlesShortRead(t *testing.T) { client.Close() }() - _, calledAE, callingAE := peekAETitles(server) + _, calledAE, callingAE, malformed := peekAETitles(server) if calledAE != "" || callingAE != "" { t.Errorf("got called=%q calling=%q on a truncated PDU, want both empty", calledAE, callingAE) } + if malformed { + t.Error("malformed = true for a truncated read, want false (not a protocol violation, just silence)") + } +} + +// realAssociateAC encodes a real A-ASSOCIATE-AC via the vendored package's +// own encoder, with a User Information item carrying the Max PDU Length +// upstream always sends today (#3155) and, unless extraItems says +// otherwise, nothing else -- matching statemachine.go's actual output, +// which never includes Implementation Class UID or Version Name. +func realAssociateAC(t *testing.T, maxPDU uint32, extraItems ...pdu.SubItem) []byte { + t.Helper() + items := append([]pdu.SubItem{&pdu.UserInformationMaximumLengthItem{MaximumLengthReceived: maxPDU}}, extraItems...) + b, err := pdu.EncodePDU(&pdu.AAssociate{ + Type: pdu.TypeAAssociateAc, + ProtocolVersion: pdu.CurrentProtocolVersion, + CalledAETitle: "RADIANT", + CallingAETitle: "STORESCU", + Items: []pdu.SubItem{&pdu.UserInformationItem{Items: items}}, + }) + if err != nil { + t.Fatalf("EncodePDU: %v", err) + } + return b +} + +// decodeAC re-decodes patched/unpatched AC bytes and returns the Max PDU +// Length plus whether Implementation Class UID / Version Name are present, +// so tests can assert on the actual wire result rather than internal state. +func decodeAC(t *testing.T, raw []byte) (maxPDU uint32, classUID, versionName string) { + t.Helper() + decoded, err := pdu.ReadPDU(bytes.NewReader(raw), len(raw)) + if err != nil { + t.Fatalf("ReadPDU: %v", err) + } + ac, ok := decoded.(*pdu.AAssociate) + if !ok { + t.Fatalf("decoded %T, want *pdu.AAssociate", decoded) + } + for _, item := range ac.Items { + ui, ok := item.(*pdu.UserInformationItem) + if !ok { + continue + } + for _, sub := range ui.Items { + switch v := sub.(type) { + case *pdu.UserInformationMaximumLengthItem: + maxPDU = v.MaximumLengthReceived + case *pdu.ImplementationClassUIDSubItem: + classUID = v.Name + case *pdu.ImplementationVersionNameSubItem: + versionName = v.Name + } + } + } + return maxPDU, classUID, versionName +} + +func TestPatchAssociateAc(t *testing.T) { + // #3155 tell 3: MaximumLengthReceived is always replaced with the fixed + // maxPDULengthReceived, regardless of what upstream advertised -- never + // mirrored from the SCU's own proposal (that would be an echo oracle). + cases := []struct { + name string + upstreamMax uint32 + }{ + {name: "upstream's hardcoded default", upstreamMax: 4194304}, + {name: "some other upstream value", upstreamMax: 8388608}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw := realAssociateAC(t, tc.upstreamMax) + patched, ok := patchAssociateAc(raw) + if !ok { + t.Fatal("patchAssociateAc returned ok=false for a well-formed AC") + } + gotMax, classUID, versionName := decodeAC(t, patched) + if gotMax != maxPDULengthReceived { + t.Errorf("MaximumLengthReceived = %d, want %d", gotMax, maxPDULengthReceived) + } + if classUID != implementationClassUID { + t.Errorf("Implementation Class UID = %q, want %q (#3155 tell 1)", classUID, implementationClassUID) + } + if versionName != implementationVersionName { + t.Errorf("Implementation Version Name = %q, want %q (#3155 tell 1)", versionName, implementationVersionName) + } + }) + } +} + +func TestPatchAssociateAcSkipsNonAssociateAc(t *testing.T) { + raw := realAssociateRQ(t, "RADIANT", "STORESCU") // an RQ, not an AC + if _, ok := patchAssociateAc(raw); ok { + t.Error("patchAssociateAc returned ok=true for a non-AC PDU") + } +} + +func TestPeekConnWritePatchesFirstAssociateAc(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + wrapped := &peekConn{Conn: server} + raw := realAssociateAC(t, 4194304) + + go func() { + if _, err := wrapped.Write(raw); err != nil { + t.Errorf("Write: %v", err) + } + }() + + got := make([]byte, 0, len(raw)+64) + buf := make([]byte, 256) + // The patched AC gained two sub-items, so it's longer than raw; read + // until the peer closes rather than assuming the original length. + go func() { time.Sleep(200 * time.Millisecond); server.Close() }() + for { + n, err := client.Read(buf) + got = append(got, buf[:n]...) + if err != nil { + break + } + } + + gotMax, classUID, versionName := decodeAC(t, got) + if gotMax != 16384 { + t.Errorf("MaximumLengthReceived = %d, want 16384", gotMax) + } + if classUID != implementationClassUID || versionName != implementationVersionName { + t.Errorf("got classUID=%q versionName=%q, want %q/%q", classUID, versionName, implementationClassUID, implementationVersionName) + } +} + +// TestRejectAssociation (#3155 tell 4): an unrecognized Called AE Title must +// get a real A-ASSOCIATE-RJ on the wire, not a bare closed socket. +func TestRejectAssociation(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + go func() { + if err := rejectAssociation(server); err != nil { + t.Errorf("rejectAssociation: %v", err) + } + }() + + raw := make([]byte, 10) // 6-byte header + 4-byte RJ payload + if _, err := io.ReadFull(client, raw); err != nil { + t.Fatalf("ReadFull: %v", err) + } + decoded, err := pdu.ReadPDU(bytes.NewReader(raw), len(raw)) + if err != nil { + t.Fatalf("ReadPDU: %v", err) + } + rj, ok := decoded.(*pdu.AAssociateRj) + if !ok { + t.Fatalf("decoded %T, want *pdu.AAssociateRj", decoded) + } + if rj.Result != pdu.ResultRejectedPermanent { + t.Errorf("Result = %v, want ResultRejectedPermanent", rj.Result) + } + if rj.Source != pdu.SourceULServiceUser { + t.Errorf("Source = %v, want SourceULServiceUser (PS3.8 Table 9-21 scopes called-AE-title-not-recognized to service-user)", rj.Source) + } + if rj.Reason != pdu.RejectReasonCalledAETitleNotRecognized { + t.Errorf("Reason = %v, want RejectReasonCalledAETitleNotRecognized", rj.Reason) + } +} + +// TestAbortAssociation (#3155 tell 4): a first PDU that isn't a well-formed +// A-ASSOCIATE-RQ must get a real A-ABORT on the wire, not a bare closed +// socket. +func TestAbortAssociation(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + + done := make(chan error, 1) + go func() { done <- abortAssociation(server) }() + + raw := make([]byte, 10) + if _, err := io.ReadFull(client, raw); err != nil { + t.Fatalf("read A-ABORT: %v", err) + } + if err := <-done; err != nil { + t.Fatalf("abortAssociation: %v", err) + } + + decoded, err := pdu.ReadPDU(bytes.NewReader(raw), len(raw)) + if err != nil { + t.Fatalf("ReadPDU: %v", err) + } + ab, ok := decoded.(*pdu.AAbort) + if !ok { + t.Fatalf("decoded %T, want *pdu.AAbort", decoded) + } + if ab.Source != pdu.SourceULServiceProviderACSE { + t.Errorf("Source = %v, want SourceULServiceProviderACSE", ab.Source) + } + if ab.Reason != pdu.AbortReasonUnexpectedPDU { + t.Errorf("Reason = %v, want AbortReasonUnexpectedPDU", ab.Reason) + } } diff --git a/arcane/home/honeypot-dicompot/dicompot/handler_panic_test.go b/arcane/home/honeypot-dicompot/dicompot/handler_panic_test.go index c55fe4572..4614b2a7d 100644 --- a/arcane/home/honeypot-dicompot/dicompot/handler_panic_test.go +++ b/arcane/home/honeypot-dicompot/dicompot/handler_panic_test.go @@ -159,7 +159,11 @@ func TestHandleConnContainsVendorParserPanic(t *testing.T) { defer close(done) handleConn(server, log, false, "RADIANT", 11112) }() - client.Write(realAssociateRQ(t, "ANY-SCP", "STORESCU")) + // #3155: CalledAETitle must match the configured "RADIANT" now that + // handleConn rejects unrecognized Called AE Titles before ever reaching + // the vendored parser -- this test is about panic containment, not AE + // Title enforcement, so the fixture has to clear that gate first. + client.Write(realAssociateRQ(t, "RADIANT", "STORESCU")) select { case <-done: diff --git a/arcane/home/honeypot-dicompot/dicompot/main.go b/arcane/home/honeypot-dicompot/dicompot/main.go index 16484bc51..65d27ac8f 100644 --- a/arcane/home/honeypot-dicompot/dicompot/main.go +++ b/arcane/home/honeypot-dicompot/dicompot/main.go @@ -371,9 +371,31 @@ func handleConn(conn net.Conn, log *logger, proxy bool, aeTitle string, port int return } log.emit(event{Port: port, SrcIP: ip, Event: "connect"}) - conn, calledAE, callingAE := peekAETitles(conn) + conn, calledAE, callingAE, malformed := peekAETitles(conn) if calledAE != "" || callingAE != "" { log.emit(event{Port: port, SrcIP: ip, Event: "associate", CalledAE: calledAE, CallingAE: callingAE}) } + // #3155: a first PDU that isn't a well-formed A-ASSOCIATE-RQ used to + // fall straight through to RunProviderForConn or an implicit close -- + // answer it with a real A-ABORT instead of silence. + if malformed { + log.emit(event{Port: port, SrcIP: ip, Event: "associate_aborted", CalledAE: calledAE, CallingAE: callingAE}) + if err := abortAssociation(conn); err != nil { + log.emit(event{Port: port, SrcIP: ip, Event: "associate_abort_failed", Data: err.Error()}) + } + return + } + // #3155: accept only the AE Title this persona actually claims -- an + // empty calledAE (malformed already false here) means the peek came up + // short or timed out, in which case there's nothing to enforce and + // RunProviderForConn's own (currently permissive) handling is left to + // run as before. + if calledAE != "" && calledAE != aeTitle { + log.emit(event{Port: port, SrcIP: ip, Event: "associate_rejected", CalledAE: calledAE, CallingAE: callingAE}) + if err := rejectAssociation(conn); err != nil { + log.emit(event{Port: port, SrcIP: ip, Event: "associate_reject_failed", Data: err.Error()}) + } + return + } dicompot.RunProviderForConn(conn, paramsFor(log, aeTitle, port, ip)) } diff --git a/docs/DECEPTION-EXTENSIONS.md b/docs/DECEPTION-EXTENSIONS.md index e7995bb5a..14255209f 100644 --- a/docs/DECEPTION-EXTENSIONS.md +++ b/docs/DECEPTION-EXTENSIONS.md @@ -75,7 +75,7 @@ Still under consideration: | Dionaea | Integrated | `arcane/home/honeypot-dionaea/` | pre-tracker malware-capture sensor; split by #258 | | SNARE/TANNER | Integrated | `arcane/home/honeypot-tanner/` | pre-tracker web-app group; split by #258 | | DNP3 protocol sensor | Integrated | `arcane/home/honeypot-dnp3/` | pre-tracker; split by #258 | -| dicompot (DICOM) | Integrated | `arcane/home/honeypot-dicompot/` | #238 batch, per-decoy plan #413 | +| dicompot (DICOM) | Integrated | `arcane/home/honeypot-dicompot/` | #238 batch, per-decoy plan #413; #3155 fingerprint-tell audit closed 3/5 tells in the wrapper, remaining 2 (vendored DIMSE handling) tracked as a fork-vs-accept decision in the issue linked from #3155 | ## Cloud, database and API deception diff --git a/docs/SENSORS.md b/docs/SENSORS.md index 888cec313..36faf392f 100644 --- a/docs/SENSORS.md +++ b/docs/SENSORS.md @@ -14,7 +14,7 @@ | **conpot-guardian** | Guardian AST 10001 | raw tunnel | fuel and tank-monitor attack surface | | **conpot-kamstrup** | Kamstrup 1025, 50100 | raw tunnel | smart-meter data and management protocols | | **dnp3** | DNP3 20000 | raw tunnel | ElbeGrid substation RTU -- decodes the link-layer function code plus, when the frame carries a transport+application-layer segment, the application-layer function code too (READ/WRITE/SELECT/OPERATE/DIRECT_OPERATE/etc., #610); the full frame is always captured as `frame_hex` regardless | -| **dicompot** | DICOM 11112 | raw tunnel + PROXY | vendored `nsmfoo/dicompot` medical-imaging decoy (C-ECHO/C-FIND/C-MOVE/C-GET/C-STORE) — ES-only from day one (#238, #413) | +| **dicompot** | DICOM 11112 | raw tunnel + PROXY | vendored `nsmfoo/dicompot` medical-imaging decoy (C-ECHO/C-FIND/C-MOVE/C-GET/C-STORE) — ES-only from day one (#238, #413). #3155 sensor-fidelity audit (tier 3b) found 5 fingerprint tells; the wrapper (`arcane/home/honeypot-dicompot/dicompot/aetitle.go`) closes 3 of them below `RunProviderForConn` without forking upstream: the A-ASSOCIATE-AC now carries an Implementation Class UID/Version Name (tell 1), Max PDU Length in the AC is a fixed 16384 persona constant (never echoed from the SCU's proposal) instead of a hardcoded 4194304 (tell 3), and an unrecognized Called AE Title gets a real A-ASSOCIATE-RJ instead of a silent close (tell 4); a first PDU that isn't a well-formed A-ASSOCIATE-RQ now gets a real A-ABORT instead of falling through to a silent close. Tells 2 (any SOP Class UID accepted, including invalid ones) and 5 (C-FIND/C-MOVE/C-GET always return success) live inside the vendored library's own DIMSE handling, unreachable from the wrapper without forking `nsmfoo/dicompot` — tracked as a separate decision, see the issue linked from #3155 | | **dns-honeypot** | DNS 53/udp | raw tunnel | from-scratch UDP reflection bait, response capped in code to at most 1.5x request size — never contacts a real resolver, so it cannot be abused as a DDoS amplification vector — ES-only from day one (#238, #415) | | **citrix-honeypot** | raw 4443 (→ container 443) | raw tunnel + PROXY | Citrix ADC/NetScaler Gateway decoy (CVE-2019-19781 path traversal), Go port of `t3chn0m4g3/CitrixHoneypot`, own self-signed TLS — ES-only from day one (#238, #414) | | **cisco-asa-honeypot** | WebVPN 8443, IKE 500/udp | raw tunnel + PROXY (8443) | Cisco ASA WebVPN + IKE decoy (CVE-2018-0101), Go port of `t3chn0m4g3/ciscoasa_honeypot` — the IKE side replies once per source with a real Diffie-Hellman/nonce exchange then goes silent, matching upstream's actual (not fully protocol-correct) behavior exactly — ES-only from day one (#238, #414) |