[0day-rubbish] SmartBear Collaborator Server 15.1.15101 authenticated DOORS/Simulink review-file upload zip-slip to anonymous webroot Groovy Server Page execution as uid 1001 (7.5)
disclosure via Fulldisclosure · · Full Disclosure
0day Rubbish Research Team is publicly disclosing a vulnerability in SmartBear
Collaborator Server 15.1.15101, the closed-source peer and technical code-review
platform from SmartBear that runs as a Java web application on Apache Tomcat. One
low-privilege authenticated account plants a Groovy Server Page in the Tomcat
webroot through an archive-extraction path traversal, and any anonymous client -
a crawler, a scanner, anyone - then executes it with an ordinary GET, yielding
operating-system command execution as uid=1001(collab) inside the product's own
JVM. Root was not reached, is not claimed, and no privilege escalation was
attempted.
Read the deployment-shape caveat first, because it decides whether this chain
works at all and it is the reason the published headline carries Attack
Complexity High. The only shipped artifact this research exercised was the
vendor's official container image, and in that image used exactly as shipped THE
CHAIN DOES NOT WORK. The image declares the webserver content directory as a
VOLUME and the installer never creates that path, so the container runtime
creates the mount point itself as drwxr-xr-x owned by root while the service runs
as uid 1001; the first filesystem call of the whole feature,
Files.createDirectories(destVersionDir.toPath()) at
HtmlBaseFilesUploadService.java:77, fails with EACCES before any archive entry is
read and no byte is ever written. That block cannot be worked around from inside
the product, because setWebserver has exactly one call site, at startup, from the
content-webserver context parameter declared at ROOT.xml:92 with
override="false". The shape in which the chain does succeed is the native,
non-container install, and that shape was never run on a real host: the claim
that a native install leaves that directory owned by the service account is a
labelled structural inference from the shipped archive, not a measurement. This
advisory therefore does not claim that every deployment of this product is
exploitable.
Type and root cause. CWE-22, path traversal through archive entry names, with
CWE-94 (code generation via the *.gsp template mapping), CWE-732, CWE-693,
CWE-668 and CWE-916 contributing. The JSON-API command
ReviewService.addDoorsFiles accepts a zip archive from any authenticated user
and dispatches through ReviewServiceImpl.addZipFiles:969-1001 to the first-party
extractor com.smartbear.ccollab.doors.DoorsFilesUploadService.upload. Across that
extraction loop the only validation of an entry is that the entry object is not
null (:73-75) and the only filter is the keyword list
IGNORE_FILES_KEYWORDS = {"__MACOSX"} declared at :63 and applied at :79. The
entry name is passed through a cosmetic square-bracket substitution at :77 -
Strings.replaceSquareBracketsToRound, which exists to satisfy the downstream
DOORS document parser and has nothing to do with paths - and is then concatenated
into a destination at :78 with
File entryDestination = new File(uploadContext.getDestVersionDir(), fileName);
The two-argument File(File, String) constructor concatenates lexically: it does
not canonicalise, does not resolve against the base and does not reject
parent-directory segments. There is no normalize() call, no startsWith(destDir)
containment test, no canonical-path comparison, no rejection of an absolute name,
no rejection of a backslash-separated name, no extension allowlist or blocklist
on this route, no cap on entry count or cumulative uncompressed size, and no
per-object authorisation on the caller-supplied reviewId. Line :83 then creates
whatever parent directories the attacker's name implies and line :84 opens the
output stream, new FileOutputStream(entryDestination), which is the sink. An
entry named ../../../../../webapps/ROOT/<name>.gsp escapes the per-review version
directory and lands in the Tomcat webroot.
A sibling implementation shares the identical defect and the identical parent
class: com.smartbear.ccollab.matlab.SimulinkFilesUploadService.copyFilesFromZip
at :186-203 resolves destDir.resolve(zipEntry.getName()).toFile() at :188 with
no normalize(), no startsWith(destDir) and no .. rejection, and is reachable from
ReviewService.addSimulinkFiles. Its checkArchive at :157-161 constrains only the
first entry, which must be named exactly webview.html, and then ignores the rest
- a format assertion, not a containment control. One root cause with two code
instances is reported here as a single finding; the Simulink instance was never
exercised end to end and carries two further gates, so its practical
exploitability is strictly weaker and is claimed on source reading only. A
restricted-file-pattern check that genuinely exists lives only in the sibling
servlet VersionContentUploadServlet:90-93, on the JSON-API-free route, and it
validates the version's already-existing paths rather than names inside the
archive.
The destination geometry is what makes a relative traversal practical instead of
theoretical. The shared parent fixes the base: HtmlBaseFilesUploadService builds
webServerFolder as <mode>/<reviewId>/<zip name without extension> at :70-71,
joins it to SystemGlobals.getWebserver() at :74, and
SystemGlobals.java:130 declares that value as the relative string
"collaborator-webserver", so it resolves against the JVM working directory,
measured as /usr/local/ccollab_server/tomcat. The complete destination is
therefore
/usr/local/ccollab_server/tomcat/collaborator-webserver/<mode>/<reviewId>/
<zipbase>/ver<N>
Every segment is fixed by product logic or returned to the attacker, so the
required climb is a deterministic function the attacker can compute: depth = 4 +
n, where n is the number of path segments in the declared zip name after
stripping .zip, and the constant 4 counts ver<N>, the mode segment, the review
identifier and the webserver directory-name segment. Two independent
measurements agree with it at two different values of n - five segments for a
flat zip name, six for a zip name of the form <dir>/<name>.zip, both markers
executing anonymously. Depth must be exact in both directions, and overshooting
is not a safety margin: POSIX clamps ".." at the filesystem root, so too many
segments turn the target into the absolute path /webapps/ROOT/<name>.gsp and the
mandatory pre-write Files.createDirectories at :83 then fails with EACCES with
nothing written, while undershooting lands silently inside the content tree, is
served behind the authenticated /webserver/* prefix where an anonymous client
receives 401, and leaves an attacker file on disk.
The execution half is first-party configuration. The ROOT webapp's own
web.xml:271-278 maps *.gsp to the upstream groovy.servlet.TemplateServlet on the
anonymous surface, so a plain unauthenticated GET of the planted path compiles
the page in memory and evaluates it. That is what converts a
post-authentication arbitrary file write into code execution triggered by
anyone, and it is why the write side and the trigger side sit on opposite sides
of the authentication boundary: the party who plants the file and the party who
runs it need not be the same.
Confirmation, and what was deliberately not reproduced. The chain is
self-verifying rather than interpretive: the planted template runs a single
read-only identity command and echoes its output into the anonymous response
body, together with a Generated by Groovy TemplateServlet timing comment that
proves evaluation rather than static serving. The observed identity string was
verbatim uid=1001(collab) gid=1001(collab) groups=1001(collab), with an empty
effective capability set. The strongest result came from an independently
rebuilt container with a different random marker tag, a different
non-administrator account and a freshly created review; the reference
implementation was then run end to end twice more against a writable-mount
instance, so the finding rests on four mutually independent execution records,
three of which used values that could not have been carried over from an earlier
run. What was deliberately not reproduced: no reverse shell, no persistence, no
scanning, no brute force, no mass traversal, no deletion of anything on the
target and no write outside the single marker file. The marker template is left
in place rather than removed by the tool, because removing it requires container
or host access the tool has no business holding, and an explicit manual cleanup
instruction is printed instead. Per-run marker tokens, session ticket values and
container identities are withheld from the published advisory as artefacts of one
execution on one host: tickets are 30-day credentials and were logged out at
teardown.
Scoring. The published headline is 7.5 High,
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H, recomputed from first principles
with the CVSS 3.1 round-up-to-tenth rule: ISS = 1 - (0.44 * 0.44 * 0.44) =
0.914816; Impact at Scope Unchanged = 6.42 * 0.914816 = 5.87311872;
Exploitability = 8.22 * 0.85 * 0.44 * 0.62 * 0.85 = 1.62014556; base =
min(7.49326428, 10) = 7.49326428, rounded up to 7.5. Attack Complexity is High
on the headline not because the traversal is subtle - it is not, the depth is
deterministic, there is no race and there is no state to gather - but because
success depends on a condition the attacker neither controls nor can verify from
the product: that the upload tree is writable by the service uid. On the one
shipped artifact actually exercised, the official container image used exactly as
shipped, that condition is false, and on the shape where it is believed to hold
it was never observed. Rating exploitability Low on the strength of an installer
that was never run would let the headline rest on the one deployment shape that
was never tested while the shape that was tested forbids the chain. This is the
honest rating, not a pessimistic one: the chain is complete and deterministic
wherever the precondition holds and the severity band does not move.
One conditional reading is published beside it, labelled as conditional: 8.8
High, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H - Exploitability = 8.22 *
0.85 * 0.77 * 0.62 * 0.85 = 2.83525473, Impact 5.87311872, base =
min(8.70837345, 10) = 8.70837345, rounded up to 8.8. It applies to
installations in which the upload tree is writable by the service uid: the
native install shape, and any container deployment where that volume was created
with service-account ownership or chowned during deployment automation. The
argument for Low there is real and runs like this: :77 creates the destination
tree itself, on the line before the first archive entry is read, so the
precondition is implied by the usability of the attacked feature - any
installation in which DOORS review-file upload can return success is an
installation in which that tree is already writable by uid 1001, and an attacker
who can call addDoorsFiles and get anything other than a permission-derived
InternalError is already in the exploitable shape. It is not the headline
because those shapes were argued from the archive rather than run. Both readings
are High, and neither says this is remotely exploitable without an account. The
blocked shape is not handled as an Environmental metric adjustment, because
Environmental describes the scorer's own deployment and would leave the base
score looking unconditional.
A rejected reading is recorded here with a correction to our own arithmetic, so
that nobody inherits it. A Scope-Changed candidate - PR:L, S:C and all three
impact metrics High, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H - was carried
during analysis and is rejected on the merits: the write and the evaluation
happen in the same JVM, the same ROOT web application and the same uid 1001, over
directories that account already owns, so no resource under a different security
authority is affected; that an anonymous client can pull the trigger is a fact
about who pulls it, which Attack Vector and User Interaction already price. The
strongest case for Changed is acknowledged - the chain does cross from a
review-material storage authority into a server-side code execution authority and
hands that capability to a party that was never granted anything, which is the
same reasoning that earns Scope Changed on some stored-cross-site-scripting
findings - and it was rejected partly for the merits above and partly because
publishing it would put a score in the maximum band on a chain whose own evidence
says the ceiling is one unprivileged uid, inviting exactly the challenge the
evidence would lose. The correction: that rejected reading was recorded during
analysis as 9.9, and 9.9 is not what those metrics give under CVSS 3.1. It is
what they give under the CVSS 3.0 Scope-Changed impact formula, whose polynomial
term is (ISS - 0.02) raised to the 15th power; the 3.1 formula uses
(ISS * 0.9731 - 0.02) raised to the 13th. Under 3.1 the same metrics give
Impact 6.12766, Exploitability 3.10963 and base min(1.08 * 9.23729, 10) =
9.97627, which rounds up to 10.0. The two formulas agree on every well-known
vector in this band and diverge only on PR:L with S:C and all-High impact, so
anyone re-deriving a Scope-Changed score for this class of finding should check
which formula their tool implements. Since Scope-Changed is rejected on the
merits, neither 9.9 nor 10.0 is published as a score. Scope is Unchanged.
Authentication: post-authentication, and any authenticated principal is enough.
An anonymous ReviewService.addDoorsFiles call returns HTTP 200 with a 127-byte
AuthenticationError, and that negative control is what fixes the finding as
post-authentication; a non-administrator account whose own
UserService.findUserByLogin response carried "admin":false completed the whole
chain. The privilege requirement is PR:L and not PR:H, and administrator status
is neither needed nor helpful - an administrator calling addDoorsFiles with
reviewId omitted falls into the auto-creation branch at ReviewServiceImpl:982 and
is refused with a 179-byte ServiceException reading "The system administrator is
not allowed to perform Reviews", a restriction that is conditional on
!getAllowSysadminReviews() and must not be described as an unconditional ban.
The gate structure was confirmed with a three-state discriminator rather than a
single positive test, because one "it worked" result cannot distinguish a role
gate from a global outage: anonymous callers get AuthenticationError, a
non-administrator calling an administrator-only method gets a 114-byte
AuthorizationError, and the same method with an administrator ticket gets 29
bytes of success. Three separable states rule out both "everything is refused"
and "everything is permitted". On the JSON-API the only enforcement point is
CommandHandler.java:57-61: a command runs unauthenticated exactly when its
handler carries @NoAuthRequired. An exhaustive pass over
collaborator-json-api-15.1.15101.jar found seventeen such annotations, twelve of
which were executed anonymously as confirmation, and ReviewService.addDoorsFiles
does not carry one. The annotation is not a reliable indicator in the other
direction either - getRemoteSystemIntegrations carries it and then applies a
second check internally.
Two further gates are named because they are the kind of detail that could
otherwise be mistaken for a higher privilege requirement, and both push the
requirement down rather than up. The sibling HTTP route /versionupload requires
an HTTP session key of the form save-request-<changelistId>, written by the
product's ordinary change-list save workflow; addZipFiles at :969-1001 never
consults it, and even on the servlet route the key encodes no role. The edition
gate at ReviewServiceImpl.addZipFiles:977 evaluates isDoorsEnabled(), which
resolves through SystemGlobals:1251-1256 to
Product.getFeatures().supportsDoors() - derived from the licence edition, with no
globals key, no setter and no doors-enabled or simulink-enabled key anywhere in
the product, so an operator who wants to close it without a vendor patch has no
supported way to do it. Default resolution is FeatureSetEnterprise, where the
gate is open, and it was observed open on two separate instances. Free and
Professional (Reviewer) inherit supportsDoors() = false and would be refused with
RequestError "Can't upload Doors archive as feature is not enabled." - a static
derivation that was never exercised against a licensed instance of those
editions.
An unauthenticated reading was considered and is withdrawn. There is a real
anonymous route into an administrator session on this product:
InitializationService.initDatabase carries @NoAuthRequired, an anonymous POST
with an empty argument object returns HTTP 200 and a 15-byte success body,
creates the schema and seeds an administrator account with a fixed factory
password - the product's own log line names the HTTP worker thread, proving the
call arrived over the network rather than during startup. An attacker holding
that account could then reach the sink above. That route holds only on an
instance that has never been initialised, and it was tested rather than assumed:
the call was replayed twice against an instance whose state had already been
enriched with a second user, a project, and a changed administrator password,
and both replays were idempotent and harmless - the second user and its
identifier unchanged, the administrator account not recreated, the changed
administrator password still valid and still issuing a ticket in a 63-byte
response, the factory password refused, the project still present, the database
file unchanged in size, no new missing-table errors, and the web UI unaffected.
So replaying initialisation neither wipes the database nor resets an
administrator password an operator has changed, which means it cannot be used to
take over a configured production instance. No unauthenticated claim is made and
no PR:N vector is printed anywhere in this advisory, because printing one beside
a chain whose published headline scores 7.5 would be an invitation to misread
it. One direction stays untested in both senses: full product initialisation
requires licence activation over the network, which was never invoked, so the
behaviour of that command against a completely initialised and licensed instance
is unknown and neither an escalation nor a further restriction may be inferred.
Relatedly, no default vendor credential is claimed - the seeded pair in this
testbed was created by our own anonymous initialisation call on an
uninitialised instance and is an artefact of the research procedure.
Impact. A post-authentication write becomes unauthenticated execution: one
low-privilege account plants something that any anonymous client will
subsequently run, and the request that triggers it is indistinguishable in shape
from any other anonymous page fetch. From uid=1001(collab), which owns the whole
installation tree and every file in it:
- Every credential in the product. The HSQLDB file hsqldb/database.data holds
password digests computed as unsalted ASCII MD5, confirmed by reading the
file, which is a disclosure of all account passwords rather than of opaque
hashes. The JNDI datasource declared at conf/Catalina/localhost/ROOT.xml:33
uses username="sa" with an empty password, and before any initialisation the
catalog carries CREATE USER SA PASSWORD DIGEST
'd41d8cd98f00b204e9800998ecf8427e' with GRANT DBA TO SA - that digest being
MD5 of the empty string. Source-control passwords, OAuth client secrets and
licence user lists are stored encrypted under a constant key embedded in the
application at EncryptUtils.java:31-32, so an identity that can read the
classpath can decrypt them. Every configured SCM and identity-provider
integration is compromised along with the product.
- All review content, readable and writable. Because addZipFiles:982 performs
no per-object authorisation on a caller-supplied reviewId, the write
primitive is not confined to reviews the attacker created, so other teams'
review material can be written into independently of the webroot question.
- Application integrity and availability. The service account owns the Tomcat
tree, so application classes, deployment descriptors and the database file
itself can be overwritten or deleted to terminate service.
- Persistence for anyone who already has execution, using the same primitive:
an account that can be revoked still leaves its marker behind. Whether a
planted template survives a restart is NOT established - the webroot is
unpacked from the war at runtime and no restart was performed - so no claim
is made in either direction.
- A second, independent read primitive observed during the survey and kept
out of this vector: an unauthenticated double-decoded traversal in the
static content servlet at WebserverServlet.java:208, which builds
new File(root, URLDecoder.decode(pathInfo)) with no canonicalisation and
reads the same tree as the service account. It is a distinct root cause
with a distinct fix, it is not folded into this vector for scoring
purposes, and it is mentioned only because an attacker combining the two
gets read access without needing an account at all.
The blast radius is bounded by the host. The achieved identity is unprivileged
inside the container with an empty capability set; the image contains no sudo
binary and neither /etc/sudoers nor /etc/sudoers.d, ships no first-party setuid
executable (the only setuid binaries are stock system utilities), and provides no
designed path from an application administrator to the root account. Container
escape was not attempted. What falls is the product and everything it holds, not
the underlying host. Every request in the chain - session ticket, upload, and
anonymous trigger - travels over ordinary HTTP to the product's single listener
on port 8080: no local access, no privileged network position, no separate
management port, and no database reachability, since HSQLDB runs in-process from
a jdbc:hsqldb:file: URL with no listening socket.
Verification boundary, stated plainly. Static reconstruction came from a
4457-file decompiled corpus of the shipped archives. Dynamic work ran against
four container instances derived from the vendor's published image, each
published on a loopback address only, and every request originated from
loopback; no re-test from a non-loopback source address was performed, so if the
product exempts private or loopback peers anywhere in this path that was not
tested and cannot be excluded. The instances divide by the ownership of the
webserver directory, which turned out to be the single discriminating variable,
and what our own harness supplied versus what the product supplied is the
central question:
- Pristine shape, two separate instances from the same recipe, with no
researcher intervention of any kind inside the container. Webserver
directory drwxr-xr-x owned by root and empty. This is the shape that
establishes the shipped artifact blocks the chain, and it did so from
observation rather than inference: the service layer returned HTTP 200 with
a 127-byte InternalError whose message was only the bare destination path,
a directory-creation attempt inside that path as uid 1001 failed with
Permission denied while the parent tomcat/ directory is service-owned and
accepts a mkdir from uid 1001, a filesystem-wide search for the marker
returned zero hits, an anonymous GET of the marker path returned 404 with
653 bytes, and on the second pristine instance a case-insensitive search of
the webroot for that round's tag returned zero. No traversal logic, no
authentication gate and no feature gate was involved in that block.
- Writable-mount shape, two separate instances, which carries the deployment
claim. A host-side directory holding zero entries was created and owned by
uid 1001 and bind-mounted at the webserver path; no chown, mkdir or chmod
was executed inside the container at any point, and the product itself
created four nested directories plus one file, all owned by uid 1001,
under a path that had been empty. That listing is the single most
important positive observation for the deployment question, and one of the
objections pursued against it was sustained at the level of wording: the
mount point's ownership was pre-set on the host side by us, in order to
model a deployment where the service account owns its own installation
directory. The vendor's native installer was never run on a real host. A
relocated mount point was not an option - an attempted move inside a
running container failed with Device or resource busy - which is why this
shape was produced by bind-mounting.
- Repaired-ownership shape, where a root-owned directory was recursively
chowned to uid 1001 by the researcher. Reportable because it produced the
first positive result and because it demonstrated the error-response-with-
successful-write behaviour described below, but explicitly NOT evidence
about a default deployment, because the ownership change was ours.
The image-build attribution for the pristine block was established in execution
order and is corroborated three ways: the build creates the service account and
recursively chowns the installation home to it, then switches to the service user
so the vendor's installer runs as uid 1001, then declares a VOLUME list covering
the configuration directory, the JDBC directory, the database directory,
tomcat/collaborator-webserver and tomcat/collaborator-content-cache - and at the
moment that instruction executes the webserver path does not exist, because the
installer never creates it, so the runtime creates the mount point as root. Of the
twenty entries under tomcat/, only two are root-owned and both are VOLUME-declared
paths; the database directory is also VOLUME-declared but was explicitly created
and chowned earlier in the build and is consequently service-owned, so the
ownership difference tracks build-time creation rather than anything about the
product; and an independent check confirmed that both root-owned entries appear in
the image's declared volume configuration. One piece of installer evidence had to
be corrected during this work: an initial statement that the installer never
mentions the webserver directory name was a false zero, produced by searching an
extracted tree rather than the installer archive itself. Reading the installer jar
directly shows the string exactly once, in the constant pool of a class whose role
is writing configuration elements, with neither a mkdirs nor a chown. The
corrected basis is that chown returns zero hits across the installer archive tree
and across all five installer definition files, that neither of the two
directory-creating classes changes ownership, and that a control search over a
sibling directory name returned one hit in an installer-only configuration file,
so the search channel was live. The conclusion is unchanged and better supported:
the root ownership is a packaging artefact, not a product defence.
The claim that a native install is therefore writable rests on three structural
observations - the installer archive contains no chown at all and its two
directory-creating classes change no ownership; the product necessarily creates
that tree itself at HtmlBaseFilesUploadService:77; and a directly comparable
runtime directory in the same image, collaborator-content-cache, is likewise
absent before startup and was observed to be created by the product as uid 1001
after the service ran on an unmodified artifact. That is a strong argument and it
is not a measurement.
Further boundaries, each a place where a reader could otherwise over-read the
result. Only build 15.1.15101 was exercised, and no cross-version or
cross-edition claim of any kind is made. A fully initialised and licensed
instance was never used; the partial-schema state did not block user creation,
review creation or the upload, but the licensed state was not compared.
Alternative executable extensions were not tested - .jsp in particular was never
exercised, so nothing here says whether a .jsp landing would also execute, and
the mapping that was proven is *.gsp. Depth behaviour with multi-digit review
identifiers, backslash-separated entry names and absolute entry names was not
exercised. The write-sink survey's negative result is incomplete by construction:
it was scoped to a single addressing base, and an earlier survey that concluded
no webroot-feeding write primitive existed was wrong precisely because it only
considered writes based at the webroot and never writes based elsewhere that
traverse relatively into it. The honest statement is therefore that no second
webroot-feeding write primitive was found, not that none exists, and the set of
entry points reaching this extractor family beyond /versionupload and the two
JSON-API commands was not enumerated. Two source-versus-runtime divergences are
recorded as reminders that corpus constants must be paired with dynamic
observation: AuthTicketFilter.java:126 sets the framing header to SAMEORIGIN in
the decompiled source while four live observations returned DENY, and the
observed two-byte drift between one in-script body length and a separately
measured download size was never root-caused because the marker file had been
removed by then - the two candidates are the per-request timing digits and a
difference in how the two measurements count the body. Marker files were removed
from the webroot after every run and the removal was confirmed by a follow-up
anonymous GET returning 404/653 and by a filesystem search returning zero; the
non-traversal control entries inside the content tree were left in place as
evidence, and all instances were destroyed at teardown along with the sessions
they held.
Negative controls were run for every positive claim and are reported with the
same prominence as the positives. A same-named entry without ".." in the same
archive produced zero hits in the webroot and landed inside the version
directory, which excludes the reading that arbitrary paths land in the webroot -
only traversal entry names escape. The identical bytes at the identical location
with the extension changed to .txt, fetched anonymously, returned HTTP 200 with
the verbatim template source and no evaluation comment, which excludes the
reading that a 200 means execution or that any webroot file is served as code:
the .gsp mapping is what evaluates. A .gsp path that does not exist returns 404
with 653 bytes, which excludes the reading that every .gsp request returns 200.
A webroot writability probe as uid 1001, entirely independent of the traversal,
succeeded and its probe template executed anonymously with its marker present
exactly once, which establishes the amplifier half separately from the write
half. A template-injection probe of ${7*7} through normal request parameters was
echoed literally with zero occurrences of 49, which excludes server-side
template injection: this is write-then-execute, a different mechanism. Feature-
gate inspection found five @NoAuthRequired getters on ServerInfoService with no
setter counterpart, which excludes the gate being flippable from the management
surface, anonymous or authenticated.
Two behaviours deserve a sentence each because they will mislead anyone
verifying by response code. First, the write succeeds even when the response
reports an error: any entry whose name contains .htm is routed at
DoorsFilesUploadService:88 into splitDocument, which throws unless the content is
a well-formed DOORS export, so a round can return HTTP 200 with a 131-byte
IllegalStateException reading "Doors document can't be split. It is empty or not
correct" after the traversal entry - appearing earlier in the archive - has
already been written and is already anonymously executable. Judge the marker, not
the status code. Second, depth is attacker-controlled and one run confirmed it at
six segments with a deeper zip name, whose marker also executed on an anonymous
GET.
Detection notes, and one that will otherwise cause false positives. Detection of
this chain is harder than the response codes suggest. HTTP 200 accompanies
success, the blocked shape and the partially-failed shape alike: the blocked
shape returns 200 with an InternalError, the .htm variant returns 200 with an
IllegalStateException after a successful write, and the clean shape returns 200
with a 41-byte success body, so status-code alerting cannot separate them. The
discriminating field is the JSON code, and even that is misleading in the
IllegalStateException case precisely because a write has succeeded. Body length
is not a reliable discriminator either - the product embeds the command name in
its error text, so the same AuthenticationError appears at 118, 122, 123 and 127
bytes depending on which method was refused, and template responses carry
per-request timing digits (77, 54, 158, 309, 150 and 140 milliseconds were
observed for the same payload) that change the length of identical output.
Verification therefore never used body length as the criterion, only the presence
of the round's random tag and the evaluation comment. The product's own audit log
looks entirely normal: it records a review creation and a file-addition line
naming a version directory under /webserver/doors/, and never the entry name, the
traversal or the webroot destination. The triggering request is anonymous and
unremarkable - an ordinary GET for a .gsp path carrying no session, for which the
server issues a fresh cookie and sets X-Smart-Bear-Not-Logged-In: true.
Compilation leaves no residue: a scan of the Tomcat work directory found zero
compiled template output, because Groovy compiles in memory. What does leave a
trace is a .gsp file in the webroot. The shipped artifact contains exactly one
Groovy template in the entire installation, at ROOT/jsp/views/json_tester.gsp, so
any other .gsp under webapps/ROOT is anomalous and is a high-confidence indicator
on its own; file-integrity monitoring on the Tomcat webroot is the single most
effective detection control available to an operator today. Multipart bodies
containing entry names with ".." are detectable at the network layer, but only
where traffic inspection decodes multipart bodies, which most deployments do not
do. Alert on the JSON code field combined with a webroot change rather than on
HTTP status, and watch for the two-request shape: a multipart POST to
/services/json/v1 whose json part names ReviewService.addDoorsFiles, followed
closely by an anonymous GET for a .gsp path that was not part of the shipped
installation. Three bypasses of the global filter were demonstrated during the
survey - a /jsp/errors substring that skips the filter body entirely through the
unanchored find() applied to the exclusion pattern at AuthTicketFilter.java:105,
a matrix-parameter suffix that defeats the prefix test at :122, and
encoded-separator variants of the same test. They are context for how much of
this product's surface is anonymous; none is required by this chain and none is
folded into this vector.
Mitigation summary, ordered by root cause. Canonicalise and containment-test
every archive entry name against a normalised destination base, per entry rather
than once per archive, in both DoorsFilesUploadService.java:77-84 and
SimulinkFilesUploadService.java:186-203 - resolve, normalise, and reject any
result that does not start with the base, or compare canonical paths - and
implement it once in the shared parent HtmlBaseFilesUploadService, which fixes
both instances and any future dispatch site. Reject dangerous entry names
explicitly as defence in depth, covering parent-directory segments, absolute
paths and backslash separators, and apply that rejection after the square-bracket
substitution rather than before it, since the substitution currently runs ahead
of the concatenation and a sanitiser inserted at :77 would be operating on a name
that then changes. Move the review-content tree out of the webroot's traversal
radius so that content-webserver no longer resolves to a sibling of
webapps/ROOT inside the Tomcat working directory; that is what removes the
property turning a content write into a code write, and the missing check alone
is not the whole defect. Enforce an extension allowlist on extraction output, or
a denylist covering *.gsp, *.jsp, *.jspx, *.war, *.class and *.jar, and align the
two upload routes by applying to addDoorsFiles and addSimulinkFiles the same
restricted-file-pattern discipline VersionContentUploadServlet:90-93 already
applies. Narrow the *.gsp mapping by relocating templates under WEB-INF, binding
the mapping to authenticated access, or removing it - that eliminates the
execution half and reduces the defect to a file write inside the content tree.
Make AuthTicketFilter default-deny; it currently authenticates opportunistically
at :118, forces a 401 only on the single two-operand condition at :122 and
otherwise allows at :128, and inverting that also closes the demonstrated
bypasses. Add per-object authorisation on review writes, since :982 accepts an
arbitrary reviewId with no check that the caller may modify that review. And fix
the image packaging so every VOLUME-declared path is created and chowned to the
service account at build time, as the database directory already is - that
removes the only mechanism currently blocking the chain in the container shape,
and it should be said plainly that it is a correctness fix rather than a
mitigation: the traversal is the bug, and a deployment that happens to fail on
permissions is not a defence anyone should rely on, so fixing the packaging
widens rather than narrows the exposed population.
Until a patch exists, operators can answer the exposure question directly by
checking the ownership of the directory named by the content-webserver context
parameter - default collaborator-webserver, relative to the Tomcat working
directory - and whether that path is a runtime-created mount point. If it is
root-owned while the service runs as uid 1001, the chain is blocked at the first
filesystem call; if it is service-owned, treat the installation as exposed. Do
not "fix" the exposure on a native install by chowning that directory to root:
that breaks the feature and is exactly the packaging defect above, arrived at
from the other side. Then put file-integrity monitoring on the Tomcat webroot
alerting on any new file under webapps/ROOT and on any .gsp other than the single
shipped template; restrict who can hold an account, since the write side requires
one valid low-privilege login and nothing more, and treat every account holder as
able to reach the sink; remove or restrict the *.gsp mapping at the container
level if the JSON tester view can live without it; egress-filter the service,
which contacts a licence host and any configured source-control and
identity-provider endpoints that uid 1001 can now use; monitor for the
two-request shape described above; do not rely on response codes; and on any
instance where compromise is suspected rotate every stored credential -
source-control tokens, OAuth client secrets, directory-service bind credentials
and all Collaborator account passwords - because all of them are readable or
decryptable by the achieved identity and the password digests are unsalted MD5.
One regression test would have caught both code instances and would catch
reintroduction: build an in-memory archive containing exactly one entry whose
name traverses out of a temporary destination, call the extractor under test, and
assert both that a containment IOException is raised and that no file appears
anywhere outside that temporary directory, repeating for both extractors and for
absolute and backslash-separated names. The filesystem assertion matters more
than the exception assertion, because verification observed a round where the
response was an error and the write had already succeeded.
This is not a re-published public issue. Two prior public records exist on this
product axis - a post-authentication Java deserialization issue in the Google Web
Toolkit API and a clickjacking issue in the web UI - both on different version
lines, neither a path-traversal root cause and neither touching archive
extraction or a template-servlet mapping. The nearest public precedent is a
directory traversal in the unpacking routine of a different product from the same
vendor: same weakness class, same vendor, different product line, with shared code
positively excluded, since that routine's name returns zero hits across the
4457-file corpus and controls over the archive-handling symbols of this product
return hits. Two limits travel with that: the search establishes absence of
evidence within public indexes only, and exactly one build was exercised.
Full technical analysis and a reproducible proof-of-concept:
https://0day-rubbish.com/blog/smartbear-collaborator-doors-upload-zip-slip-webroot-gsp-rce
Project archive (ongoing disclosure series):
https://github.com/Exploit-Garbage/0day-Rubbish
The proof-of-concept is a Python 3 script using the standard library only -
argparse, io, json, ssl, sys, urllib, uuid and zipfile - performs no shell-out
and no operating-system command on the analyst host, assembles the archive in
memory, writes nothing to the analyst disk, embeds no lab address, defaults to
loopback, defaults to a read-only identity command, and refuses to run without an
explicit authorisation flag. It obtains a login ticket, builds a randomly tagged
marker archive, posts the batched authenticate and addDoorsFiles command array as
multipart, performs the anonymous GET and reports the marker and evaluation
evidence. Its control mode repeats the identical request with the traversal entry
name removed, so a null result demonstrates that the escape is caused by the entry
name rather than by the upload path in general; a further flag skips the two
read-only control requests.
SmartBear has been notified through the corporate legal mailbox its own website
terms of use publish, after we found no RFC 9116 security.txt at the well-known
path and confirmed that the vulnerability-submission control on its security page
is an embedded intake operated by a third-party managed program with no public
program page. We submitted nothing to that intake, because it is a
pre-publication channel that presumes coordinated handling and account creation,
whereas this research is already public and we disclose anonymously; the
notification is therefore a courtesy and this disclosure is unconditional. 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/