Analyze the security incident using network traffic and Velociraptor logs as a DFIR analyst.
An external host attacked a CI server running in a Docker container and bypassed its authentication with a crafted REST request. It used that access to mint administrator accounts through the API, then logged in as the new user and delivered a JSP web shell packaged as a plugin. A second rogue account was created later and a second shell followed the same route. Through the shell the attacker ran OS commands and dropped a text file on the host as proof of access. The Velociraptor collection identifies the host and the container runtime; the packet capture holds the whole attack in cleartext HTTP.
client_info.json and collection_context.json → read with cat/Python. These hold the client and flow identifiersPstree.csv, Netstat, Docker.Version.csv, Docker.Info → read with Python or headPacketCapture result in the collection → open with tsharkTip:
http.file_datacomes back empty for chunked or reassembled bodies. Usetshark -z follow,tcp,ascii,<stream>to read request and response bodies.
Start with the collection metadata. client_info.json carries the Velociraptor client_id for the endpoint the artifacts were pulled from. It follows the C. plus 16 hex characters shape from the format mask.
grep -o '"client_id": *"[^"]*"' client_info.json
The flow is the collection job itself, so its identifier lives in collection_context.json as session_id. Flow IDs start with F., which matches the mask.
grep -o '"session_id": *"[^"]*"' collection_context.json
Search the process tree for docker components. In Pstree.csv the Docker daemon is the parent of the docker-proxy processes that publish the container’s ports, so it is the process that sits above the container workload on the host. The containerd-shim and the container’s run-server.sh entry point also show up in the tree, which makes the question ambiguous.
grep -i docker Pstree.csv
Gotcha: The shim and the container’s startup script look like natural “parent” candidates but are not what the question wants. The daemon PID that parents
docker-proxyis the accepted one.
The collection includes a Docker version artifact. Read the server version from Docker.Version.csv.
head Docker.Version.csv
Rank conversations in the capture, then look at the HTTP requests from the external talker. One address sends a stream of odd /hax?jsp=... requests to the web server port, which is not application traffic.
tshark -r capture.pcap -q -z conv,tcp
tshark -r capture.pcap -Y http.request -T fields -e ip.src -e http.request.method -e http.request.uri | sort | uniq -c | sort -rn
The server answers /app/rest/server with its version metadata, and the attacker’s own requests fetch it. Pull the response body from that frame and read the buildNumber. The product release (TeamCity 2023.11.3) maps to this build.
tshark -r capture.pcap -Y 'http.request.uri contains "/app/rest/server"' -T fields -e frame.number
tshark -r capture.pcap -q -z follow,tcp,ascii,<stream> | grep -i build
The attacker requests REST endpoints with a ;.jsp suffix, such as /hax?jsp=/app/rest/users;.jsp. Appending .jsp makes the server treat the request as one of the paths that skip authentication, so the REST API runs unauthenticated. That is the TeamCity authentication bypass that affects builds before the 2023.11.4 fix. This answer comes from matching the request pattern and the Q6 build to the public advisory, not from a field in the capture.
tshark -r capture.pcap -Y 'http.request.uri contains ";.jsp"' -T fields -e frame.number -e http.request.method -e http.request.uri
The bypass lets the attacker POST to the users endpoint. Take the first POST to /hax?jsp=/app/rest/users;.jsp and read the request body, which carries the new account’s username and password in JSON. Follow the TCP stream to get the body, since the raw HTTP field is empty.
tshark -r capture.pcap -Y 'http.request.method == "POST" && http.request.uri contains "/app/rest/users"' -T fields -e frame.number -e tcp.stream
tshark -r capture.pcap -q -z follow,tcp,ascii,<stream>
Gotcha: Several accounts are created. Sort by frame number and take the earliest POST to the users endpoint.
Filter the attacker’s POST requests that are not part of the REST API. The upload goes to the admin plugin upload page, a multipart POST carrying a ZIP.
tshark -r capture.pcap -Y 'ip.src == 95.164.9.144 && http.request.method == "POST"' -T fields -e http.request.uri | sort | uniq -c
Plugins land under /plugins/<name>/ once loaded. The attacker’s later POSTs carry a cmd= parameter against a JSP in that tree, which is the shell. Take host and port from the request’s Host header to build the full URL.
tshark -r capture.pcap -Y 'http.request.uri contains "/plugins/"' -T fields -e http.host -e http.request.method -e http.request.uri
Find the request that creates the 41m67llo account and follow the upload that comes after it. That upload is a second pluginUpload.html POST whose multipart body names the ZIP in its filename= field. Following the stream (118) shows the upload and the post-exploitation that follows.
tshark -r capture.pcap -q -z follow,tcp,ascii,118 | grep -a 'filename='
Search the capture for shell commands sent through the cmd= parameter. One of them is an echo redirected into a file on the host, and the quoted string is the file content.
tshark -r capture.pcap -Y 'frame contains "cmd="' -T fields -e frame.number -e http.request.uri -e urlencoded-form.value
| Phase | Action |
|---|---|
| Initial Access | Attacker sends REST requests with a ;.jsp suffix to bypass TeamCity authentication |
| Persistence | Creates the first administrator account through /app/rest/users |
| Execution | Logs in and uploads a JSP web shell as a plugin through /admin/pluginUpload.html |
| Execution | Runs commands through the cmd= parameter of the shell |
| Persistence | Creates a second account, 41m67llo, and uploads a second plugin ZIP |
| Impact | Writes a taunting text file to the host with echo |
| Type | Value |
|---|---|
| IP (attacker) | 95[.]164[.]9[.]144 |
| IP (victim server) | 18[.]159[.]50[.]167 |
| URL (web shell) | hxxp[://]18[.]159[.]50[.]167:8111/plugins/5z6p8kCA/5z6p8kCA[.]jsp |
| Account (first) | ahup1rui |
| Account (second) | 41m67llo |
| File (plugin upload) | V5HwJgS3.zip |
| CVE | CVE-2024-27198 |
| Technique | ID | Description |
|---|---|---|
| Exploit Public-Facing Application | T1190 | Authentication bypass on the TeamCity server |
| Create Account | T1136 | Administrator accounts created through the REST API |
| Server Software Component: Web Shell | T1505.003 | JSP shells delivered as plugin uploads |
| Command and Scripting Interpreter | T1059 | OS commands run through the shell’s cmd= parameter |
Patch internet-facing CI servers on the advisory, not the next cycle. The attacker’s very first requests used a known bypass against an unpatched build. Version disclosure on /app/rest/server also told them what they were facing, so restrict that endpoint and track vendor advisories for build tooling.
Alert on the ;.jsp request pattern. Paths with ;.jsp appended to REST routes have no legitimate use. A WAF or reverse-proxy rule on that pattern would have blocked the exploit and flagged the scan.
Admin account creation and plugin uploads are rare, high-signal events. A burst of new users via the API followed by a POST to pluginUpload.html should page someone. Audit the user list and installed plugins after any exposure.
Capture the container host as well as the container. The Velociraptor collection resolved the Docker daemon, version and port proxies, which is what ties the network evidence back to a specific process on the host.
The attacker went from one unauthenticated REST request to administrator accounts and a working web shell on a containerised CI server, then left a text file to say so. A single patch and a block on one URL pattern would have stopped the whole chain.