VULNARCHIVE

[0day-rubbish] PacsOne Server 7.3.9 authenticated DICOMweb STOW-RS SOP Instance UID path traversal to www-data code execution on a PACS host (7.2)

disclosure via Fulldisclosure · · Full Disclosure

Original source

0day Rubbish Research Team is publicly disclosing a vulnerability in PacsOne
Server 7.3.9, Premium Edition, Linux x86-64 distribution, the closed-source
commercial PACS (medical imaging archive and DICOM server) from RainbowFish
Software, California, United States. The product combines a native C++ DICOM
and HL7 service with a PHP web interface of 204 endpoints running on MySQL
and Apache, fronted by a single Apache alias that maps /pacsone to the
product's php/ directory.

Type: path traversal on a file-write sink (CWE-22 primary; CWE-73 External
Control of File Name or Path and CWE-20 Improper Input Validation
contributing). The DICOMweb STOW-RS handler stowrs.php reads the SOP Instance
UID attribute (0008,0018) out of the posted DICOM object and uses it as the
storage filename. In import() in dicomweb.php, approximately lines
1391-1440, the construction is:

    $uid     = $rawTags->getAttr(0x00080018);
    $dir     = dirname($tempfile);
    $newfile = $dir . "/" . $uid;
    rename($tempfile, $newfile);

$dir is the per-day study directory under the configured archive root, from
getStowRsStoreDir() in database.php:1780; $tempfile is staged in that same
directory by DicomWebStow::__construct (dicomweb.php:1165), which reads the raw
body from php://input, splits it on the declared boundary and writes each
application/dicom part through tempnam() and file_put_contents(); and $uid is
the raw attribute value as it appeared on the wire: RawTags::getAttr()
(dicom.php:4516) returns it unfiltered, and the Explicit VR parser
parseDataExplicit() (dicom.php:4378) applies trim() only, which is VR padding
normalisation and not a security control. The presence checks in store()
(dicomweb.php:1592) verify only that
the SOP Class, SOP Instance, Study and Series Instance UIDs are non-empty,
which any non-empty string satisfies. There is no character whitelist, no
canonicalisation, no length bound and no containment check on this path, so a
SOP Instance UID of ../../../php/x.php moves the received object out of the
study directory and into the directory Apache serves through the /pacsone
alias. The 128-byte DICOM Part 10 preamble is never interpreted by the DICOM
parser, so it can carry a PHP marker script, and once the file is in the
served tree mod_php executes it under the web server identity.

The identity reached is uid=33(www-data) gid=33(www-data)
groups=33(www-data) - the Apache worker serving the request, confirmed by id,
whoami, a read of /etc/passwd, a marker write-and-read under /tmp, hostname
and uname. There is no child process and no identity change in this chain.
Root was not reached and is not claimed; no privilege escalation, no
container or host escape, and no contact with any other machine was
attempted.

The ordering inside import() decides both whether the write is usable and
whether a reproduction appears to work. rename() executes BEFORE the database
inserts, so no database-layer control - schema constraint, length limit,
uniqueness check - sits upstream of the damage, and the operation is not
transactional: the file lands even when the corresponding study record never
does. Each of the image, series, study and patient inserts is followed on
failure by unlink($newfile), so a minimal DICOM object writes the payload and
then deletes it, returning HTTP 409 with failure reason 272 (ProcessingFailure).
Making all four inserts succeed requires Patient Name (0010,0010), Patient ID
(0010,0020) and the pixel attributes (0028,xxxx). Anyone attempting a
reproduction without those attributes will watch the file disappear and may
conclude this is a false positive. It is not.

The root cause is an asymmetry rather than a general absence of input
validation. The product contains a UID validator, isUidValid(), and does call
it on the unauthenticated wadoAnonymized.php read path, where its character
whitelist blocks "/" and therefore blocks traversal. The store path that turns
a UID into a filename never calls it. Two details narrow where a fix has to
go: the server uses the dataset value and not the file meta header value, so
a validator bolted onto the meta header parse alone would miss this (the
verified object carried an ordinary dotted UID in (0002,0003) and the
traversal in (0008,0018)); and the parser is structurally lenient - it
accepted a (0028,xxxx) group emitted in non-ascending order - so leniency
cannot be relied on to reject malformed identifiers either.

How confirmation works. The STOW-RS JSON response echoes the identifier back,
so the outcome is machine-readable without any filesystem access: the nested
sequence carries (0008,1155) with the traversal string verbatim plus a
per-instance Retrieve URL in which it appears percent-encoded, a success
arrives as HTTP 200 and a failure as HTTP 409 with failure reason 272. That
makes the one deployment-variable quantity - how many ../ components separate
the study directory from a served directory - cheap to enumerate with clear
feedback and one request per guess, which is why Attack Complexity is rated
Low. Three components on the layout we configured; Setup.exe lets the
operator choose the archive directory, so another installation needs a
different count. The target directory has to satisfy only two conditions: the
web server user can write into it, and something served by the web server
executes files there. Relocating the archive outside the web tree is therefore
hardening rather than a fix. We did not reconcile this build's use of
(0008,1199) for the successful-store sequence against the DICOM PS3.18
STOW-RS response definition, and nothing in the finding depends on that tag
numbering. Execution is then confirmed by an unauthenticated GET for the
written file: the gate protects the store operation, not the file the store
operation created. The response body begins with the command's stdout and is
followed by the binary remainder of the DICOM object rendered as literal
output, which is itself evidence that the whole object, preamble included, was
written intact. The file on disk was 580 bytes - the size of the object sent -
owned by the web server identity at mode 0600. That mode is what tempnam()
creates and rename() preserved; it does not obstruct execution, because the
writer, the owner and the interpreter are the same uid.

What was deliberately not reproduced: the rename()/unlink() pairing on the
failure branch, which is argued from POSIX rename() atomic-replace semantics
and from the shipped code rather than exercised. Because unlink($newfile)
operates on the same attacker-chosen path, a deliberately failing store can
replace and then remove an existing file that the web server user can write.
The primitive is then not only "write an attacker-chosen file" but also
"replace and then delete a file whose name the attacker chooses, inside any
directory the web server user can write". No procedure for it is published and
the PoC refuses to target any path that already resolves. No denial-of-service
test was performed either.

Scoring. The published score is 7.2 High, CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H.
PR:H is load-bearing: only an administrator-role account was demonstrated to
reach the DICOMweb STOW-RS route. hasaccess() in database.php:315 evaluates
isAdministrator() first, which returns true unconditionally for the
administrator group, and for everyone else selects the stowrs column of the
privilege table; the privilege table on the tested installation held exactly
one row, that administrator, with stowrs=1. No lower-privileged role was
demonstrated or documented to hold stowrs. The vector's arithmetic: ISS
0.914816, Impact(S:U) 5.873119, Exploitability 1.234708, unrounded base
7.107826, taken to 7.2 by the CVSS ceiling-to-tenth Roundup - which is why a
calculator reporting 7.1 is disagreeing with the rounding rule, not with the
score. For the record, no numeric CVSS value accompanied the verification
evidence gathered for this finding (that evidence recorded its type,
authentication requirement and achieved identity), so 7.2 is derived here from
the vector rather than copied from a recorded figure, and there is no
recorded-versus-computed discrepancy to correct.

One conditional reading is published alongside it and is never the headline.
In a deployment that grants the stowrs permission to a non-administrator
account - the realistic shapes being an acquisition device such as a modality
or scanner workstation, a partner PACS federated to the archive, or an imaging
application service account, all of which is what a DICOMweb store interface
exists to serve - the least privileged principal reaching the sink is a
low-privileged user and the honest rating for that deployment is 8.8 High,
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H, Exploitability 2.835255 and
unrounded base 8.708373. We did not demonstrate that any non-administrator
account can be granted stowrs through the product and found no code path that
grants it to one, so this reading is conditional on a deployment state we did
not prove. An operator can establish which reading applies to them with one
query against the privilege table.

Two readings were considered and rejected, published with their arithmetic for
transparency. S:C would compute to 9.1 (Impact(S:C) 6.128026, Exploitability
2.286496, unrounded 9.087684) and is rejected: the PHP interpreter that
executes the payload is part of the same product serving the vulnerable
endpoint, on the same host, under the same service identity, and our traversal
target was inside the product's own served directory, so no second component
with an independent authorization boundary was crossed and no cross-account or
cross-machine outcome was demonstrated. A third argument for S:C is tempting
and still wrong in CVSS terms - that the victim of the confidentiality impact
is the patient whose imaging is stored, who is certainly not the principal
that authenticated - because scope concerns which component's authorization
boundary is crossed, not who suffers the consequence. The clinical weight of
that data is carried by the impact metrics, all three High. AC:H would compute
to 6.6 (unrounded 6.578666) and is rejected because the traversal depth is
discoverable with observable feedback at a cost of one request per guess. We
publish no PR:N vector: no unauthenticated path to this sink exists. The
bypass-file branch in the STOW-RS code path, which accepts fixed wado
credentials when a referenced file is present, is dead code on a default
installation because that file does not exist; we did not create it, we attach
no rating to a state the product does not ship, and we recommend the branch be
deleted.

Authentication: post-authentication, and the gate was tested by behaviour
rather than trusted from configuration. The performed negative control is a
persistence differential: two requests to the same endpoint with the same
account and the same object layout, differing only in whether Patient Name and
Patient ID were present. The incomplete object produced HTTP 409 and left
nothing in the served directory, having been removed by the failure branch's
unlink(); the complete object produced HTTP 200 and left a 580-byte file at
exactly the location the UID named. That establishes both that the file's
presence is a consequence of the store path completing and that its location
is the UID, since nothing else in the request specified a directory; it also
excludes mishandling of the temporary file, which tempnam() creates inside the
study directory and which would never appear in the served PHP tree on its
own. A third control comes from a sibling endpoint and was observed: UID-shaped
attacker input on the unauthenticated wadoAnonymized.php path passes through
isUidValid(), whose whitelist contains no "/", so the same input class validated
does not traverse and unvalidated does.

A fourth control is shipped but was not executed, and we say so plainly. The
published PoC carries a conforming-UID control mode that sends a SOP Instance
UID made only of digits and dots with an inert preamble containing no PHP, then
asserts that the endpoint accepts the object and that a GET for the payload
filename under the web prefix returns 404. That mode is implemented and
specified, not observed: it was never run against the product, because the test
instance is no longer available. Any conclusion that depends on it should be
treated accordingly, though the structural argument holds independently - a UID
of digits and dots cannot produce a path separator, so
dirname($tempfile) . "/" . $uid cannot leave the study directory.

Two candidate privilege-downgrade routes were examined and both were closed.
The bypass-file variable named above is absent by default and was not created.
Neither the signup path nor an accession-number login can mint the privilege:
userSignup.php inserts a registration request using a hardcoded database
account whose grants we confirmed are limited to INSERT and UPDATE on the
usersignup table, with no SELECT on privilege or study, and
loginByAccessionNumber.php exchanges request-supplied database credentials for
an application session, which is not a bypass but does mean any
credential-disclosure primitive can be converted into a console session. We
verified that neither the signup account nor the application account passes
hasaccess("stowrs", ...). No authentication bypass, no unauthenticated
reachability of stowrs.php, no cross-account impact and no credential-guessing
result is claimed; the console's lockout accounting was not attacked.

Impact, stated inside what was actually reached. The demonstrated blast radius
is arbitrary file write into the served directory as the web server account,
leading to code execution at that identity, with the consequent read access to
the same tree. On a PACS host, www-data is the identity that owns the DICOM
study repository. Executing code there permits reading stored studies, and a
DICOM object is not merely an image: its header carries patient name, patient
ID, accession number, birth date, sex, referring physician, institution, study
and series descriptions and equipment details alongside the pixels. The same
identity reads the product's PHP configuration, which holds the
installer-configured database credentials, and with them the full relational
schema - patient, study, series and image records, user accounts and the
privilege table itself. We confirmed read access as a capability proof against
/etc/passwd and did not enumerate or copy study data, and we did not use the
database credentials we could have read. No patient data was accessed,
retrieved, modified or enumerated at any point, and the verification did not
touch study contents beyond the single synthetic object our own request
supplied. On the integrity side the same write access permits altering pixel
data in stored instances, substituting or deleting studies and introducing
fabricated ones; the demonstrated chain already performs a small version of the
last of those as a side effect, since a successful store inserts patient,
study, series and image rows, which is why the PoC uses obviously synthetic
identifiers and warns before it runs. On availability, the written script runs
inside the Apache worker context, so worker processes can be consumed and the
files the imaging service depends on can be corrupted or removed, interrupting
the study browser and the DICOMweb services; no denial-of-service test was
performed and no measured availability impact is claimed beyond what arbitrary
write access as the service identity inherently supports. The chain installs
no persistence of its own, but the file it leaves in the served PHP directory
persists until removed and is then executable by any unauthenticated HTTP
client that can reach the alias: rotating the credential does not remove it and
its use does not appear in STOW-RS logs.

On network position, two things are worth separating. The endpoint is an
ordinary .php file behind the same Apache alias as the rest of the application,
so its reachability is exactly the reachability of /pacsone - in the
configuration we tested there was no separate listener, port, firewall profile
or TLS termination for the DICOMweb services, and whoever can load the console
login page can also POST to stowrs.php. The credential, not the network path,
is the control. DICOMweb STOW-RS is in addition an interface designed to be
called by acquisition modalities, worklist managers, other PACS instances and
vendor-neutral archives, so in a deployment that follows that design an
acquisition-side credential becomes a code-execution credential on the imaging
host. We did not survey public exposure and make no claim about how many
installations are internet-facing, and no hospital-wide or network-wide
compromise is claimed: the blast radius demonstrated is the imaging server and
everything stored on it, under one service identity.

Verification boundary, stated plainly. The chain was driven end to end over
HTTP against the vendor-installed instance - authentication, the
multipart/related; type=application/dicom POST, the JSON response, and
execution of the written file - and confirmed by inspecting the written file's
owner, size and mode and the four database rows inside the container. The
product was the vendor's public download for the Linux x86-64 distribution,
installed with the vendor's install.sh followed by the vendor's native Setup.exe
into an Ubuntu 24.04 x86-64 container running Apache 2.4.58 with mod_php, PHP
8.3 and MySQL 8.0 with default_authentication_plugin=mysql_native_password,
which the vendor's bundled MariaDB client library requires. The container
published its Apache port to the host and every request was driven over HTTP
from the host into that published port; the MySQL port was not published, which
is why the application-level credential path is the only one the chain needs.

Three properties of the instance were established by us and are not product
defaults. The database name, account and password were chosen at the product's
own setup prompts, and so was the application administrator account and
password, because the product ships no default web or database credentials. We
state that explicitly: nothing in this finding depends on a shipped default
credential. Archive and php directory ownership also had to be restored with
chown -R www-data:www-data to match the installer's own permission model.
Traversal depth of three ../ components is specific to that archive layout.

Also outside what was executed: the exact construction of import(), getAttr(),
parseDataExplicit(), hasaccess(), getStowRsStoreDir(), the bypass-file branch
and the other filesystem sinks in the product were verified statically from the
shipped source and not run; the destructive rename()/unlink() variant was
argued and not exercised; no race between rename() and unlink() was tested; and
no version other than 7.3.9, not the Windows distribution (obtained but never
deployed), no archive layout other than the one we configured, and no traversal
destination other than the served PHP directory is claimed. The defect is in
shipped PHP source, so other builds sharing that store path are expected to
behave identically, but we did not test them and do not assert it. The PoC's
own safety rails - a preflight requirement that the target path be absent,
payload-name restrictions, a marker path restriction, a destructive-command
blocklist, self-deleting cleanup and the control mode - were exercised two
ways: offline, by constructing objects and inspecting their byte structure and
by running the argument validation; and against a local stub server that
returns the same STOW-RS response shape and serves the payload path only once a
store has happened, including a repeat run confirming the cleanup leaves
nothing behind. That stub validates the script's request framing, response
parsing, marker round trip and cleanup logic. It is a test of the script and is
not evidence about the product.

Detection notes. A successful traversal leaves an image.path row containing
../, so a query for image.path LIKE '%../%' or image.uuid LIKE '%/%' is a cheap
integrity check - the product stores the uncanonicalised path, which is what
makes that query possible. It also leaves a mode 0600 file owned by the web
server user in the served PHP tree, which a file-integrity monitor over that
tree will flag, and whose mode will not make it unreadable to the interpreter.
In the access log the signature is a POST to stowrs.php followed within seconds
by GETs to a .php path that did not previously exist, especially with a
repeated single query parameter and a response body containing binary DICOM
bytes after the command output; that binary tail is a reliable tell that the
response was generated by an executed DICOM object rather than by application
code. The synthetic patient, study and series rows the store inserts are
identifiable by their accession numbers, identifiers and descriptions. The
identifier travels in the request body and not in the URL, so request-line
inspection will not show it. Signals that will NOT appear: nothing in the DICOM
upper-layer logs, because the chain never touches the native DICOM service;
nothing anomalous in the STOW-RS response status on a successful run, because
it is a normal HTTP 200 with a success sequence; and no privilege-change or
sudo audit event, because execution happens inside the existing web server
identity. An attacker who replaces the single-command marker with a payload
that talks only over the same HTTP listener generates no outbound connection at
all.

Mitigation summary, ordered by root cause. Validate the SOP Instance UID
against the DICOM UID grammar before it is used for anything: reject the store
in import() unless the value matches ^(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*$
with total length at most 64 over the alphabet [0-9.] only. The product already
contains isUidValid() and already calls it on the wadoAnonymized.php read path,
so calling it here closes the traversal at no interoperability cost, since every
conforming peer sends UIDs made only of digits and dots. Better still, stop
deriving filesystem paths from protocol identifiers: generate the storage name
server-side from a random or content-derived token, keep the SOP Instance UID
as a database column and resolve objects through the database, applying the
same treatment to Study and Series Instance UIDs wherever either builds a
directory name. Enforce containment at the write independently of validation -
canonicalise the intended destination's parent with realpath(), compare against
the canonical store root including a trailing separator, reject symlinks,
basename() any identifier used in a path, and only then call rename(). Move the
write after the decision to keep the object and never unlink() a path you did
not validate: stage in a non-served directory, insert the rows in a
transaction, and move to the final location only on commit, so that a failure
branch deletes the server-generated staged path. In the deployment, separate
writable data from executable code - keep the archive out of the served tree,
disable script execution in every writable data directory (php_admin_flag
engine off or an equivalent handler restriction, plus open_basedir where
practical) and make the served PHP tree non-writable by the web server identity
after verifying that no endpoint the installation uses writes there. Validate
on the parse path as well as at the sink, since applying the UID grammar inside
parseDataExplicit() for the UI VR protects every consumer of getAttr(),
including future code that uses a UID in a path, a filename, a shell argument
or a log format string. Remove the latent authentication fallback and the
unauthenticated adjunct endpoints: delete the bypass-file branch and its fixed
wado credentials; retire setup.php after installation and quote its database
name as an identifier rather than concatenating it into database-creation and
grant statements; remove phpinfo() from Hello.php; restore a real authorization
check on risquery.php, whose referer check is commented out in the shipped
source; and have the installer generate a per-installation signup database
credential instead of the hardcoded pair at constants.php:580-581. Ship a
regression test that sends a STOW-RS object whose SOP Instance UID is
../../../<served-dir>/probe.php and asserts a rejection, that no file exists
outside the study directory, and that no image row contains a path with ../ -
the second assertion matters precisely because the write precedes the record,
so a fix that only changes the response still moves bytes.

Until a patch exists, operators should restrict stowrs.php, wadors.php and
qidors.php at the proxy or firewall to the specific modality, worklist and
partner addresses that legitimately store studies; audit privilege rows with
stowrs=1 and treat each such credential as equivalent to a shell on the imaging
host - long, unique, secret-managed, rotated, per-device rather than shared, and
never the administrator account; segment the PACS from general clinical and
corporate traffic and restrict its egress; monitor for new executable-suffix
files in the served PHP tree and query the database periodically for
image.path LIKE '%../%'; and if there is evidence the traversal was used,
treat the stored study repository on that host as having been readable by the
attacker and handle it as an exposure of the imaging archive.

Full technical analysis and a reproducible proof-of-concept:
  https://0day-rubbish.com/blog/rainbowfish-pacsone-stowrs-sop-instance-uid-traversal-rce

Project archive (ongoing disclosure series):
  https://github.com/Exploit-Garbage/0day-Rubbish

RainbowFish Software was notified through the bug-report mailbox
it publishes as a mailto link on the product's contact page, alongside
documented addresses for comments and suggestions, feature requests and
licensing. We looked for an RFC 9116 security.txt first: the well-known path
returned a generic 404 on one host and a web-application-firewall 406 on
another. We found no security page, no vulnerability-disclosure policy and no
compliance page on the product site - those paths return 404 - so there was no
dedicated security intake to use and we wrote to the documented bug-report
address on the basis that a security report is a bug report. We submitted
nothing to any pre-publication form, because none exists, and we addressed no
individual by name. The vendor was asked to route the report to whoever
maintains the product, to tell us the correct intake if one exists, and, given
the clinical role of the product, whether it would prefer a different channel
for future security reports. This disclosure is unconditional: the research is
already public, no permission was asked for, no embargo was offered and nothing
is withheld pending a reply. No vulnerability identifier has been assigned to
this finding yet.

--
0day Rubbish Research Team
disclosure () 0day-rubbish com
https://0day-rubbish.com
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/