[med-svn] [Git][med-team/dcmtk][debian/trixie] 2 commits: d/patches/*-CVE-2026-*.patch: new security patches.
Étienne Mollier (@emollier)
gitlab at salsa.debian.org
Tue Jul 7 21:41:56 BST 2026
Étienne Mollier pushed to branch debian/trixie at Debian Med / dcmtk
Commits:
3e1f2608 by Étienne Mollier at 2026-07-07T22:37:27+02:00
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
- - - - -
988ba8fa by Étienne Mollier at 2026-07-07T22:40:04+02:00
d/changelog: ready for upload to trixie-pu.
- - - - -
8 changed files:
- debian/changelog
- + debian/patches/0020-CVE-2026-50003.patch
- + debian/patches/0021-CVE-2026-50254.patch
- + debian/patches/0022-CVE-2026-35505.patch
- + debian/patches/0023-CVE-2026-52868.patch
- + debian/patches/0024-CVE-2026-44628a.patch
- + debian/patches/0025-CVE-2026-44628b.patch
- debian/patches/series
Changes:
=====================================
debian/changelog
=====================================
@@ -1,3 +1,15 @@
+dcmtk (3.6.9-5+deb13u3) trixie; urgency=medium
+
+ * 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)
+
+ -- Étienne Mollier <emollier at debian.org> Tue, 07 Jul 2026 22:38:06 +0200
+
dcmtk (3.6.9-5+deb13u2) trixie; urgency=medium
* Team upload.
=====================================
debian/patches/0020-CVE-2026-50003.patch
=====================================
@@ -0,0 +1,42 @@
+Applied-Upstream: eca9a03dda7d4fc1faa7e5a6dac9617938cf5803
+Author: Michael Onken <onken at open-connections.de>
+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 <emollier at debian.org>
+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())
+ {
=====================================
debian/patches/0021-CVE-2026-50254.patch
=====================================
@@ -0,0 +1,164 @@
+Applied-Upstream: 23f181f7a3cb8334056f751a3a0c2ddf01046752
+Author: Michael Onken <onken at open-connections.de>
+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 <emollier at debian.org>
+Bug-Debian: https://bugs.debian.org/1141411
+
+--- dcmtk.orig/dcmnet/libsrc/dulconst.cc
++++ dcmtk/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 @@
+ 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;
+--- dcmtk.orig/dcmnet/libsrc/dulfsm.cc
++++ dcmtk/dcmnet/libsrc/dulfsm.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
+@@ -1031,7 +1031,14 @@
+
+ }
+
+- /* 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;
+@@ -1235,7 +1242,14 @@
+ 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;
+--- dcmtk.orig/dcmnet/libsrc/dulparse.cc
++++ dcmtk/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 @@
+ 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 @@
+ 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;
+--- dcmtk.orig/dcmnet/libsrc/extneg.cc
++++ dcmtk/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();
+--- dcmtk.orig/dcmnet/libsrc/helpers.cc
++++ dcmtk/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 @@
+ }
+ 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;
+
=====================================
debian/patches/0022-CVE-2026-35505.patch
=====================================
@@ -0,0 +1,509 @@
+Applied-Upstream: 2312891a8d058c862e00bcbd636e5da26308658a
+Author: Michael Onken <onken at open-connections.de>
+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 <emollier at debian.org>
+Bug-Debian: https://bugs.debian.org/1141411
+
+--- dcmtk.orig/dcmnet/libsrc/dulfsm.cc
++++ dcmtk/dcmnet/libsrc/dulfsm.cc
+@@ -971,14 +971,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;
+@@ -1011,6 +1023,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);
+ }
+
+@@ -1019,6 +1034,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);
+@@ -1041,7 +1059,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);
+ }
+
+@@ -1049,7 +1073,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;
++ }
+
+ }
+
+@@ -1234,11 +1264,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;
+ }
+
+@@ -1252,14 +1291,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));
+ }
+
+@@ -3947,6 +3998,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<OFString> 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")
=====================================
debian/patches/0023-CVE-2026-52868.patch
=====================================
@@ -0,0 +1,746 @@
+commit e3878daf870cd2db50eadfde38615f0afae8a584
+Author: Michael Onken <onken at open-connections.de>
+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
+@@ -98,7 +98,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[],
+@@ -2230,34 +2229,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
+@@ -2293,7 +2264,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 << ".");
+@@ -2303,7 +2274,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 << ".");
+@@ -2313,7 +2284,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 << ".");
+@@ -2429,7 +2400,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 << ".");
+@@ -2439,7 +2410,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 << ".");
+@@ -2449,7 +2420,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
+@@ -231,6 +231,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
+@@ -289,6 +306,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 dcmnet dcmtls dcmdata oflog ofstd)
++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 at 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
+@@ -3500,6 +3500,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-2024, 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 at host-1";
++ OFStandard::sanitizeAETitle(s);
++ OFCHECK_EQUAL(s, "AE.TITLE:1 at 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 ----------------
=====================================
debian/patches/0024-CVE-2026-44628a.patch
=====================================
@@ -0,0 +1,114 @@
+Applied-Upstream: f4e0074682645b1a4289d62581926c4394d5c6d5
+Author: Michael Onken <onken at open-connections.de>
+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 <emollier at debian.org>
+Bug-Debian: https://bugs.debian.org/1141411
+
+--- dcmtk.orig/dcmwlm/libsrc/wldsfs.cc
++++ dcmtk/dcmwlm/libsrc/wldsfs.cc
+@@ -1,6 +1,6 @@
+ /*
+ *
+- * Copyright (C) 1996-2023, 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
+@@ -173,20 +173,23 @@
+ // 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 );
+@@ -223,18 +226,21 @@
+ // 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;
+ }
+ }
+@@ -259,7 +265,7 @@
+ // 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;
+@@ -354,9 +360,13 @@
+ }
+
+ // 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 );
=====================================
debian/patches/0025-CVE-2026-44628b.patch
=====================================
@@ -0,0 +1,80 @@
+Applied-Upstream: 694a0a06a38015ce768fa161a62b148189f84959
+Author: Michael Onken <onken at open-connections.de>
+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 <emollier at debian.org>
+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 ; i<sequence->card() && !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 )
=====================================
debian/patches/series
=====================================
@@ -15,3 +15,9 @@ remove_version.patch
0017-CVE-2025-14841.patch
0018-CVE-2026-10194.patch
0019-CVE-2026-12805.patch
+0020-CVE-2026-50003.patch
+0021-CVE-2026-50254.patch
+0022-CVE-2026-35505.patch
+0023-CVE-2026-52868.patch
+0024-CVE-2026-44628a.patch
+0025-CVE-2026-44628b.patch
View it on GitLab: https://salsa.debian.org/med-team/dcmtk/-/compare/a83229fb604786c30145c04a02ad1c2e19952d0e...988ba8fa609150214f73c69cf9beaef6e9662c1e
--
View it on GitLab: https://salsa.debian.org/med-team/dcmtk/-/compare/a83229fb604786c30145c04a02ad1c2e19952d0e...988ba8fa609150214f73c69cf9beaef6e9662c1e
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20260707/94b13c12/attachment-0001.htm>
More information about the debian-med-commit
mailing list