From: Étienne Mollier Date: Mon, 6 Jul 2026 20:34:37 +0000 (+0200) Subject: d/patches/CVE-2026-*.patch: new security patches. X-Git-Tag: archive/raspbian/3.7.0+really3.7.0-7+rpi1^2~16 X-Git-Url: https://dgit.raspbian.org/?a=commitdiff_plain;h=1013f32ba294fa35717e4af11fe013c674a7c618;p=dcmtk.git d/patches/CVE-2026-*.patch: new security patches. This change includes a patch queue addressing CVE-2026-50003, CVE-2026-50254, CVE-2026-35505, CVE-2026-52868 and CVE-2026-44628. The latter CVE-2026-44628 is divided into two patches to match upstream's commits. These changes fix a range of issues, including risks of path traversals, denial of services and information leaks. Closes: #1141411 --- diff --git a/debian/patches/CVE-2026-35505.patch b/debian/patches/CVE-2026-35505.patch new file mode 100644 index 00000000..803250cf --- /dev/null +++ b/debian/patches/CVE-2026-35505.patch @@ -0,0 +1,509 @@ +Applied-Upstream: 2312891a8d058c862e00bcbd636e5da26308658a +Author: Michael Onken +Last-Update: 2026-05-29 +Description: Fix A-ASSOCIATE-RQ/AC error-path leaks in DUL FSM. + AE_3_AssociateConfirmationAccept and AE_6_ExamineAssociateRequest leaked + the whole parsed PDU graph (presentation contexts, transfer syntax + sub-chains, role list, user info) on error returns taken after + parseAssociate() succeeded, since only the success path freed it. For an + SCP this is remotely triggerable and accumulates per connection. + . + Free the parsed PDU before each such return; + translatePresentationContextList() also frees the proposed transfer + syntax list of a rejected context. Add a dcmnet regression test + (tassocleak.cc) driving the AE_6 zero-transfer-syntax path against an + in-process DcmSCP; leak-clean with the fix under DCMTK_WITH_SANITIZERS. + . + Thanks for the report and analysis to Abhinav Agarwal. + . + This closes DCMTK issue #1217. +Reviewed-By: Étienne Mollier +Bug-Debian: https://bugs.debian.org/1141411 + +--- dcmtk.orig/dcmnet/libsrc/dulfsm.cc ++++ dcmtk/dcmnet/libsrc/dulfsm.cc +@@ -967,14 +967,26 @@ + OFStandard::strlcpy(service->callingAPTitle, assoc.callingAPTitle, sizeof(service->callingAPTitle)); + OFStandard::strlcpy(service->applicationContextName, assoc.applicationContext.data, sizeof(service->applicationContextName)); + +- if ((service->acceptedPresentationContext = LST_Create()) == NULL) return EC_MemoryExhausted; ++ if ((service->acceptedPresentationContext = LST_Create()) == NULL) ++ { ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); ++ return EC_MemoryExhausted; ++ } + + prvCtx = (PRV_PRESENTATIONCONTEXTITEM*)LST_Head(&assoc.presentationContextList); + if (prvCtx != NULL) + (void) LST_Position(&assoc.presentationContextList, (LST_NODE*)prvCtx); + while (prvCtx != NULL) { + userPresentationCtx = (DUL_PRESENTATIONCONTEXT*)malloc(sizeof(DUL_PRESENTATIONCONTEXT)); +- if (userPresentationCtx == NULL) return EC_MemoryExhausted; ++ if (userPresentationCtx == NULL) ++ { ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); ++ return EC_MemoryExhausted; ++ } + + (void) memset(userPresentationCtx, 0, sizeof(DUL_PRESENTATIONCONTEXT)); + userPresentationCtx->result = prvCtx->result; +@@ -1007,6 +1019,9 @@ + char buf1[256]; + OFStandard::snprintf(buf1, sizeof(buf1), "DUL Peer supplied illegal number of transfer syntaxes (%d)", 0); + free(userPresentationCtx); ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); + return makeDcmnetCondition(DULC_PEERILLEGALXFERSYNTAXCOUNT, OF_error, buf1); + } + +@@ -1015,6 +1030,9 @@ + char buf2[256]; + OFStandard::snprintf(buf2, sizeof(buf2), "DUL Peer supplied illegal number of transfer syntaxes (%ld)", LST_Count(&prvCtx->transferSyntaxList)); + free(userPresentationCtx); ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); + return makeDcmnetCondition(DULC_PEERILLEGALXFERSYNTAXCOUNT, OF_error, buf2); + } + subItem = (DUL_SUBITEM*)LST_Head(&prvCtx->transferSyntaxList); +@@ -1037,7 +1055,13 @@ + */ + if (assoc.userInfo.extNegList != NULL) { + service->acceptedExtNegList = new SOPClassExtendedNegotiationSubItemList; +- if (service->acceptedExtNegList == NULL) return EC_MemoryExhausted; ++ if (service->acceptedExtNegList == NULL) ++ { ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); ++ return EC_MemoryExhausted; ++ } + appendList(*assoc.userInfo.extNegList, *service->acceptedExtNegList); + } + +@@ -1045,7 +1069,13 @@ + if (assoc.userInfo.usrIdent != NULL) { + service->ackUserIdentNeg = + new UserIdentityNegotiationSubItemAC( *(OFstatic_cast(UserIdentityNegotiationSubItemAC*, assoc.userInfo.usrIdent))); +- if (service->ackUserIdentNeg == NULL) return EC_MemoryExhausted; ++ if (service->ackUserIdentNeg == NULL) ++ { ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); ++ return EC_MemoryExhausted; ++ } + + } + +@@ -1230,11 +1260,20 @@ + OFStandard::strlcpy(service->callingAPTitle, assoc.callingAPTitle, sizeof(service->callingAPTitle)); + OFStandard::strlcpy(service->applicationContextName, assoc.applicationContext.data, sizeof(service->applicationContextName)); + +- if ((service->requestedPresentationContext = LST_Create()) == NULL) return EC_MemoryExhausted; ++ if ((service->requestedPresentationContext = LST_Create()) == NULL) ++ { ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); ++ return EC_MemoryExhausted; ++ } + if (translatePresentationContextList(&assoc.presentationContextList, + &assoc.userInfo.SCUSCPRoleList, + &service->requestedPresentationContext).bad()) + { ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); + return DUL_PCTRANSLATIONFAILURE; + } + +@@ -1248,14 +1287,26 @@ + */ + if (assoc.userInfo.extNegList != NULL) { + service->requestedExtNegList = new SOPClassExtendedNegotiationSubItemList; +- if (service->requestedExtNegList == NULL) return EC_MemoryExhausted; ++ if (service->requestedExtNegList == NULL) ++ { ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); ++ return EC_MemoryExhausted; ++ } + appendList(*assoc.userInfo.extNegList, *service->requestedExtNegList); + } + + /* user identity negotiation: Remember request values in association parameters (copy)*/ + if (assoc.userInfo.usrIdent != NULL) { + service->reqUserIdentNeg = new UserIdentityNegotiationSubItemRQ(); +- if (service->reqUserIdentNeg == NULL) return EC_MemoryExhausted; ++ if (service->reqUserIdentNeg == NULL) ++ { ++ /* free memory allocated by parseAssociate() before returning */ ++ destroyAssociatePDUPresentationContextList(&assoc.presentationContextList); ++ destroyUserInformationLists(&assoc.userInfo); ++ return EC_MemoryExhausted; ++ } + *(service->reqUserIdentNeg) = *(OFstatic_cast(UserIdentityNegotiationSubItemRQ*,assoc.userInfo.usrIdent)); + } + +@@ -3943,6 +3994,8 @@ + { + char buf1[256]; + OFStandard::snprintf(buf1, sizeof(buf1), "DUL Peer supplied illegal number of transfer syntaxes (%d)", 0); ++ /* free the (empty) transfer syntax list allocated for this context above */ ++ LST_Destroy(&userContext->proposedTransferSyntax); + free(userContext); + return makeDcmnetCondition(DULC_PEERILLEGALXFERSYNTAXCOUNT, OF_error, buf1); + } +--- dcmtk.orig/dcmnet/tests/CMakeLists.txt ++++ dcmtk/dcmnet/tests/CMakeLists.txt +@@ -1,5 +1,6 @@ + # declare executables + DCMTK_ADD_TEST_EXECUTABLE(dcmnet_tests ++ tassocleak.cc + tdimse.cc + tdump.cc + tests.cc +--- dcmtk.orig/dcmnet/tests/Makefile.in ++++ dcmtk/dcmnet/tests/Makefile.in +@@ -25,7 +25,7 @@ + LOCALLIBS = -ldcmnet -ldcmdata -loflog -lofstd -loficonv $(ZLIBLIBS) \ + $(TCPWRAPPERLIBS) $(CHARCONVLIBS) $(MATHLIBS) + +-objs = tests.o tdump.o tdimse.o tpool.o tscuscp.o tscusession.o ++objs = tests.o tdump.o tdimse.o tassocleak.o tpool.o tscuscp.o tscusession.o + progs = tests + + +--- /dev/null ++++ dcmtk/dcmnet/tests/tassocleak.cc +@@ -0,0 +1,312 @@ ++/* ++ * ++ * Copyright (C) 2026, OFFIS e.V. ++ * All rights reserved. See COPYRIGHT file for details. ++ * ++ * This software and supporting documentation were developed by ++ * ++ * OFFIS e.V. ++ * R&D Division Health ++ * Escherweg 2 ++ * D-26121 Oldenburg, Germany ++ * ++ * ++ * Module: dcmnet ++ * ++ * Author: Michael Onken ++ * ++ * Purpose: Integration regression tests that drive an in-process DcmSCP with ++ * hand-crafted A-ASSOCIATE-RQ PDUs. They exercise the receiver-side ++ * association request handling on malformed input that a conformant ++ * DCMTK requestor (DcmSCU / the ASC_* API) would refuse to put on ++ * the wire. Built with DCMTK_WITH_SANITIZERS=ON these tests double ++ * as memory-error / leak regression tests for the DUL association ++ * parsing and translation code paths. ++ * ++ */ ++ ++ ++#include "dcmtk/config/osconfig.h" /* make sure OS specific configuration is included first */ ++ ++#ifdef WITH_THREADS ++ ++#include "dcmtk/ofstd/oftest.h" ++#include "dcmtk/ofstd/ofstd.h" /* for OFStandard::initializeNetwork() */ ++#include "dcmtk/ofstd/ofthread.h" /* for OFThread */ ++#include "dcmtk/dcmnet/scp.h" ++#include "dcmtk/dcmnet/dcmtrans.h" /* for DcmTCPConnection */ ++#include "dcmtk/dcmnet/dcompat.h" /* for platform socket headers */ ++#include "dcmtk/dcmnet/dulstruc.h" /* DUL_TYPE* constants, DUL_PROTOCOL */ ++#include "dcmtk/dcmnet/dul.h" ++ ++ ++/* -------------------------------------------------------------------------- ++ * Minimal in-process SCP that accepts exactly one association attempt and ++ * then returns. The DUL state machine (and therefore the association request ++ * parsing/translation) runs inside ASC_receiveAssociation() before the SCP ++ * gets a chance to accept or reject, so it suffices to let the SCP try to ++ * receive a single association. ++ * ------------------------------------------------------------------------ */ ++struct OneShotReceiverSCP : DcmSCP, OFThread ++{ ++ OneShotReceiverSCP() ++ : DcmSCP() ++ , m_listen_result(EC_NotYetImplemented) ++ , m_portNum(0) ++ { ++ DcmSCPConfig& config = getConfig(); ++ config.setAETitle("RECV_SCP"); ++ // Non-blocking accept with a short connection timeout, so the accept ++ // loop cannot get stuck if a (future) malformed PDU leaves the SCP ++ // waiting on a partial read, or if no client connects at all. For the ++ // normal flow the client connects immediately, so this timeout is not ++ // hit and adds no delay. ++ config.setConnectionBlockingMode(DUL_NOBLOCK); ++ config.setConnectionTimeout(2 /* seconds */); ++ config.setHostLookupEnabled(OFFalse); ++ config.setPort(0); // OS chooses a free port ++ OFList xfers; ++ xfers.push_back(UID_LittleEndianImplicitTransferSyntax); ++ OFCHECK(config.addPresentationContext(UID_VerificationSOPClass, xfers).good()); ++ OFCHECK(openListenPort().good()); ++ m_portNum = config.getPort(); ++ } ++ ++ /** Stop the accept loop after the single association attempt has been ++ * handled, so the SCP thread returns and can be joined. ++ */ ++ virtual OFBool stopAfterCurrentAssociation() ++ { ++ return OFTrue; ++ } ++ ++ /** Also stop if the connection timeout elapses without any association ++ * request. Together with the non-blocking mode above this guarantees the ++ * accept loop terminates even if no client ever connects, so a stuck test ++ * fails fast (via join()) instead of hanging. ++ */ ++ virtual OFBool stopAfterConnectionTimeout() ++ { ++ return OFTrue; ++ } ++ ++ virtual void run() ++ { ++ m_listen_result = acceptAssociations(); ++ } ++ ++ OFCondition m_listen_result; ++ Uint16 m_portNum; ++}; ++ ++ ++/* Write a 16-bit big-endian value into buf and advance the cursor. */ ++static void put_u16_be(unsigned char *&p, unsigned short v) ++{ ++ *p++ = OFstatic_cast(unsigned char, (v >> 8) & 0xff); ++ *p++ = OFstatic_cast(unsigned char, v & 0xff); ++} ++ ++/* Write a 32-bit big-endian value into buf and advance the cursor. */ ++static void put_u32_be(unsigned char *&p, unsigned long v) ++{ ++ *p++ = OFstatic_cast(unsigned char, (v >> 24) & 0xff); ++ *p++ = OFstatic_cast(unsigned char, (v >> 16) & 0xff); ++ *p++ = OFstatic_cast(unsigned char, (v >> 8) & 0xff); ++ *p++ = OFstatic_cast(unsigned char, v & 0xff); ++} ++ ++/* Write a sub-item header (type + reserved + 2-byte big-endian length) and ++ * advance the cursor. */ ++static void put_subitem_header(unsigned char *&p, unsigned char type, unsigned short bodyLen) ++{ ++ *p++ = type; ++ *p++ = 0x00; ++ put_u16_be(p, bodyLen); ++} ++ ++ ++/* Open a blocking TCP connection to 127.0.0.1:port. Sending and ++ * tearing the connection down is done via DcmTCPConnection so the test needs ++ * no platform-specific socket code. Returns an invalid socket on failure. */ ++static DcmNativeSocketType connect_loopback(Uint16 port) ++{ ++ DcmNativeSocketType sock = socket(AF_INET, SOCK_STREAM, 0); ++ if (sock == OFstatic_cast(DcmNativeSocketType, -1)) ++ return sock; ++ ++ struct sockaddr_in addr; ++ memset(&addr, 0, sizeof(addr)); ++ addr.sin_family = AF_INET; ++ addr.sin_port = htons(port); ++ addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); ++ ++ if (connect(sock, OFreinterpret_cast(struct sockaddr*, &addr), sizeof(addr)) != 0) ++ return OFstatic_cast(DcmNativeSocketType, -1); ++ return sock; ++} ++ ++ ++/* Build an A-ASSOCIATE-RQ PDU whose single presentation context contains an ++ * Abstract Syntax sub-item (Verification SOP Class) but NO Transfer Syntax ++ * sub-items. The PDU is otherwise well-formed: Application Context item + ++ * User Information item with Maximum Length and Implementation Class UID ++ * sub-items. ++ * ++ * We cannot use the existing C++/C API to construct this since in DCMTK ++ * it is protected not to run any Presentation Contexts with 0 transfer ++ * syntaxes. ++ * ++ * @param outLen receives the total PDU length on the wire (incl. preamble) ++ * @return heap-allocated buffer (caller frees with delete[]) ++ */ ++static unsigned char* build_zero_ts_assoc_rq(unsigned long &outLen) ++{ ++ const char *appCtx = "1.2.840.10008.3.1.1.1"; // DICOM Application Context ++ const char *abstractStx = "1.2.840.10008.1.1"; // Verification SOP Class ++ const char *implClassUID = "1.2.276.0.7230010.3.0.3.6.0"; // random digits ++ ++ const unsigned short appCtxLen = OFstatic_cast(unsigned short, strlen(appCtx)); ++ const unsigned short abstractLen = OFstatic_cast(unsigned short, strlen(abstractStx)); ++ const unsigned short implLen = OFstatic_cast(unsigned short, strlen(implClassUID)); ++ ++ // Sub-PDU sizes (4-byte header + body). ++ const unsigned long appCtxItem = 4 + appCtxLen; ++ const unsigned long abstractItem = 4 + abstractLen; ++ // Presentation Context body = ctxID + 3 reserved + abstract syntax item. ++ const unsigned long pcBody = 4 + abstractItem; ++ const unsigned long pcItem = 4 + pcBody; ++ const unsigned long maxLenItem = 4 + 4; // Max Length sub-item (body = 4-byte maxPDU) ++ const unsigned long implItem = 4 + implLen; // Implementation Class UID sub-item ++ const unsigned long userInfoBody = maxLenItem + implItem; ++ const unsigned long userInfoItem = 4 + userInfoBody; ++ ++ // Fixed A-ASSOCIATE-RQ header before variable items: ++ // protocol(2) + reserved(2) + calledAE(16) + callingAE(16) + reserved(32) = 68 ++ const unsigned long fixedAfterLen = 2 + 2 + 16 + 16 + 32; ++ const unsigned long variableItems = appCtxItem + pcItem + userInfoItem; ++ ++ // PDU length field counts everything AFTER the 6-byte preamble ++ // (type + reserved + 4-byte length), i.e. the fixed header plus items. ++ const unsigned long pduPayloadLen = fixedAfterLen + variableItems; ++ const unsigned long totalLen = 6 + pduPayloadLen; ++ ++ unsigned char *buf = new unsigned char[totalLen]; ++ unsigned char *p = buf; ++ ++ // PDU preamble. ++ *p++ = DUL_TYPEASSOCIATERQ; // 0x01 ++ *p++ = 0x00; // reserved ++ put_u32_be(p, pduPayloadLen); ++ ++ // Fixed header. ++ put_u16_be(p, DUL_PROTOCOL); // protocol version ++ *p++ = 0x00; *p++ = 0x00; // reserved ++ { ++ const char *calledAE = "RECV_SCP "; // 16 bytes, space padded ++ const char *callingAE = "SEND_SCU "; // 16 bytes, space padded ++ for (int i = 0; i < 16; ++i) *p++ = OFstatic_cast(unsigned char, calledAE[i]); ++ for (int i = 0; i < 16; ++i) *p++ = OFstatic_cast(unsigned char, callingAE[i]); ++ } ++ for (int i = 0; i < 32; ++i) *p++ = 0x00; // reserved ++ ++ // Application Context item. ++ put_subitem_header(p, DUL_TYPEAPPLICATIONCONTEXT, appCtxLen); ++ for (unsigned short i = 0; i < appCtxLen; ++i) *p++ = OFstatic_cast(unsigned char, appCtx[i]); ++ ++ // Presentation Context item (with abstract syntax, NO transfer syntax). ++ put_subitem_header(p, DUL_TYPEPRESENTATIONCONTEXTRQ, OFstatic_cast(unsigned short, pcBody)); ++ *p++ = 0x01; // presentation context ID (odd, as required) ++ *p++ = 0x00; // reserved ++ *p++ = 0x00; // reserved (result/reason, unused in RQ) ++ *p++ = 0x00; // reserved ++ // Abstract Syntax sub-item. ++ put_subitem_header(p, DUL_TYPEABSTRACTSYNTAX, abstractLen); ++ for (unsigned short i = 0; i < abstractLen; ++i) *p++ = OFstatic_cast(unsigned char, abstractStx[i]); ++ // (intentionally no Transfer Syntax sub-item) ++ ++ // User Information item. ++ put_subitem_header(p, DUL_TYPEUSERINFO, OFstatic_cast(unsigned short, userInfoBody)); ++ // Maximum Length sub-item (body = 4-byte maximum PDU length). ++ put_subitem_header(p, DUL_TYPEMAXLENGTH, 4); ++ put_u32_be(p, 16384); ++ // Implementation Class UID sub-item. ++ put_subitem_header(p, DUL_TYPEIMPLEMENTATIONCLASSUID, implLen); ++ for (unsigned short i = 0; i < implLen; ++i) *p++ = OFstatic_cast(unsigned char, implClassUID[i]); ++ ++ OFCHECK_EQUAL(OFstatic_cast(unsigned long, p - buf), totalLen); ++ outLen = totalLen; ++ return buf; ++} ++ ++ ++/* Regression test for two leaks on the receiver-side handling of an ++ * A-ASSOCIATE-RQ whose single presentation context carries an Abstract Syntax ++ * but ZERO Transfer Syntaxes: ++ * ++ * 1. parseAssociate() succeeds, then translatePresentationContextList() ++ * rejects the context with an illegal transfer-syntax count and ++ * AE_6_ExamineAssociateRequest() returns DUL_PCTRANSLATIONFAILURE. The ++ * already-parsed PRV_ASSOCIATEPDU (presentation context + user ++ * information lists) must be freed on that error path. ++ * 2. translatePresentationContextList() itself must free the (empty) ++ * proposedTransferSyntax list it allocated for the rejected context. ++ * ++ * Both leaks are tracked in DCMTK issue #1217. ++ * ++ * A conformant DCMTK requestor cannot emit a zero-transfer-syntax context ++ * (ASC_addPresentationContext rejects it), so the malicious PDU is crafted by ++ * hand and sent over a raw TCP connection to an in-process DcmSCP. The leaks ++ * are asserted implicitly by LeakSanitizer at process exit (build with ++ * DCMTK_WITH_SANITIZERS=ON on Linux); the OFCHECKs below only assert the ++ * harness behaved (socket connected, send succeeded, SCP thread joined). ++ */ ++OFTEST(dcmnet_scp_assocRQ_zeroTransferSyntax_no_leak) ++{ ++ OFStandard::initializeNetwork(); ++ ++ OneShotReceiverSCP scp; ++ // The listen socket is already bound and listening (openListenPort() runs ++ // in the SCP constructor), so a client connect() succeeds via the TCP ++ // backlog even before the accept thread is scheduled -- no startup sleep ++ // is needed here. ++ scp.start(); ++ ++ DcmNativeSocketType sock = connect_loopback(scp.m_portNum); ++ OFCHECK(sock != OFstatic_cast(DcmNativeSocketType, -1)); ++ ++ if (sock != OFstatic_cast(DcmNativeSocketType, -1)) ++ { ++ DcmTCPConnection conn(sock); // takes ownership of the socket ++ ++ unsigned long pduLen = 0; ++ unsigned char *pdu = build_zero_ts_assoc_rq(pduLen); ++ ++ // Send the hand-built A-ASSOCIATE-RQ. This drives the SCP through ++ // parseAssociate() + translatePresentationContextList() (the original leak path). ++ unsigned long sent = 0; ++ OFBool sendOk = OFTrue; ++ while (sent < pduLen) ++ { ++ ssize_t n = conn.write(pdu + sent, OFstatic_cast(size_t, pduLen - sent)); ++ if (n <= 0) { sendOk = OFFalse; break; } ++ sent += OFstatic_cast(unsigned long, n); ++ } ++ OFCHECK(sendOk); ++ ++ // Close immediately after sending instead of reading a response. The ++ // buffered PDU is still delivered to the SCP before the FIN, so it ++ // reaches the leak path; the FIN then unblocks the SCP's receive call ++ // at once. Reading here would instead leave both ends blocked on each ++ // other until the socket receive timeout (dcmSocketReceiveTimeout, ++ // 60s by default) expired -- which made this test take ~63s. ++ delete[] pdu; ++ conn.close(); ++ } ++ ++ // Wait for the SCP thread to finish handling the (failed) association. ++ const int joinResult = scp.join(); ++ OFCHECK(joinResult != OFThread::busy); ++} ++ ++#endif // WITH_THREADS +--- dcmtk.orig/dcmnet/tests/tests.cc ++++ dcmtk/dcmnet/tests/tests.cc +@@ -51,6 +51,8 @@ + OFTEST_REGISTER(dcmnet_scu_sendNSETRequest_succeeds_and_modifies_instance_when_scp_has_instance); + OFTEST_REGISTER(dcmnet_scu_sendNSETRequest_succeeds_and_sets_responsestatuscode_from_scp_when_scp_sets_error_status); + ++OFTEST_REGISTER(dcmnet_scp_assocRQ_zeroTransferSyntax_no_leak); ++ + #endif // WITH_THREADS + + OFTEST_MAIN("dcmnet") diff --git a/debian/patches/CVE-2026-44628a.patch b/debian/patches/CVE-2026-44628a.patch new file mode 100644 index 00000000..9170c92c --- /dev/null +++ b/debian/patches/CVE-2026-44628a.patch @@ -0,0 +1,116 @@ +Applied-Upstream: f4e0074682645b1a4289d62581926c4394d5c6d5 +Author: Michael Onken +Last-Update: 2026-05-29 +Description: Fix wlmscpfs crash on VR-spoofed seq. attributes. + wldsfs.cc cast findAndGetElement() results to DcmSequenceOfItems* + without checking the VR. A C-FIND with a dictionary-SQ tag declared + under a non-SQ wire VR (Explicit VR) thus dispatched through the wrong + vtable and crashes the SCP (see also CVE-2024-28130 in dcmpstat). + . + Use findAndGetSequence(), which validates the VR. Also guard getItem(0) + against an empty ScheduledProcedureStepSequence and drop a bogus cast. + . + Thanks for the report and analysis to Abhinav Agarwal. + . + This closes DCMTK issue #1218. +Reviewed-By: Étienne Mollier +Bug-Debian: https://bugs.debian.org/1141411 + +diff --git a/dcmwlm/libsrc/wldsfs.cc b/dcmwlm/libsrc/wldsfs.cc +index ee83d7b07..7bb4db1f8 100644 +--- a/dcmwlm/libsrc/wldsfs.cc ++++ b/dcmwlm/libsrc/wldsfs.cc +@@ -1,6 +1,6 @@ + /* + * +- * Copyright (C) 1996-2025, OFFIS e.V. ++ * Copyright (C) 1996-2026, OFFIS e.V. + * All rights reserved. See COPYRIGHT file for details. + * + * This software and supporting documentation were developed by +@@ -171,20 +171,23 @@ void WlmDataSourceFileSystem::HandleExistentButEmptyDescriptionAndCodeSequenceAt + // codeSequenceTagKey - [in] DcmTagKey of the codeSequence attribute which shall be checked. + // Return Value : none. + { +- DcmElement *codeSequenceAttribute = NULL, *descriptionAttribute = NULL; ++ DcmSequenceOfItems *codeSequenceAttribute = NULL; ++ DcmElement *descriptionAttribute = NULL; + DcmElement *elementToRemove = NULL, *codeValueAttribute = NULL, *codingSchemeDesignatorAttribute = NULL; + OFBool codeSequenceAttributeRemoved = OFFalse; + +- // only do something with the code sequence attribute if it is contained in the dataset +- if( dataset->findAndGetElement( codeSequenceTagKey, codeSequenceAttribute ).good() ) ++ // only do something with the code sequence attribute if it is contained in the dataset. ++ // Note: findAndGetSequence() verifies the element's VR (SQ); a malformed element with a ++ // dictionary-SQ tag but a non-SQ wire VR is rejected here instead of being cast blindly. ++ if( dataset->findAndGetSequence( codeSequenceTagKey, codeSequenceAttribute ).good() ) + { + // if the code sequence attribute is empty or contains exactly one item with an empty + // CodeValue and an empty CodingSchemeDesignator, remove the attribute from the dataset +- if( ( ((DcmSequenceOfItems*)codeSequenceAttribute)->card() == 0 ) || +- ( ((DcmSequenceOfItems*)codeSequenceAttribute)->card() == 1 && +- ((DcmSequenceOfItems*)codeSequenceAttribute)->getItem(0)->findAndGetElement( DCM_CodeValue, codeValueAttribute ).good() && ++ if( ( codeSequenceAttribute->card() == 0 ) || ++ ( codeSequenceAttribute->card() == 1 && ++ codeSequenceAttribute->getItem(0)->findAndGetElement( DCM_CodeValue, codeValueAttribute ).good() && + codeValueAttribute->getLength() == 0 && +- ((DcmSequenceOfItems*)codeSequenceAttribute)->getItem(0)->findAndGetElement( DCM_CodingSchemeDesignator, codingSchemeDesignatorAttribute ).good() && ++ codeSequenceAttribute->getItem(0)->findAndGetElement( DCM_CodingSchemeDesignator, codingSchemeDesignatorAttribute ).good() && + codingSchemeDesignatorAttribute->getLength() == 0 ) ) + { + elementToRemove = dataset->remove( codeSequenceAttribute ); +@@ -221,18 +224,21 @@ void WlmDataSourceFileSystem::HandleExistentButEmptyReferencedStudyOrPatientSequ + // sequenceTagKey - [in] DcmTagKey of the sequence attribute which shall be checked. + // Return Value : none. + { +- DcmElement *sequenceAttribute = NULL, *referencedSOPClassUIDAttribute = NULL, *referencedSOPInstanceUIDAttribute = NULL; ++ DcmSequenceOfItems *sequenceAttribute = NULL; ++ DcmElement *referencedSOPClassUIDAttribute = NULL, *referencedSOPInstanceUIDAttribute = NULL; + + // in case the sequence attribute contains exactly one item with an empty +- // ReferencedSOPClassUID and an empty ReferencedSOPInstanceUID, remove the item +- if( dataset->findAndGetElement( sequenceTagKey, sequenceAttribute ).good() && +- ( (DcmSequenceOfItems*)sequenceAttribute )->card() == 1 && +- ( (DcmSequenceOfItems*)sequenceAttribute )->getItem(0)->findAndGetElement( DCM_ReferencedSOPClassUID, referencedSOPClassUIDAttribute ).good() && ++ // ReferencedSOPClassUID and an empty ReferencedSOPInstanceUID, remove the item. ++ // Note: findAndGetSequence() verifies the element's VR (SQ); a malformed element with a ++ // dictionary-SQ tag but a non-SQ wire VR is rejected here instead of being cast blindly. ++ if( dataset->findAndGetSequence( sequenceTagKey, sequenceAttribute ).good() && ++ sequenceAttribute->card() == 1 && ++ sequenceAttribute->getItem(0)->findAndGetElement( DCM_ReferencedSOPClassUID, referencedSOPClassUIDAttribute ).good() && + referencedSOPClassUIDAttribute->getLength() == 0 && +- ( (DcmSequenceOfItems*)sequenceAttribute )->getItem(0)->findAndGetElement( DCM_ReferencedSOPInstanceUID, referencedSOPInstanceUIDAttribute, OFFalse ).good() && ++ sequenceAttribute->getItem(0)->findAndGetElement( DCM_ReferencedSOPInstanceUID, referencedSOPInstanceUIDAttribute, OFFalse ).good() && + referencedSOPInstanceUIDAttribute->getLength() == 0 ) + { +- DcmItem *item = ((DcmSequenceOfItems*)sequenceAttribute)->remove( ((DcmSequenceOfItems*)sequenceAttribute)->getItem(0) ); ++ DcmItem *item = sequenceAttribute->remove( sequenceAttribute->getItem(0) ); + delete item; + } + } +@@ -257,7 +263,7 @@ WlmDataSourceStatusType WlmDataSourceFileSystem::StartFindRequest( const DcmData + // WLM_FAILED_IDENTIFIER_DOES_NOT_MATCH_SOP_CLASS - Error in the search mask encountered. + { + unsigned long i, j; +- DcmElement *scheduledProcedureStepSequenceAttribute = NULL; ++ DcmSequenceOfItems *scheduledProcedureStepSequenceAttribute = NULL; + + // Initialize offending elements, error elements and error comment. + delete offendingElements; +@@ -352,9 +358,13 @@ WlmDataSourceStatusType WlmDataSourceFileSystem::StartFindRequest( const DcmData + } + + // if the ScheduledProcedureStepSequence can be found in the current dataset, handle +- // existent but empty ScheduledProcedureStepDescription and ScheduledProtocolCodeSequence +- if( resultRecord->findAndGetElement( DCM_ScheduledProcedureStepSequence, scheduledProcedureStepSequenceAttribute, OFFalse ).good() ) +- HandleExistentButEmptyDescriptionAndCodeSequenceAttributes( ((DcmDataset*)((DcmSequenceOfItems*)scheduledProcedureStepSequenceAttribute)->getItem(0)), DCM_ScheduledProcedureStepDescription, DCM_ScheduledProtocolCodeSequence ); ++ // existent but empty ScheduledProcedureStepDescription and ScheduledProtocolCodeSequence. ++ // Note: findAndGetSequence() verifies the element's VR (SQ); a malformed element with a ++ // dictionary-SQ tag but a non-SQ wire VR is rejected here instead of being cast blindly. ++ // Also guard against an empty sequence so that getItem(0) does not return NULL. ++ if( resultRecord->findAndGetSequence( DCM_ScheduledProcedureStepSequence, scheduledProcedureStepSequenceAttribute, OFFalse ).good() && ++ scheduledProcedureStepSequenceAttribute->card() > 0 ) ++ HandleExistentButEmptyDescriptionAndCodeSequenceAttributes( scheduledProcedureStepSequenceAttribute->getItem(0), DCM_ScheduledProcedureStepDescription, DCM_ScheduledProtocolCodeSequence ); + + // handle existent but empty RequestedProcedureDescription and RequestedProcedureCodeSequence + HandleExistentButEmptyDescriptionAndCodeSequenceAttributes( resultRecord, DCM_RequestedProcedureDescription, DCM_RequestedProcedureCodeSequence ); diff --git a/debian/patches/CVE-2026-44628b.patch b/debian/patches/CVE-2026-44628b.patch new file mode 100644 index 00000000..105354b7 --- /dev/null +++ b/debian/patches/CVE-2026-44628b.patch @@ -0,0 +1,80 @@ +Applied-Upstream: 694a0a06a38015ce768fa161a62b148189f84959 +Author: Michael Onken +Last-Update: 2026-05-29 +Description: Fix VR-spoofed sequence cast crash in wlfsim, too. + Apply the same findAndGetSequence() fix to the unchecked casts in + DatasetIsComplete() and ReferencedStudyOrPatientSequenceIsAbsent...() + as in f4e0074. +Reviewed-By: Étienne Mollier +Bug-Debian: https://bugs.debian.org/1141411 + +diff --git a/dcmwlm/libsrc/wlfsim.cc b/dcmwlm/libsrc/wlfsim.cc +index 40a93c4eb..8543f795a 100644 +--- a/dcmwlm/libsrc/wlfsim.cc ++++ b/dcmwlm/libsrc/wlfsim.cc +@@ -493,7 +493,7 @@ OFBool WlmFileSystemInteractionManager::DatasetIsComplete( DcmDataset *dataset ) + // Return Value : OFTrue in case the given dataset contains all necessary return type 1 information, + // OFFalse otherwise. + { +- DcmElement *scheduledProcedureStepSequence = NULL; ++ DcmSequenceOfItems *scheduledProcedureStepSequence = NULL; + + // initialize returnValue + OFBool complete = OFTrue; +@@ -503,7 +503,7 @@ OFBool WlmFileSystemInteractionManager::DatasetIsComplete( DcmDataset *dataset ) + // the dataset is considered to be incomplete... + // ...if the ScheduledProcedureStepSequence is missing or + // ...if the ScheduledProcedureStepSequence does not have exactly one item +- if( dataset->findAndGetElement( DCM_ScheduledProcedureStepSequence, scheduledProcedureStepSequence ).bad() || ((DcmSequenceOfItems*)scheduledProcedureStepSequence)->card() != 1 ) ++ if( dataset->findAndGetSequence( DCM_ScheduledProcedureStepSequence, scheduledProcedureStepSequence ).bad() || scheduledProcedureStepSequence->card() != 1 ) + { + DCMWLM_DEBUG("- ScheduledProcedureStepSequence " << DCM_ScheduledProcedureStepSequence << " is missing or does not have exactly one item"); + complete = OFFalse; +@@ -512,7 +512,7 @@ OFBool WlmFileSystemInteractionManager::DatasetIsComplete( DcmDataset *dataset ) + { + // so the ScheduledProcedureStepSequence is existent and has exactly one item; + // get this one and only item from the ScheduledProcedureStepSequence +- DcmItem *scheduledProcedureStepSequenceItem = ((DcmSequenceOfItems*)scheduledProcedureStepSequence)->getItem(0); ++ DcmItem *scheduledProcedureStepSequenceItem = scheduledProcedureStepSequence->getItem(0); + + // the dataset is considered to be incomplete... + // ...if ScheduledStationAETitle is missing or empty in the ScheduledProcedureStepSequence, or +@@ -563,11 +563,14 @@ OFBool WlmFileSystemInteractionManager::ReferencedStudyOrPatientSequenceIsAbsent + // Return Value : OFTrue in case the sequence attribute is absent (and cannot be added to the dataset) + // or existent but non-empty and incomplete, OFFalse otherwise. + { +- DcmElement *sequence = NULL; ++ DcmSequenceOfItems *sequence = NULL; + OFBool result; + + // check whether the type 2 sequence attribute is absent +- if( dset->findAndGetElement( sequenceTagKey, sequence ).bad() ) ++ // (findAndGetSequence also fails if the attribute is present but does not have ++ // a sequence VR, e.g. due to a VR-spoofed Explicit VR encoding; in that case ++ // the malformed element is replaced below with a proper empty sequence) ++ if( dset->findAndGetSequence( sequenceTagKey, sequence ).bad() ) + { + DCMWLM_DEBUG("- " << DcmTag(sequenceTagKey).getTagName() << " " << sequenceTagKey << " is missing"); + // try to add it to the dataset and return OFFalse if successful +@@ -583,17 +586,17 @@ OFBool WlmFileSystemInteractionManager::ReferencedStudyOrPatientSequenceIsAbsent + { + // if the sequence attribute is existent but empty, we want to return OFFalse + // (note that the sequence is actually type 2, so being empty is ok) +- if( ((DcmSequenceOfItems*)sequence)->card() == 0 ) ++ if( sequence->card() == 0 ) + result = OFFalse; + else + { + // if the sequence attribute is existent and non-empty, we need + // to check every item in the sequence for completeness + result = OFFalse; +- for( unsigned long i=0 ; i<((DcmSequenceOfItems*)sequence)->card() && !result ; i++ ) ++ for( unsigned long i=0 ; icard() && !result ; i++ ) + { +- if( AttributeIsAbsentOrEmpty( DCM_ReferencedSOPClassUID, ((DcmSequenceOfItems*)sequence)->getItem(i) ) || +- AttributeIsAbsentOrEmpty( DCM_ReferencedSOPInstanceUID, ((DcmSequenceOfItems*)sequence)->getItem(i) ) ) ++ if( AttributeIsAbsentOrEmpty( DCM_ReferencedSOPClassUID, sequence->getItem(i) ) || ++ AttributeIsAbsentOrEmpty( DCM_ReferencedSOPInstanceUID, sequence->getItem(i) ) ) + result = OFTrue; + } + if ( result ) diff --git a/debian/patches/CVE-2026-50003.patch b/debian/patches/CVE-2026-50003.patch new file mode 100644 index 00000000..4171b412 --- /dev/null +++ b/debian/patches/CVE-2026-50003.patch @@ -0,0 +1,42 @@ +Applied-Upstream: eca9a03dda7d4fc1faa7e5a6dac9617938cf5803 +Author: Michael Onken +Last-Update: 2026-05-12 +Description: Fixed path traversal in DcmSCU bit-pres. C-GET. + In DCMSCU_STORAGE_BIT_PRESERVING mode, the C-STORE sub-operation + handler in handleCGETSession() built the on-disk filename from the + peer-supplied AffectedSOPInstanceUID without sanitization, allowing + a malicious C-STORE SCP to write files outside the configured + storage directory via path-separator or "../" sequences in the UID. + . + The DISK mode path was already sanitized (via createStorageFilename(), + fixed in commit f06a86751 for CVE-2022-2120); this branch was missed. + The fix mirrors the same pattern (sanitize a local OFString copy) so + the request struct stays intact and the C-STORE response still echoes + the peer's original UID per protocol. + . + Affects all consumers of DcmSCU using DCMSCU_STORAGE_BIT_PRESERVING, + including getscu --bit-preserving. + . + This fixes DCMTK issue #1207. + . + Thanks to Abhinav Agarwal for the report and analysis. +Reviewed-By: Étienne Mollier +Bug-Debian: https://bugs.debian.org/1141411 + +--- dcmtk.orig/dcmnet/libsrc/scu.cc ++++ dcmtk/dcmnet/libsrc/scu.cc +@@ -1220,9 +1220,13 @@ + // handle bit preserving storage mode, i.e. receive directly to disk + else if (m_storageMode == DCMSCU_STORAGE_BIT_PRESERVING) + { ++ // Sanitize SOP Instance UID before using it as part of a filename ++ // to prevent path traversal via malicious peers (see also CVE-2022-2120). ++ OFString uidForFilename = rsp.msg.CStoreRQ.AffectedSOPInstanceUID; ++ OFStandard::sanitizeFilename(uidForFilename); + OFString storageFilename; + OFStandard::combineDirAndFilename( +- storageFilename, m_storageDir, rsp.msg.CStoreRQ.AffectedSOPInstanceUID, OFTrue); ++ storageFilename, m_storageDir, uidForFilename, OFTrue); + result = handleSTORERequestFile(&pcid, storageFilename, &(rsp.msg.CStoreRQ)); + if (result.good()) + { diff --git a/debian/patches/CVE-2026-50254.patch b/debian/patches/CVE-2026-50254.patch new file mode 100644 index 00000000..845164e8 --- /dev/null +++ b/debian/patches/CVE-2026-50254.patch @@ -0,0 +1,174 @@ +Applied-Upstream: 23f181f7a3cb8334056f751a3a0c2ddf01046752 +Author: Michael Onken +Last-Update: 2026-05-26 +Description: Fix extNegList leaks on A-ASSOCIATE parse failure. + Thanks for the report and analysis to Abhinav Agarwal. + . + This closes DCMTK feature #126. +Reviewed-By: Étienne Mollier +Bug-Debian: https://bugs.debian.org/1141411 + +diff --git a/dcmnet/libsrc/dulconst.cc b/dcmnet/libsrc/dulconst.cc +index 063cf5aba..d7f17c923 100644 +--- a/dcmnet/libsrc/dulconst.cc ++++ b/dcmnet/libsrc/dulconst.cc +@@ -1,6 +1,6 @@ + /* + * +- * Copyright (C) 1994-2024, OFFIS e.V. ++ * Copyright (C) 1994-2026, OFFIS e.V. + * All rights reserved. See COPYRIGHT file for details. + * + * This software and supporting documentation were partly developed by +@@ -1087,6 +1087,14 @@ constructExtNeg(unsigned char type, + unsigned long length; + *rtnLength = 0; + ++ /* The transient list built here ends up as userInfo->extNegList of the ++ * outgoing PDU; via the shallow appendList() it borrows the sub-item ++ * pointers from the params-owned list. destroyUserInformationLists() ++ * (called once the PDU has been streamed) releases only the container, ++ * leaving the items in place for DUL_ClearServiceParameters() to free ++ * through the params side. See the ownership note in ++ * destroyUserInformationLists() (helpers.cc). ++ */ + if (type == DUL_TYPEASSOCIATERQ && params->requestedExtNegList != NULL) { + *lst = new SOPClassExtendedNegotiationSubItemList; + if (*lst == NULL) return EC_MemoryExhausted; +diff --git a/dcmnet/libsrc/dulfsm.cc b/dcmnet/libsrc/dulfsm.cc +index 604c4721f..30fc0bc44 100644 +--- a/dcmnet/libsrc/dulfsm.cc ++++ b/dcmnet/libsrc/dulfsm.cc +@@ -1,6 +1,6 @@ + /* + * +- * Copyright (C) 1994-2025, OFFIS e.V. ++ * Copyright (C) 1994-2026, OFFIS e.V. + * All rights reserved. See COPYRIGHT file for details. + * + * This software and supporting documentation were partly developed by +@@ -1027,7 +1027,14 @@ AE_3_AssociateConfirmationAccept(PRIVATE_NETWORKKEY ** /*network*/, + + } + +- /* extended negotiation */ ++ /* Extended negotiation: ownership of the sub-item objects transfers ++ * from the transient assoc.userInfo.extNegList to service->acceptedExtNegList ++ * via the shallow appendList(). The destroyUserInformationLists() call ++ * a few lines below deliberately frees only the list container; the ++ * items themselves will be released by DUL_ClearServiceParameters() ++ * through the service parameter list. See the ownership note in ++ * destroyUserInformationLists() (helpers.cc). ++ */ + if (assoc.userInfo.extNegList != NULL) { + service->acceptedExtNegList = new SOPClassExtendedNegotiationSubItemList; + if (service->acceptedExtNegList == NULL) return EC_MemoryExhausted; +@@ -1231,7 +1238,14 @@ AE_6_ExamineAssociateRequest(PRIVATE_NETWORKKEY ** /*network*/, + return DUL_PCTRANSLATIONFAILURE; + } + +- /* extended negotiation */ ++ /* Extended negotiation: ownership of the sub-item objects transfers ++ * from the transient assoc.userInfo.extNegList to service->requestedExtNegList ++ * via the shallow appendList(). The destroyUserInformationLists() call ++ * a few lines below frees only the list container; the items themselves ++ * are released later by DUL_ClearServiceParameters() through the service ++ * parameter list. See the ownership note in destroyUserInformationLists() ++ * (helpers.cc). ++ */ + if (assoc.userInfo.extNegList != NULL) { + service->requestedExtNegList = new SOPClassExtendedNegotiationSubItemList; + if (service->requestedExtNegList == NULL) return EC_MemoryExhausted; +diff --git a/dcmnet/libsrc/dulparse.cc b/dcmnet/libsrc/dulparse.cc +index 020c0fe0e..ea1738557 100644 +--- a/dcmnet/libsrc/dulparse.cc ++++ b/dcmnet/libsrc/dulparse.cc +@@ -1,6 +1,6 @@ + /* + * +- * Copyright (C) 1994-2024, OFFIS e.V. ++ * Copyright (C) 1994-2026, OFFIS e.V. + * All rights reserved. See COPYRIGHT file for details. + * + * This software and supporting documentation were partly developed by +@@ -292,6 +292,15 @@ parseAssociate(unsigned char *buf, unsigned long pduLength, + if (cond.bad()) + { + destroyAssociatePDUPresentationContextList(&assoc->presentationContextList); ++ /* On a parse error the parsed extended negotiation sub-items are only ++ * held by assoc->userInfo.extNegList; no service parameter list has ++ * adopted them yet. destroyUserInformationLists() releases the list ++ * container but not its members (see the ownership note there), so ++ * the SOPClassExtendedNegotiationSubItem objects must be released ++ * explicitly here. ++ */ ++ if (assoc->userInfo.extNegList != NULL) ++ deleteListMembers(*assoc->userInfo.extNegList); + destroyUserInformationLists(&assoc->userInfo); + } + return cond; +@@ -590,7 +599,17 @@ parseUserInfo(DUL_USERINFO * userInfo, + extNeg = new SOPClassExtendedNegotiationSubItem; + if (extNeg == NULL) return EC_MemoryExhausted; + cond = parseExtNeg(extNeg, buf, &length, userLength); +- if (cond.bad()) return cond; ++ if (cond.bad()) ++ { ++ /* extNeg has not yet been pushed to extNegList, so the ++ * outer cleanup in parseAssociate would not see it. Release ++ * it here. parseExtNeg only returns errors before allocating ++ * extNeg->serviceClassAppInfo, so there is no inner buffer ++ * to free. ++ */ ++ delete extNeg; ++ return cond; ++ } + if (userInfo->extNegList == NULL) + { + userInfo->extNegList = new SOPClassExtendedNegotiationSubItemList; +diff --git a/dcmnet/libsrc/extneg.cc b/dcmnet/libsrc/extneg.cc +index 26b659fd0..a6f2ab18a 100644 +--- a/dcmnet/libsrc/extneg.cc ++++ b/dcmnet/libsrc/extneg.cc +@@ -14,6 +14,12 @@ + #include "dcmtk/dcmnet/extneg.h" + + ++/* Shallow copy: 'to' receives the same SOPClassExtendedNegotiationSubItem* ++ * pointers held by 'from'. Both lists end up sharing the items; exactly one ++ * side may eventually call deleteListMembers() on them. See the ownership ++ * note in destroyUserInformationLists() (helpers.cc) for which side that is ++ * on each call path. ++ */ + void appendList(const SOPClassExtendedNegotiationSubItemList& from, SOPClassExtendedNegotiationSubItemList& to) + { + OFListConstIterator(SOPClassExtendedNegotiationSubItem*) i = from.begin(); +diff --git a/dcmnet/libsrc/helpers.cc b/dcmnet/libsrc/helpers.cc +index 9121926f7..9e59cd750 100644 +--- a/dcmnet/libsrc/helpers.cc ++++ b/dcmnet/libsrc/helpers.cc +@@ -1,6 +1,6 @@ + /* + * +- * Copyright (C) 2021, OFFIS e.V. ++ * Copyright (C) 2021-2026, OFFIS e.V. + * All rights reserved. See COPYRIGHT file for details. + * + * This software and supporting documentation were partly developed by +@@ -63,7 +63,15 @@ destroyUserInformationLists(DUL_USERINFO * userInfo) + } + LST_Destroy(&userInfo->SCUSCPRoleList); + +- /* extended negotiation */ ++ /* Extended negotiation: the list contents (SOPClassExtendedNegotiationSubItem*) ++ * are intentionally NOT deleted here. On all normal paths the items are ++ * shared (via the shallow appendList()) with the owning service parameter ++ * list (params->{requested,accepted}ExtNegList in DUL_ASSOCIATESERVICEPARAMETERS), ++ * which releases them through DUL_ClearServiceParameters() -> ++ * deleteListMembers(). Callers with no service-side owner (e.g. the ++ * parse-error cleanup in parseAssociate()) must call deleteListMembers() ++ * themselves before invoking this function. ++ */ + delete userInfo->extNegList; + userInfo->extNegList = NULL; + diff --git a/debian/patches/CVE-2026-52868.patch b/debian/patches/CVE-2026-52868.patch new file mode 100644 index 00000000..3be31545 --- /dev/null +++ b/debian/patches/CVE-2026-52868.patch @@ -0,0 +1,746 @@ +commit e3878daf870cd2db50eadfde38615f0afae8a584 +Author: Michael Onken +Date: Tue May 19 17:16:08 2026 +0200 + + Fix path traversal in wlmscpfs through Called AET. + + The wlmscpfs SCP appended the Called Application Entity Title received + in the A-ASSOCIATE-RQ directly onto the configured worklist data file + path and used the existence of the resulting directory as an access + control decision. Because DICOM VR AE permits the characters "/", "\" + and ".", a peer could send a 16-byte AE title such as "../secret/VICTIM" + and have wlmscpfs serve worklist records from a sibling directory of + the configured root. With option --request-file-path enabled, the same + AE title (and the Patient ID) was substituted into the output filename + template without sanitization, producing an arbitrary-location write + primitive outside the configured request file directory. + + This commit closes both holes: + + - wlmscpfs now rejects any A-ASSOCIATE-RQ whose Called AE title is not + safe to use as a single filesystem path component, refusing the + association with WLM_BAD_AE_SERVICE. The validation is implemented in + the new static method WlmFileSystemInteractionManager::IsValidAETitle\ + ForFilesystem, which rejects empty or over-long titles, any title + containing a dot, and delegates the remaining character check to + OFStandard::sanitizeAETitle (sanitize-and-compare). + + - The placeholder substitution in storeRequestToFile now passes each + substituted value (#a, #c, #p) through OFStandard::sanitizeAETitle + before insertion, and additionally sanitizes the final filename, so + that any path separator surviving the substitution is defanged. + + Supporting changes: + + - Promote storescp's private sanitizeAETitle helper into the public + OFStandard::sanitizeAETitle, with documentation noting that the + function is also used by wlmscpfs to validate filesystem path + components (so widening the allow list has downstream effects). + - Replace storescp's local copy of the helper with the new public one. + - Add a forward declaration of DcmSequenceOfItems in wlfsim.h that + was previously missing (existing callers happened to include dctk.h + first). + - Document the new behaviour in the wlmscpfs man page. + + Tests: + + - New ofstd_OFStandard_sanitizeAETitle test covers the lifted helper + (path separators, NUL, control bytes, high-range bytes, shell + metacharacters, the quotation-mark preservation behaviour, and + empty/length-1 edge cases). + - New dcmwlm_aetitle_validation test covers the validator directly + with every path-traversal payload from the bug report ("../secret/\ + VICTIM", "../CARDIOLOGY", "..", ".", "..\secret"), dotted variants + ("MY.AE", "foo..bar", ".foo", "foo."), structural rejections (empty, + 17 bytes, embedded NUL, tab, 0xFF), shell metacharacters, and a row + of legitimate AE titles that must still be accepted. + - dcmwlm previously had no OFTEST scaffolding; tests.cc has been added + along with the corresponding CMakeLists.txt and Makefile.in entries. + + Thanks to Abhinav Agarwal for the report. + +--- dcmtk.orig/dcmnet/apps/storescp.cc ++++ dcmtk/dcmnet/apps/storescp.cc +@@ -95,7 +95,6 @@ + static OFString replaceChars( const OFString &srcstr, const OFString &pattern, const OFString &substitute ); + static void executeCommand( const OFString &cmd ); + static void cleanChildren(pid_t pid, OFBool synch); +-static void sanitizeAETitle(OFString& aet); + static OFCondition acceptUnknownContextsWithPreferredTransferSyntaxes( + T_ASC_Parameters * params, + const char* transferSyntaxes[], +@@ -2338,34 +2337,6 @@ + lastStudySubdirectoryPathAndName.clear(); + } + +-/* replace all characters that might be interpreted by the shell with underscores +- */ +-static void sanitizeAETitle(OFString& aet) +-{ +- static const char sanitized_aetitle_charset[] = +- { +- ' ', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '-', '.', '_', +- '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', '_', '_', '_', '_', '_', +- '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', +- 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '_', '_', '_', '_', '_', +- '_', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', +- 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '_', '_', '_', '_', '_' +- }; +- +- // the aet string starts and ends with quotation marks. We ignore these. +- size_t len = aet.length(); +- if (len < 3) return; +- +- char c; +- --len; +- for (size_t i=1; i < len; ++i) +- { +- c = aet[i]; +- if (c != 0 && (c < 32 || c >= 127)) c = '_'; else c = sanitized_aetitle_charset[c-32]; +- aet[i] = c; +- } +-} +- + static void executeOnReception() + /* + * This function deals with the execution of the command line which was passed +@@ -2401,7 +2372,7 @@ + // perform substitution for placeholder #a. + // Note that this string is already enclosed in double quotes at this point + s = callingAETitle; +- sanitizeAETitle(s); ++ OFStandard::sanitizeAETitle(s); + if (s != callingAETitle) + { + OFLOG_WARN(storescpLogger, "Sanitized unusual characters in calling aetitle, converted from " << callingAETitle << " to " << s << "."); +@@ -2411,7 +2382,7 @@ + // perform substitution for placeholder #c. + // Note that this string is already enclosed in double quotes at this point + s = calledAETitle; +- sanitizeAETitle(s); ++ OFStandard::sanitizeAETitle(s); + if (s != calledAETitle) + { + OFLOG_WARN(storescpLogger, "Sanitized unusual characters in called aetitle, converted from " << calledAETitle << " to " << s << "."); +@@ -2421,7 +2392,7 @@ + // perform substitution for placeholder #r. + // Note that this string is already enclosed in double quotes at this point + s = callingPresentationAddress; +- sanitizeAETitle(s); ++ OFStandard::sanitizeAETitle(s); + if (s != callingPresentationAddress) + { + OFLOG_WARN(storescpLogger, "Sanitized unusual characters in calling presentation address, converted from " << callingPresentationAddress << " to " << s << "."); +@@ -2537,7 +2508,7 @@ + // perform substitution for placeholder #a. + // Note that this string is already enclosed in double quotes at this point + s = callingAETitle; +- sanitizeAETitle(s); ++ OFStandard::sanitizeAETitle(s); + if (s != callingAETitle) + { + OFLOG_WARN(storescpLogger, "Sanitized unusual characters in calling aetitle, converted from " << callingAETitle << " to " << s << "."); +@@ -2547,7 +2518,7 @@ + // perform substitution for placeholder #c. + // Note that this string is already enclosed in double quotes at this point + s = calledAETitle; +- sanitizeAETitle(s); ++ OFStandard::sanitizeAETitle(s); + if (s != calledAETitle) + { + OFLOG_WARN(storescpLogger, "Sanitized unusual characters in called aetitle, converted from " << calledAETitle << " to " << s << "."); +@@ -2557,7 +2528,7 @@ + // perform substitution for placeholder #r. + // Note that this string is already enclosed in double quotes at this point + s = callingPresentationAddress; +- sanitizeAETitle(s); ++ OFStandard::sanitizeAETitle(s); + if (s != callingPresentationAddress) + { + OFLOG_WARN(storescpLogger, "Sanitized unusual characters in calling presentation address, converted from " << callingPresentationAddress << " to " << s << "."); +--- dcmtk.orig/dcmwlm/docs/wlmscpfs.man ++++ dcmtk/dcmwlm/docs/wlmscpfs.man +@@ -242,6 +242,23 @@ + Table K.6-1 in part 4 annex K of the DICOM standard lists all corresponding + type 1 attributes (see column "Return Key Type"). + ++The called Application Entity Title from an incoming A-ASSOCIATE-RQ is used ++as a subdirectory name below the \e --data-file-path directory in order to ++look up the worklist files that this association may access. The DICOM VR ++AE (see DICOM PS3.5 Section 6.1.3) permits the characters "/" (forward slash) ++and "." (full stop), so a conformant peer can send an AE title such as ++"../OTHER" that would resolve to a sibling directory of the configured ++worklist root. To prevent this, \b wlmscpfs rejects any A-ASSOCIATE-RQ ++whose called AE title is not safe to use as a single filesystem path ++component. The set of accepted characters is letters, digits, space and ++the characters "-", ":", "@" and "_"; any other byte (path separators ++"/" and "\\", NUL, control bytes, bytes outside the printable ASCII range, ++shell metacharacters as well as the dot character) causes the association ++to be rejected. Dots are rejected outright (rather than only the ++standalone components "." and "..") because real-world AE titles never ++contain dots and rejecting the entire character avoids any need to reason ++about platform-specific path normalization corner cases. ++ + \subsection wlmscpfs_request_files Writing Request Files + + Providing option \e --request-file-path enables writing of the incoming C-FIND +@@ -300,6 +317,13 @@ + cannot be written successfully or will look broken once written. Also, an empty + Patient ID is used as such, i.e. the \#p will be replaced with an empty string. + ++For security reasons, the values substituted for the placeholders \#a, \#c and ++\#p are sanitized before they are inserted into the resulting file name: path ++separators ("/", "\\") and other characters that are not safe in a file name ++component are replaced with the underscore character ("_"). This prevents ++attacker-controlled values transmitted over the network (called/calling AE ++title, Patient ID) from escaping the configured request file directory. ++ + \subsection wlmscpfs_dicom_conformance DICOM Conformance + + The \b wlmscpfs application supports the following SOP Classes as an SCP: +--- dcmtk.orig/dcmwlm/include/dcmtk/dcmwlm/wlfsim.h ++++ dcmtk/dcmwlm/include/dcmtk/dcmwlm/wlfsim.h +@@ -34,6 +34,7 @@ + class DcmTagKey; + class OFCondition; + class DcmItem; ++class DcmSequenceOfItems; + class OFdirectory_iterator; + + /** This class encapsulates data structures and operations for managing +@@ -199,13 +200,53 @@ + + /** Checks if the given called application entity title is supported. If this is the case, + * OFTrue will be returned, else OFFalse. ++ * ++ * The check is performed in two stages. First, the AE title is validated against the ++ * rules of IsValidAETitleForFilesystem(): titles that contain path separators, embedded ++ * NUL or other control characters, or any dot character are rejected outright (returning ++ * OFFalse). This prevents the wire-side AE title from escaping the worklist data file ++ * path through path traversal. Second, the AE title is appended to the configured ++ * worklist root path and the resulting directory must exist. ++ * + * @param calledApplicationEntityTitlev The application entity title which shall be checked +- * for support. Valid pointer expected. ++ * for support. + * @return OFTrue, if the called application entity title is supported, +- * OFFalse, if the called application entity title is not supported or it is not given. ++ * OFFalse, if the called application entity title is not supported, not given, ++ * or rejected because it would not be safe to use as a filesystem path component. + */ + OFBool IsCalledApplicationEntityTitleSupported( const OFString& calledApplicationEntityTitlev ); + ++ /** Determines whether the given DICOM Application Entity Title is safe to use as a single ++ * directory or filename component below the worklist data file path. ++ * ++ * An AE title is considered safe if and only if all of the following hold: ++ * - it is not empty and not longer than 16 bytes (the DICOM AE VR length limit), ++ * - it contains no dot ('.') at all, ++ * - OFStandard::sanitizeAETitle() would not change any byte of it. ++ * ++ * The third rule delegates the character-set check to OFStandard::sanitizeAETitle(), ++ * which is the same allow list used by \b storescp for filename and shell-substitution ++ * contexts: letters, digits, space and the characters '-', '.', ':', '@', '_' are kept, ++ * every other byte (path separators '/' and '\\', NUL, control characters, bytes ++ * outside the printable ASCII range and shell metacharacters) is replaced. If the ++ * sanitized string differs from the input, the AE title contains at least one such ++ * unsafe byte and is rejected. ++ * ++ * Dots are rejected by the explicit second rule even though sanitizeAETitle() keeps ++ * them. Real-world AE titles never contain dots, and rejecting the entire character ++ * avoids any need to reason about path normalization corner cases (".", "..", trailing ++ * dots on Windows etc.). ++ * ++ * Note that this delegation couples the rule applied here to the allow list inside ++ * OFStandard::sanitizeAETitle(). If that allow list is widened in the future, the set ++ * of AE titles accepted as safe filesystem path components widens too, so any change ++ * to it must consider downstream effects on \b wlmscpfs. ++ * ++ * @param aeTitle Application Entity Title to validate. ++ * @return OFTrue if the AE title is safe to use as a path component, OFFalse otherwise. ++ */ ++ static OFBool IsValidAETitleForFilesystem( const OFString& aeTitle ); ++ + /** This function determines the records from the Worklist files that match + * the given search mask and returns the number of matching records. Also, + * this function will store the matching records inside the member variable +--- dcmtk.orig/dcmwlm/libsrc/wlfsim.cc ++++ dcmtk/dcmwlm/libsrc/wlfsim.cc +@@ -1,6 +1,6 @@ + /* + * +- * Copyright (C) 1996-2024, OFFIS e.V. ++ * Copyright (C) 1996-2026, OFFIS e.V. + * All rights reserved. See COPYRIGHT file for details. + * + * This software and supporting documentation were developed by +@@ -155,6 +155,32 @@ + + // ---------------------------------------------------------------------------- + ++OFBool WlmFileSystemInteractionManager::IsValidAETitleForFilesystem( const OFString& aeTitle ) ++{ ++ // Length must be 1..16 (DICOM AE VR limit). ++ if( aeTitle.empty() || aeTitle.length() > 16 ) ++ return OFFalse; ++ ++ // OFStandard::sanitizeAETitle() keeps '.' in its allow list (the function ++ // is also used in shell-substitution contexts where dots are common in ++ // AE titles), so reject dots explicitly here. Rejecting the entire ++ // character avoids any need to reason about platform-specific path ++ // normalization corner cases (".", "..", trailing dots on Windows etc.). ++ if( aeTitle.find('.') != OFString_npos ) ++ return OFFalse; ++ ++ // Delegate the remaining character check to OFStandard::sanitizeAETitle(). ++ // If the sanitizer would change any byte, the AE title contains characters ++ // that are not safe to use as a single filesystem path component (path ++ // separators, NUL, control bytes, bytes outside the printable ASCII range ++ // and shell metacharacters). ++ OFString sanitized = aeTitle; ++ OFStandard::sanitizeAETitle( sanitized ); ++ return ( sanitized == aeTitle ) ? OFTrue : OFFalse; ++} ++ ++// ---------------------------------------------------------------------------- ++ + OFBool WlmFileSystemInteractionManager::IsCalledApplicationEntityTitleSupported( const OFString& calledApplicationEntityTitlev ) + // Date : July 11, 2002 + // Author : Thomas Wilkens +@@ -168,6 +194,15 @@ + // copy value + calledApplicationEntityTitle = calledApplicationEntityTitlev; + ++ // Reject AE titles that are not safe to use as a filesystem path component. ++ // This guards against path traversal via path separators or "../" segments ++ // smuggled in through the wire-side AE title. ++ if( !IsValidAETitleForFilesystem( calledApplicationEntityTitle ) ) ++ { ++ DCMWLM_WARN( "Refusing called AE title because it is not safe to use as a directory name (contains path separators, control characters or is \".\"/\"..\"); rejecting association" ); ++ return( OFFalse ); ++ } ++ + // Determine complete path to the files that make up the data source. + OFString fullPath( dfPath ); + if( !fullPath.empty() && fullPath[fullPath.length()-1] != PATH_SEPARATOR ) +--- dcmtk.orig/dcmwlm/libsrc/wlmactmg.cc ++++ dcmtk/dcmwlm/libsrc/wlmactmg.cc +@@ -1174,10 +1174,19 @@ + static void storeRequestToFile(DcmDataset& request, const OFString& callingAE, const OFString& calledAE, const OFString& reqFilePath, const OFString& reqFileFormat) + { + OFString fileName = reqFileFormat; +- // Called Application Entity Title +- OFStringUtil::replace_all(fileName, WLM_CALLED_AETITLE_PLACEHOLDER, calledAE); +- // Calling Application Entity Title +- OFStringUtil::replace_all(fileName, WLM_CALLING_AETITLE_PLACEHOLDER, callingAE); ++ ++ // Each substituted value is sanitized before being inserted into the ++ // filename template. This prevents the calling/called AE title and the ++ // Patient ID (all originating on the wire) from injecting path ++ // separators or other unsafe characters into the resulting filename ++ // and thereby escaping the configured request file directory. ++ OFString safeCalledAE = calledAE; ++ OFStandard::sanitizeAETitle(safeCalledAE); ++ OFStringUtil::replace_all(fileName, WLM_CALLED_AETITLE_PLACEHOLDER, safeCalledAE); ++ ++ OFString safeCallingAE = callingAE; ++ OFStandard::sanitizeAETitle(safeCallingAE); ++ OFStringUtil::replace_all(fileName, WLM_CALLING_AETITLE_PLACEHOLDER, safeCallingAE); + + // Process ID + int processID = dcmtk::log4cplus::internal::get_process_id(); +@@ -1199,8 +1208,15 @@ + // Patient ID goes last since it might contain placeholders again (".#x...)" + OFString patientID; + request.findAndGetOFStringArray(DCM_PatientID, patientID); ++ OFStandard::sanitizeAETitle(patientID); + OFStringUtil::replace_all(fileName, WLM_PATIENT_ID_PLACEHOLDER, patientID); + ++ // Defense in depth: also sanitize the fully assembled filename so that ++ // any path separator that survived (for example through interaction ++ // between placeholder substitutions or via the user-supplied format ++ // string itself) is replaced before the file is opened. ++ OFStandard::sanitizeAETitle(fileName); ++ + // Finally store file + STD_NAMESPACE ofstream outputStream; + OFString fullPath; +--- dcmtk.orig/dcmwlm/tests/CMakeLists.txt ++++ dcmtk/dcmwlm/tests/CMakeLists.txt +@@ -1,5 +1,10 @@ + # declare executables + DCMTK_ADD_TEST_EXECUTABLE(wltest wltest.cc) ++DCMTK_ADD_TEST_EXECUTABLE(dcmwlm_tests tests.cc twlaetval.cc) + + # make sure executables are linked to the corresponding libraries + DCMTK_TARGET_LINK_MODULES(wltest dcmwlm dcmtls) ++DCMTK_TARGET_LINK_MODULES(dcmwlm_tests dcmwlm) ++ ++# This macro parses tests.cc and registers all tests ++DCMTK_ADD_TESTS(dcmwlm) +--- dcmtk.orig/dcmwlm/tests/Makefile.in ++++ dcmtk/dcmwlm/tests/Makefile.in +@@ -25,19 +25,25 @@ + LOCALLIBS = -ldcmwlm -ldcmnet -ldcmdata -loflog -lofstd -loficonv $(ZLIBLIBS) \ + $(TCPWRAPPERLIBS) $(CHARCONVLIBS) $(MATHLIBS) + +-objs = wltest.o +-progs = wltest ++test_objs = tests.o twlaetval.o ++objs = wltest.o $(test_objs) ++progs = wltest dcmwlm_tests + + + all: $(progs) + +-wltest: $(objs) +- $(CXX) $(CXXFLAGS) $(LIBDIRS) $(LDFLAGS) -o $@ $(objs) $(LOCALLIBS) $(LIBS) ++wltest: wltest.o ++ $(CXX) $(CXXFLAGS) $(LIBDIRS) $(LDFLAGS) -o $@ wltest.o $(LOCALLIBS) $(LIBS) + ++dcmwlm_tests: $(test_objs) ++ $(CXX) $(CXXFLAGS) $(LIBDIRS) $(LDFLAGS) -o $@ $(test_objs) $(LOCALLIBS) $(LIBS) + +-check: + +-check-exhaustive: ++check: dcmwlm_tests ++ ./dcmwlm_tests ++ ++check-exhaustive: dcmwlm_tests ++ ./dcmwlm_tests -x + + + install: all +--- /dev/null ++++ dcmtk/dcmwlm/tests/tests.cc +@@ -0,0 +1,27 @@ ++/* ++ * ++ * Copyright (C) 2026, OFFIS e.V. ++ * All rights reserved. See COPYRIGHT file for details. ++ * ++ * This software and supporting documentation were developed by ++ * ++ * OFFIS e.V. ++ * R&D Division Health ++ * Escherweg 2 ++ * D-26121 Oldenburg, Germany ++ * ++ * ++ * Module: dcmwlm ++ * ++ * Author: Michael Onken ++ * ++ * Purpose: main test program ++ * ++ */ ++ ++#include "dcmtk/config/osconfig.h" ++#include "dcmtk/ofstd/oftest.h" ++ ++OFTEST_REGISTER(dcmwlm_aetitle_validation); ++ ++OFTEST_MAIN("dcmwlm") +--- /dev/null ++++ dcmtk/dcmwlm/tests/twlaetval.cc +@@ -0,0 +1,116 @@ ++/* ++ * ++ * Copyright (C) 2026, OFFIS e.V. ++ * All rights reserved. See COPYRIGHT file for details. ++ * ++ * This software and supporting documentation were developed by ++ * ++ * OFFIS e.V. ++ * R&D Division Health ++ * Escherweg 2 ++ * D-26121 Oldenburg, Germany ++ * ++ * ++ * Module: dcmwlm ++ * ++ * Author: Michael Onken ++ * ++ * Purpose: Tests for the AE title validation used by the wlmscpfs ++ * filesystem worklist SCP to guard against path traversal ++ * through the Called Application Entity Title. ++ * ++ */ ++ ++#include "dcmtk/config/osconfig.h" ++ ++#include "dcmtk/ofstd/oftest.h" ++#include "dcmtk/ofstd/ofstring.h" ++#include "dcmtk/dcmwlm/wlfsim.h" ++ ++ ++// Helper: build an OFString that contains a single NUL byte in the middle ++// of an otherwise printable AE title. OFString tolerates embedded NUL bytes ++// when constructed from a (pointer, length) pair, which is how a NUL byte ++// could enter wlmscpfs from the wire. ++static OFString aeTitleWithEmbeddedNul() ++{ ++ const char raw[] = { 'A', 'B', '\0', 'C', 'D' }; ++ return OFString(raw, sizeof(raw)); ++} ++ ++ ++OFTEST(dcmwlm_aetitle_validation) ++{ ++ // ----- Path traversal payloads from the original bug report. ----- ++ // All of these must be rejected; if any of them is accepted, the ++ // CVE-2026-... path traversal hole is open again. ++ ++ // 16-byte payload from the proof of concept ("../secret/VICTIM"). ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("../secret/VICTIM")); ++ ++ // Multi-AET demo ("../CARDIOLOGY"). ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("../CARDIOLOGY")); ++ ++ // Plain parent reference and self reference. ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("..")); ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem(".")); ++ ++ // Windows-style separator. ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("..\\secret")); ++ ++ // Leading-dot variants and dots in the middle. None of these are ++ // path-traversal payloads on their own, but we reject any AE title ++ // that contains a dot, see the documentation of ++ // IsValidAETitleForFilesystem() for the rationale. ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem(".foo")); ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("foo.")); ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("foo..bar")); ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("MY.AE")); ++ ++ // ----- Other unsafe input. ----- ++ ++ // Empty AE title. ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("")); ++ ++ // Too long (17 bytes; the DICOM AE VR allows at most 16). ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("ABCDEFGHIJKLMNOPQ")); ++ ++ // Embedded NUL byte. ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem(aeTitleWithEmbeddedNul())); ++ ++ // Control character (tab). ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("AB\tCD")); ++ ++ // High-range byte. ++ { ++ OFString withHigh; ++ withHigh.append("AE", 2); ++ withHigh.append(1, OFstatic_cast(char, 0xFF)); ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem(withHigh)); ++ } ++ ++ // Shell metacharacters: rejected because ++ // OFStandard::sanitizeAETitle() would replace them. ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("AE;rm")); ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("AE|cat")); ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("AE`id`")); ++ OFCHECK(!WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("AE$VAR")); ++ ++ // ----- Legitimate AE titles must still be accepted. ----- ++ ++ // Plain alphanumeric, mixed case. ++ OFCHECK(WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("WORKLIST")); ++ OFCHECK(WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("CARDIOLOGY")); ++ OFCHECK(WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("MyAE")); ++ ++ // Single character (minimum length). ++ OFCHECK(WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("A")); ++ ++ // Maximum length (16 bytes). ++ OFCHECK(WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("ABCDEFGHIJKLMNOP")); ++ ++ // Conformant punctuation that is also safe as a path component. ++ OFCHECK(WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("MY-AE_1")); ++ OFCHECK(WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("AE@HOST")); ++ OFCHECK(WlmFileSystemInteractionManager::IsValidAETitleForFilesystem("AE:1")); ++} +--- dcmtk.orig/ofstd/include/dcmtk/ofstd/ofstd.h ++++ dcmtk/ofstd/include/dcmtk/ofstd/ofstd.h +@@ -1181,6 +1181,44 @@ + */ + static void sanitizeFilename(char *fname); + ++ /** sanitize a DICOM Application Entity Title for safe use as a ++ * substitution value in a filename or in a command line that will ++ * be passed to a shell. ++ * ++ * This method maps each byte through an allow list: ASCII letters, ++ * digits, space and the characters '-', '.', ':', '@', '_' are kept ++ * unchanged; every other byte (including path separators '/' and ++ * '\\', NUL, control characters, shell metacharacters and bytes ++ * outside the printable ASCII range) is replaced by '_'. ++ * ++ * The aetitle string is expected to be already trimmed (i.e. without ++ * leading or trailing whitespace). Surrounding quotation marks, if ++ * present, are preserved unchanged (the first and last byte are not ++ * rewritten) so that quoted AE titles intended for shell command ++ * substitution remain quoted. ++ * ++ * This sanitization is sufficient for preventing shell injection in ++ * contexts where the AE title is substituted into an already quoted ++ * command-line argument, and for preventing path separators from ++ * appearing in filenames. It is NOT sufficient to prevent path ++ * traversal on its own, because the character '.' is kept and the ++ * sequence "..", which is a path-relative parent reference on most ++ * operating systems, is therefore not removed. Callers that ++ * substitute the result into a filesystem path must reject or ++ * collapse such sequences explicitly. ++ * ++ * Note: this function is also used by the wlmscpfs application as ++ * the basis of its filesystem-path-component validation (an AE title ++ * is accepted only if sanitizeAETitle() would not change it, in ++ * addition to an explicit dot rejection). Widening the allow list ++ * below therefore widens the set of AE titles accepted as worklist ++ * directory names; any change to it must consider that downstream ++ * effect. ++ * ++ * @param aetitle Application Entity Title to be sanitized in place. ++ */ ++ static void sanitizeAETitle(OFString& aetitle); ++ + /** retrieve the name of the default directory for support data. + * On Windows, this method resolves environment variables such as + * \%PROGRAMDATA% in the path, on Posix platforms it just returns +--- dcmtk.orig/ofstd/libsrc/ofstd.cc ++++ dcmtk/ofstd/libsrc/ofstd.cc +@@ -3440,6 +3440,42 @@ + } + + ++// Allow list used by sanitizeAETitle(). Index is (byte - 32), so the ++// table covers the printable ASCII range 0x20..0x7E. Every entry either ++// repeats the input byte (kept) or is '_' (replaced). Kept characters: ++// space, '-', '.', ':', '@', '_' and ASCII letters/digits. All shell ++// metacharacters and path separators map to '_'. ++static const char sanitized_aetitle_charset[] = ++{ ++ ' ', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '-', '.', '_', ++ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', '_', '_', '_', '_', '_', ++ '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', ++ 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '_', '_', '_', '_', '_', ++ '_', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', ++ 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '_', '_', '_', '_', '_' ++}; ++ ++ ++void OFStandard::sanitizeAETitle(OFString& aetitle) ++{ ++ // Preserve a surrounding pair of quotation marks (used by callers ++ // that substitute the AE title into an already quoted shell argument). ++ size_t len = aetitle.length(); ++ size_t start = 0; ++ if (len >= 2 && aetitle[0] == '"' && aetitle[len - 1] == '"') ++ { ++ start = 1; ++ --len; ++ } ++ for (size_t i = start; i < len; ++i) ++ { ++ unsigned char c = OFstatic_cast(unsigned char, aetitle[i]); ++ if (c < 32 || c >= 127) aetitle[i] = '_'; ++ else aetitle[i] = sanitized_aetitle_charset[c - 32]; ++ } ++} ++ ++ + OFString OFStandard::getDefaultSupportDataDir() + { + #ifdef HAVE_WINDOWS_H +--- dcmtk.orig/ofstd/tests/tests.cc ++++ dcmtk/ofstd/tests/tests.cc +@@ -1,6 +1,6 @@ + /* + * +- * Copyright (C) 2011-2025, OFFIS e.V. ++ * Copyright (C) 2011-2026, OFFIS e.V. + * All rights reserved. See COPYRIGHT file for details. + * + * This software and supporting documentation were developed by +@@ -48,6 +48,7 @@ + OFTEST_REGISTER(ofstd_OFStandard_isReadWriteable); + OFTEST_REGISTER(ofstd_OFStandard_appendFilenameExtension); + OFTEST_REGISTER(ofstd_OFStandard_removeRootDirFromPathname); ++OFTEST_REGISTER(ofstd_OFStandard_sanitizeAETitle); + OFTEST_REGISTER(ofstd_OFFile); + OFTEST_REGISTER(ofstd_OFString_compare); + OFTEST_REGISTER(ofstd_OFString_concatenate); +--- dcmtk.orig/ofstd/tests/tofstd.cc ++++ dcmtk/ofstd/tests/tofstd.cc +@@ -1,6 +1,6 @@ + /* + * +- * Copyright (C) 2002-2022, OFFIS e.V. ++ * Copyright (C) 2002-2026, OFFIS e.V. + * All rights reserved. See COPYRIGHT file for details. + * + * This software and supporting documentation were developed by +@@ -373,6 +373,62 @@ + OFCHECK(OFStandard::removeRootDirFromPathname(result, nullPtr, nullPtr).good()); + } + ++OFTEST(ofstd_OFStandard_sanitizeAETitle) ++{ ++ // Pure-ASCII alphanumeric is preserved. ++ OFString s = "MY_AE_42"; ++ OFStandard::sanitizeAETitle(s); ++ OFCHECK_EQUAL(s, "MY_AE_42"); ++ ++ // Dots, colons, dashes, '@' are part of the allow list. ++ s = "AE.TITLE:1@host-1"; ++ OFStandard::sanitizeAETitle(s); ++ OFCHECK_EQUAL(s, "AE.TITLE:1@host-1"); ++ ++ // Path separators map to underscores. ++ s = "../etc/passwd"; ++ OFStandard::sanitizeAETitle(s); ++ OFCHECK_EQUAL(s, ".._etc_passwd"); ++ ++ // Backslash is replaced (Windows path separator). ++ s = "..\\secret"; ++ OFStandard::sanitizeAETitle(s); ++ OFCHECK_EQUAL(s, ".._secret"); ++ ++ // Shell metacharacters are replaced. ++ s = "AE;rm -rf /"; ++ OFStandard::sanitizeAETitle(s); ++ OFCHECK_EQUAL(s, "AE_rm -rf _"); ++ ++ // Control characters (including NUL inside the string) are replaced. ++ OFString withNul; ++ withNul.append("AB", 2); ++ withNul.append(1, '\0'); ++ withNul.append("CD", 2); ++ OFStandard::sanitizeAETitle(withNul); ++ OFCHECK_EQUAL(withNul, OFString("AB_CD")); ++ ++ // High-range bytes (>= 0x7F) are replaced. ++ OFString withHigh; ++ withHigh.append("AE", 2); ++ withHigh.append(1, OFstatic_cast(char, 0xFF)); ++ OFStandard::sanitizeAETitle(withHigh); ++ OFCHECK_EQUAL(withHigh, OFString("AE_")); ++ ++ // A surrounding quotation-mark pair is preserved (storescp behaviour). ++ s = "\"../bad\""; ++ OFStandard::sanitizeAETitle(s); ++ OFCHECK_EQUAL(s, "\".._bad\""); ++ ++ // Empty string and length-1 string are handled without crashing. ++ s = ""; ++ OFStandard::sanitizeAETitle(s); ++ OFCHECK_EQUAL(s, ""); ++ s = "/"; ++ OFStandard::sanitizeAETitle(s); ++ OFCHECK_EQUAL(s, "_"); ++} ++ + OFTEST(ofstd_safeSubtractAddMult) + { + // --------------- Subtraction ---------------- diff --git a/debian/patches/series b/debian/patches/series index 84490a5c..4fc8bccd 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -7,3 +7,9 @@ hurd.patch CVE-2026-5663.patch CVE-2026-10194.patch CVE-2026-12805.patch +CVE-2026-50003.patch +CVE-2026-50254.patch +CVE-2026-35505.patch +CVE-2026-52868.patch +CVE-2026-44628a.patch +CVE-2026-44628b.patch