Title: Synametrics Syncrify 6.2 - command execution
Author: 0day Rubbish Research Team
Contact: disclosure@0day-rubbish.com
Type: remote
Platform: Linux
CVE: pending
Description:
Synametrics Syncrify 6.2 build 1178, the closed-source incremental backup and file-replication server from Synametrics Technologies, Inc. (United States), written in Java and served by an embedded Tomcat 9.0.113 through the vendor's in-house sradef framework whose single front controller routes every request as /app?operation=<op>&st=<step> to a handler registered in WorkerMappings.xml, contains a path traversal (CWE-22 primary, CWE-269 and CWE-78 contributing for the deployment shape that converts the write into an execution) in the chunked-upload branch of its PublicLink handler. That branch, operation=pb with st=u, reads a length-prefixed binary frame straight from request.getInputStream() rather than through any parameter parser: four big-endian int32 fields for protocol, chunkNumber, totalChunks and size, then a four-byte length prefix followed by UTF-8 filename bytes, then exactly size body bytes. At PublicLink.java:70-110 the decompiled method evaluates new File(base, filename) on that client-supplied filename with no rejection of "..", no rejection of path separators, no base-name extraction and no canonical containment check, creates missing parent directories with target.getParentFile().mkdirs(), opens the result with new FileOutputStream(target, true) in append mode, writes the size bytes it read and returns 0, which the caller at PublicLink.java:127 turns into the client reply code="1" with <payload>0</payload>. base is the authenticated user's own folder at /opt/Syncrify/PublicLinks/<user>/<generated-folder>, five components below the filesystem root, so a chunk name of ../../../../../etc/cron.d/<name> leaves it and lands a cron fragment anywhere the service identity can write. Because the documented Linux deployment shape runs the server process at uid 0 and a cron daemon was active on the verified instance, the host scheduler then reads that fragment as a root-authored job definition and runs its command field through a shell on the next minute boundary. The application never constructs, quotes or invokes a command line and every execution site in the product uses an argument vector with no shell, so the execution happens one component later and argument hygiene at the application layer would not have prevented it. The chain was verified end to end and the identity capture the cron job produced was uid=0(root) gid=0(root) groups=0(root), established twice over: the fragment landed as -rw-r--r-- 1 root root at mode 0644, a file's owner after creation being the effective uid of its creator, and the scheduled job's own output reported uid 0 because the fragment names root in its user field and a scheduler will not honour that for a job file it does not already trust. The principal required is the product's lowest-trust account type. The gate on operation=pb is a secLevel=0 registration, meaning no administrator session, and the handler's own authentication call validates a credential from the backup-user store at config/UserMappings.xml via syncrify.users.DefaultUserManager rather than from the web-administrator store at config/umap.dat; the two stores are independent and must not be conflated. A backup user's entire legitimate job is to move bytes into its own repository, so the privilege boundary that fails here is inside the product, not a misconfiguration of the host. The defect is an omission rather than an absent control: three sibling families in the same tree, written against the same client protocol, do filter the same kind of field, the profile-upload, profile-fetch and file-splitter handlers rejecting separators with indexOf(47) and indexOf(92), the multipart upload path collapsing the name through getName(), and the download path testing indexOf("..") and answering 404 on a match. The chunk-upload branch calls none of them. There is no decoding step between the socket and the constructor, so no encoding trick is involved or needed. The product does ship a factory administration password of letmein in its first-run install wizard, but the first administrator login is forced through a password change through ChangePassword.jsp, the credential belongs to a different user store than the one gating this sink, and it is documented as a contributing fact only: it appears in no vector, raises no metric, and no conditional reading assumes it has not been rotated. No unauthenticated path to this sink was found and none is claimed.
CVSS: 7.5 High (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H) is the single score published for this finding. That is the primary reading, and the two metrics that carry its nuance are AV:N and AC:H: the vulnerable component is an HTTP handler on a network-bound servlet connector and the credential it accepts exists so that remote backup clients can use it, so AV:N is the faithful classification of the component, while the deployment-dependent exposure of the listener carrying the route is priced in attack complexity rather than smuggled into the attack vector. AC:H is High for two reasons outside the attacker's control, that the listener carrying /app?operation= be reachable from the attacker's position and that the host run a scheduler which scans /etc/cron.d. Base = Roundup(5.873119 + 1.620146) = Roundup(7.493265) = 7.5, from ISS = 0.914816 and Impact at S:U = 5.873119. Conditional reading: 8.8 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), applying only where the listener carrying the /app?operation= surface is reachable from the attacker's network position, which is the normal operating mode for a backup server whose clients push data to it remotely, and computed as Roundup(5.873119 + 2.835255) = Roundup(8.708374) = 8.8. The conditional is labelled conditional everywhere it appears, is never the headline, and the published score is 7.5 rather than 8.8. Two further readings are published as rejected with their arithmetic. The changed-scope variant of the same metrics, 8.6 High (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H), is rejected because the identity reached is the same root identity the server process already holds on the same host, so no component authority boundary is crossed by the outcome; the privilege gain is carried by PR:L and by C:H/I:H/A:H, which are the metrics meant to express it. The local-vector reading, 7.8 High (CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), is rejected for an evidential reason and it is worth being explicit that this rejection lowers our own number rather than raising it: that reading is what follows if you treat the loopback binding observed in the testbed as the shipped default, and 7.8 is higher than the 7.5 this submission publishes at AV:N. The loopback binding was applied by us in the configuration before any testing began, so the host carried no public exposure while research was in progress, and there is no shipped artifact, no configuration template, no installer script excerpt and no documentation quotation, establishing the factory value of that binding in either direction, not that it binds publicly by default and not that it binds loopback by default. That unestablished reachable-listener premise is the reason AC is High and the reason the 8.8 figure is conditional, and a reader who disagrees with us about the factory default ends up with a larger score than ours, not a smaller one. No version-range claim is made: only 6.2 build 1178 was decompiled and exercised.
Impact: arbitrary file write with an attacker-chosen path and attacker-chosen bytes, escalating to command execution as uid 0 on the verified deployment shape. The chunk branch writes exactly the bytes it reads to whatever path the chunk name resolves to, in append mode, creating missing parent directories via mkdirs(), so /etc/cron.d is the demonstrated escalation path and not the limit of the primitive. On this product the weight comes from what the host is for: it is a backup and file-replication server, and root on it means read access to the entire repository tree, every backup belonging to every configured client, which on a multi-tenant server is a confidentiality breach across all tenants rather than within one account, plus the server's own configuration holding administrator and backup-user credential material and transport secrets, the backup-user store comparing passwords in plaintext or storing them under a reversible permutation transform so a read of it hands over credentials directly. It also means integrity control over the replication path itself: repository contents, version and retention metadata and the server's own binaries can be altered in place, and because that path is how clients back up and restore, a modified backup set becomes a distribution channel whose corruption stays invisible until the moment the data is needed. Persistence is structural rather than incidental, a cron fragment running on every minute boundary until it is deleted and surviving restarts of both the service and the host. Availability follows the same authority: the backup service can be stopped, the host rebooted or hung, repositories deleted or truncated, and the scheduler used to keep doing so after the application alone is remediated, which destroys the recoverability that is the entire reason the product exists. All of that beyond the executed identity capture is argued from the identity reached and the deployment layout and was not performed; no repository data was read and no product file was modified or deleted during verification.
Verification boundary: static reversal of the decompiled handler tree, then a live run against a single deployed instance of Syncrify 6.2 build 1178 installed from the distributed Linux tarball archive into /opt/Syncrify and started with ./run.sh, behind the embedded Tomcat 9.0.113 announcing itself as Synametrics Web Server v7. Two properties of that instance were the researchers' rather than the product's and both are carried as boundaries. The portal listener was bound to the loopback address by an explicit configuration edit made before testing, so remote reachability of the route is argued from the product's own design and binding configuration, from the front controller being the same listener that serves the client-protocol operations backup clients use, and from the structural fact that a backup server whose clients push across a network must have that listener reachable from them or it cannot perform its function; it is not a measurement from a separate host, and no shipped artifact establishes the factory binding in either direction. The backup-user store did not exist after installation, so the verification account was written into config/UserMappings.xml by hand with plaintext password comparison rather than provisioned through the administrator's user-management operation, which is licence-gated and was blocked by a zero licence count; the encrypted-entry and directory-authentication branches were examined statically and not exercised. The chain was driven from the target host itself over the loopback interface because that is how the listener was bound, so the same chain arriving from across a network segment was not verified and is not claimed. Four controls were exercised: a non-traversing chunk name through identical authentication, framing and routing in the same session, landing inside the user's own PublicLinks folder with an assertion that neither the fragment nor the marker existed, which isolates the traversal as the cause of the outcome and of nothing else; a Java-level confirmation that the multipart family's getName() collapse keeps a traversing name inside the PublicLinks root; a dispatch control against st=f, which reached a different void E() overload that parses an ed parameter as a Long and performed no write anywhere, fixing the sink to the st=u branch rather than to the handler in general after an initial static reading had attributed the upload to the wrong overload; and the four-way authentication reply table showing code="1" for a correct pair, code="-2" for a wrong password so the comparison is live, and code="-1" for an absent password and an absent user so the parameter-presence gate fired before the credential lookup was entered, which is what makes the lookup's no-comparison branch unreachable from this surface. Root execution was observed rather than inferred, and a non-root service deployment was not tested, so no claim is made about what the primitive achieves under an unprivileged service identity, where the escalation demonstrated here would not follow while the file write itself survived. Exactly one payload shape was executed, an identity capture into a marker file, and only that shape is claimed; the advisory reproduces the identity string and the frame field lengths derived from the field values but not the run's own byte capture, which was not preserved, not the generated PublicLinks folder name, which embeds the run's timestamp, not the credential pair, which are mandatory tool arguments with no default, and not the per-run fragment name or marker path. The unauthenticated surface was exhausted first with negative results: thirteen operations answer anonymous requests with real content and no credential check and all thirteen are sink-free; the remaining level-0 operations are not anonymous and answer code="-2" without valid credentials; seventeen Runtime.exec and ProcessBuilder sites exist and none is reachable anonymously; eight deserialization candidates have no network caller and the classpath carries no gadget library, no commons-collections, Jackson, XStream, Kryo or Hessian; and no template engine or naming-service lookup exists in the tree. That enumeration is not a proof the unauthenticated surface is safe, and it surfaced weaknesses deliberately not claimed here and not folded into any metric, being a manualOverride filter-block bypass exposing debug pages with a directory listing and an environment-variable dump, a password-recovery account-enumeration weakness, globally disabled CSRF prevention that is irrelevant to a chain needing credentials the attacker holds, vendor-default credentials for an embedded database that is configured but disabled and was not listening, and a default trial licence serial whose effect was left unresolved. Only Syncrify 6.2 build 1178 is claimed; the same sradef framework underpins the vendor's sibling products and this build's own web.xml still carries the display-name SynaMan, which is recorded to say where else the same construction is worth auditing, and no sibling product was tested.
Proof-of-concept and full analysis, published unconditionally:
https://0day-rubbish.com/blog/synametrics-syncrify-publiclink-chunk-upload-traversal-cron-root-rce
PoC location in the public repository: product/synametrics/syncrify-6.2/publiclink-chunk-upload-traversal-cron-root-rce/exploit/synametrics_syncrify_publiclink_chunk_upload_traversal_cron_root_rce.py
Repository: https://github.com/Exploit-Garbage/0day-Rubbish
The shipped proof of concept is Python 3 using only the standard library, with no third-party dependency and no hardcoded target. It takes host, port, scheme, front-controller route, operation and upload step; a mandatory backup-user credential pair with no default; and the command, marker path, fragment path, fragment user field, fragment schedule, traversal depth, scheduler wait and per-request timeout as arguments, defaulting to the loopback interface, port 5800, a depth of five and an identity capture. It offers an --insecure flag for self-signed TLS on the https scheme, a --control mode that performs the identical authenticated upload with a non-traversing chunk name and then asserts that neither the fragment path nor the marker path exists, a --proof-command argument that reads proof from a local command's standard output for an operator who already holds a shell channel to the host, an --expect-root flag that exits non-zero unless the recovered proof reports uid=0, and a --cleanup mode that removes only the two paths the script created. Nothing destructive is performed and no product file is overwritten or deleted.
--
0day Rubbish Research Team
disclosure@0day-rubbish.com
https://0day-rubbish.com