├── .github └── workflows │ ├── quickstart.yml │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE.txt ├── OpenSafetyInstall.ps1 ├── README.md └── src └── main.rs /.github/workflows/quickstart.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 3 | on: [push, pull_request] 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: icepuma/rust-action@master 11 | with: 12 | args: cargo fmt -- --check && cargo clippy -- -W clippy::pedantic -Dwarnings && cargo test 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Release 2 | on: push 3 | 4 | jobs: 5 | sign: 6 | 7 | runs-on: windows-2019 8 | 9 | steps: 10 | - name: Checkout Binaries 11 | uses: actions/checkout@v2 12 | - name: Install Rust 13 | uses: actions-rs/toolchain@v1 14 | with: 15 | toolchain: stable 16 | - name: Build Binaries 17 | run: cargo build --release --all-features 18 | - name: Decrypt signing key 19 | if: startsWith(github.ref, 'refs/tags/') 20 | run: | 21 | $tmpFolder = Join-Path $env:TEMP $(New-Guid) 22 | New-Item -ItemType Directory $tmpFolder 23 | Invoke-WebRequest -Uri "https://github.com/FiloSottile/age/releases/download/v1.0.0/age-v1.0.0-windows-amd64.zip" -OutFile (Join-Path $tmpFolder "age.zip") 24 | Add-Type -assembly "system.io.compression.filesystem" 25 | [io.compression.zipfile]::ExtractToDirectory((Join-Path $tmpFolder "age.zip"), $tmpFolder) 26 | [IO.File]::WriteAllText((Join-Path $tmpFolder "code_signing.age"), ("${{ secrets.SIGN_PFX }}" -replace "`r`n", "`n")) 27 | [IO.File]::WriteAllText((Join-Path $tmpFolder "agekey.txt"), ("${{ secrets.SIGN_AGE_KEY }}" -replace "`r`n", "`n")) 28 | & (Join-Path $tmpFolder "age\age.exe") --decrypt -i (Join-Path $tmpFolder "agekey.txt") -o (Join-Path $tmpFolder "code_sign.pfx") (Join-Path $tmpFolder "code_signing.age") 29 | $codeCertificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2((Join-path $tmpFolder "code_sign.pfx") , "${{ secrets.SIGN_PFX_KEY }}") 30 | Set-AuthenticodeSignature -FilePath ".\target\release\open_safety.exe" -Certificate $codeCertificate -TimeStampServer "http://timestamp.digicert.com" 31 | Set-AuthenticodeSignature -FilePath ".\OpenSafetyInstall.ps1" -Certificate $codeCertificate -TimeStampServer "http://timestamp.digicert.com" 32 | Remove-Item -Recurse "$tmpFolder" -Force 33 | shell: powershell 34 | - name: Upload Release 35 | uses: softprops/action-gh-release@v1 36 | if: startsWith(github.ref, 'refs/tags/') 37 | with: 38 | files: | 39 | OpenSafetyInstall.ps1 40 | .\target\release\open_safety.exe -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "base64" 7 | version = "0.13.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 10 | 11 | [[package]] 12 | name = "open_safety" 13 | version = "0.1.0" 14 | dependencies = [ 15 | "base64", 16 | ] 17 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "open_safety" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [dependencies] 7 | base64 = "0.13.0" 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Joshua Small 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /OpenSafetyInstall.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version 2 2 | 3 | # Check for elevation 4 | 5 | if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { 6 | Write-Output "This application must be run as an elevated admin" 7 | exit 8 | } 9 | 10 | $tmpfile = New-TemporaryFile 11 | 12 | $installpath = 'C:\Program Files\open_safety' 13 | If (-not (Test-Path $installpath)) { 14 | New-Item -Path $installpath -ItemType Directory 15 | } 16 | 17 | try { 18 | Invoke-WebRequest "https://github.com/technion/open_safety/releases/latest/download/open_safety.exe" -OutFile $tmpfile.FullName 19 | } catch { 20 | Write-Output "Failed to download installer" 21 | exit 22 | } 23 | 24 | $signature = Get-AuthenticodeSignature $tmpfile.FullName 25 | if ( $signature.Status -ne 'Valid') { 26 | Write-Output "Warning: Downloaded file is not signed" 27 | Remove-Item $tmpfile.FullName 28 | exit 29 | } 30 | 31 | Move-Item $tmpfile.FullName -Destination "$($installpath)\open_safety.exe" -Force 32 | Unblock-File "$($installpath)\open_safety.exe" 33 | Write-Output "Verified signature and installed binary. Setting up mappings" 34 | 35 | # List from application: allowed_extensions = ["js", "jse", "vbs", "wsf", "wsh", "hta", "com", "inf", "pif", "reg", "scf", "scr", "wsc"]; 36 | # .com is supported for manual mapping, but not automatically done due to potential false positives 37 | # Obtained existing names with: cmd /c assoc .ext 38 | # .js=JSFile 39 | # .jse=JSEFile 40 | # .vbs=VBSFile 41 | # .wsf=WSFFile 42 | # .wsh=WSHFile 43 | # .hta=htafile 44 | 45 | Write-Output "Assigning file associations:" 46 | cmd /c ftype JSFile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 47 | cmd /c ftype JSEFile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 48 | cmd /c ftype VBSFile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 49 | cmd /c ftype WSFFile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 50 | cmd /c ftype WSHFile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 51 | cmd /c ftype htafile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 52 | cmd /c ftype inffile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 53 | cmd /c ftype piffile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 54 | cmd /c ftype regfile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 55 | cmd /c ftype htafile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 56 | cmd /c ftype scffile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 57 | cmd /c ftype scrfile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 58 | cmd /c ftype wscfile=`"C:\Program Files\open_safety\open_safety.exe`" `"%1`" 59 | 60 | Write-Output "Open_safety is now active" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Safety 2 | An improvement on the "map .js files to notepad" trick 3 | 4 | Designed to assist with securing environments by ensuring such blocking events raise significant alarms. For background and more information, see [this blog post](https://lolware.net/blog/neutralising-script-ransomware/) 5 | 6 | # Installation 7 | ## Enterprise 8 | - Deploy the executable to an appropriate location 9 | - Replace notepad.exe mappings in Group Policies with the new location 10 | ## SMB/Home User 11 | - [Download the Release of OpenSafetyInstall.ps1](https://github.com/technion/open_safety/releases/latest/download/OpenSafetyInstall.ps1). This is a signed version of the script in the git tree. 12 | - Run OpenSafetyInstall.ps1 from an elevated Powershell 13 | 14 | # Usage 15 | 16 | A typical intended deployment involves never manually using this application. The above installation process will configure it to run with suspect files as a parameter. Example: 17 | ``` 18 | open_safety.exe example.js 19 | ``` 20 | 21 | You may wish to query the version: 22 | ``` 23 | open_safety.exe --version 24 | ``` 25 | 26 | # Response 27 | 28 | This application aims to provide two mechanisms to better handle script execution than the notepad trick. Specifically: 29 | 30 | - It provides the user a suitable message, presenting a much less confusing feedback than open a test file of source code 31 | - It attempts to alert any monitoring IT teams 32 | 33 | ## Details 34 | 35 | When this application is executed it will follow the below process, for the script "example.js": 36 | 37 | - To prevent any misuse, it first ensures the called file has an appropriate file extension 38 | - It further checks the file does not sit under standard system directories 39 | - The file is renamed to "DANGEROUS example.js.txt" to neutralise the risk. 40 | - It creates the file "example.com" in the same directory containing the EICAR test string. This should set off appropriate alarms for Defenders 41 | 42 | ## Development 43 | 44 | This application currently uses only one external crate (base64). It's designed as much as possible with guard rails around misuse, and it never actually deletes content. CI has been setup with strict use of clippy and cargo fmt. There's a deliberate goal of becoming "stable" and not requiring ongoing addition of features to assist with this becoming trusted for use. To this end, I'm unlikely to accept PRs with substantive changes. Designed to build with rust stable with no unsafe. The binary in "releases" is built straight from this codebase, includes no telemetry or additional code. Currently only Windows x64 type binaries are pre-built for releases. 45 | 46 | ## TODO 47 | 48 | - [X] Installation Powershell to fetch executable from Github releases 49 | - [X] Implement CI with Github actions 50 | - [X] Blog post on why this is useful 51 | - [X] Obtain a code signing cert 52 | 53 | ### Release guide 54 | ``` 55 | cargo build --release 56 | $codeCertificate = Get-ChildItem Cert:\CurrentUser\My 57 | Set-AuthenticodeSignature -FilePath .\target\release\open_safety.exe -Certificate $codeCertificate -TimeStampServer "http://timestamp.digicert.com" 58 | ``` 59 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![deny(unsafe_code)] 2 | 3 | use std::env; 4 | use std::ffi::OsStr; 5 | use std::fs; 6 | use std::fs::File; 7 | use std::io; 8 | use std::io::prelude::*; 9 | use std::io::Write; 10 | use std::path::Path; 11 | use std::path::PathBuf; 12 | 13 | extern crate base64; 14 | use base64::decode; 15 | 16 | const OPEN_SAFETY_VERSION: &str = "v1.1"; 17 | 18 | fn process_malware(filename: &std::path::Path) { 19 | // our "happy path" is the unhappy path where a user has executed a script 20 | // We are going to raise alarms by adding the EICAR string 21 | // Rename the original file preventing it from being run in future 22 | // https://stackoverflow.com/questions/43019846/best-way-to-format-a-file-name-based-on-another-path-in-rust 23 | let mut newname = PathBuf::from(filename); 24 | newname.set_file_name(format!( 25 | "DANGEROUS {}{}", 26 | newname.file_stem().unwrap().to_str().unwrap(), 27 | ".txt" 28 | )); 29 | if let Err(e) = fs::rename(filename, newname) { 30 | display_information(Some(&format!("Failed to rename file: {}", e))); 31 | return; 32 | }; 33 | 34 | // Create a new file with the same name, .com extension to ensure EICAR traps it. 35 | // Why yes, the original version of this script did write EICAR to .js files and several AV vendors wouldn't flag it 36 | let mut eicarfile = PathBuf::from(filename); 37 | eicarfile.set_extension("com"); 38 | let mut file = match File::create(eicarfile) { 39 | Ok(f) => f, 40 | Err(e) => { 41 | display_information(Some(&format!("Failed to create new file: {}", e))); 42 | return; 43 | } 44 | }; 45 | // In order to avoid this application itself being flagged by endpoint software, we've encoded our string 46 | // The below is the EICAR test string, with a new line before and after 47 | let eicarb64 = "WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCoK"; 48 | // The decode has to be safe as the B64 input is hard coded 49 | if let Err(e) = file.write_all(&decode(eicarb64).unwrap()) { 50 | display_information(Some(&format!("Failed to write EICAR to file: {}", e))); 51 | return; 52 | }; 53 | 54 | display_information(None); 55 | } 56 | 57 | fn main() { 58 | println!("open_safety: https://lolware.net/blog/neutralising-script-ransomware/"); 59 | let args: Vec = env::args().collect(); 60 | if args.len() < 2 { 61 | println!("This application must be provided a filename in order to take action - closing."); 62 | return; 63 | } 64 | if args[1] == "--version" { 65 | println!( 66 | "open_safety Version: {}\nhttps://github.com/technion/open_safety", 67 | OPEN_SAFETY_VERSION 68 | ); 69 | return; 70 | } 71 | let path = match Path::new(&args[1]).canonicalize() { 72 | Ok(buf) => buf, 73 | Err(x) => { 74 | println!("Error with provided file name: {}", x); 75 | return; 76 | } 77 | }; 78 | 79 | if let Err(e) = check_safe_extension(&path) { 80 | display_information(Some(e)); 81 | return; 82 | } 83 | if let Err(e) = check_safe_path(&path) { 84 | display_information(Some(e)); 85 | return; 86 | } 87 | if !path.is_file() { 88 | display_information(Some("Filename provided is not a valid file")); 89 | return; 90 | } 91 | process_malware(&path); 92 | } 93 | 94 | fn check_safe_extension(path: &std::path::Path) -> Result<(), &str> { 95 | // To ensure this application doesn't clobber anything unwanted, we check the filename against an extension allow list 96 | let extension = match path.extension().and_then(OsStr::to_str) { 97 | Some(ext) => ext, 98 | None => { 99 | return Err("Filename provided did not have an extension"); 100 | } 101 | }; 102 | 103 | let allowed_extensions = [ 104 | "js", "jse", "vbs", "wsf", "wsh", "hta", "com", "inf", "pif", "reg", "scf", "scr", "wsc", 105 | ]; 106 | if !allowed_extensions.contains(&extension) { 107 | return Err("Filename provided did not have an allowed extension"); 108 | } 109 | Ok(()) 110 | } 111 | 112 | fn check_safe_path(path: &std::path::Path) -> Result<(), &str> { 113 | // To ensure this application doesn't clobber anything unwanted, we check the path against a block list 114 | // This is a "best effort" type of test and obviously doesn't address all possible risks 115 | let canonical = path.to_str().expect("convert to path"); 116 | if canonical.starts_with("\\\\?\\C:\\Windows\\") 117 | || canonical.starts_with("\\\\?\\C:\\Program Files") 118 | { 119 | return Err("File resides in unsafe path"); 120 | } 121 | Ok(()) 122 | } 123 | 124 | fn display_information(input: Option<&str>) { 125 | let display = r##" 126 | 127 | ================================================================ 128 | 129 | This computer attempted to open a file type that is not usually 130 | associated with legitimate activities and has been protected by 131 | the open_safety system. 132 | 133 | If you are a developer or admin who is certain a script is safe, 134 | scripts can be run by passing as arguments to 135 | c:\windows\system32\cscript.exe 136 | 137 | This application will attempt to raise an alarm that should be 138 | seen by your IT security team. 139 | 140 | "##; 141 | 142 | let notice = match input { 143 | None => String::from("The potentially malicious application has been defanged. A substitute file was created to raise alarms"), 144 | Some(x) => format!("Unfortunately the following error was encountered when triaging this issue:\n {}", x) 145 | }; 146 | println!("{}{}", display, notice); 147 | pause(); 148 | } 149 | 150 | fn pause() { 151 | // From: https://users.rust-lang.org/t/rusts-equivalent-of-cs-system-pause/4494/3 152 | let mut stdin = io::stdin(); 153 | let mut stdout = io::stdout(); 154 | 155 | // We want the cursor to stay at the end of the line, so we print without a newline and flush manually. 156 | write!(stdout, "Press any key to continue...").unwrap(); 157 | stdout.flush().unwrap(); 158 | 159 | // Read a single byte and discard 160 | let _ = stdin.read(&mut [0_u8]).unwrap(); 161 | } 162 | 163 | #[cfg(test)] 164 | mod tests { 165 | use super::{check_safe_extension, check_safe_path, Path}; 166 | #[test] 167 | fn rejects_bad_extensions() { 168 | assert!(check_safe_extension(Path::new("filename.bad")).is_err()); 169 | assert!(check_safe_extension(Path::new("filename")).is_err()); 170 | } 171 | #[test] 172 | fn accepts_good_extensions() { 173 | assert!(check_safe_extension(Path::new("filename.js")).is_ok()); 174 | assert!(check_safe_extension(Path::new("C:\\test.folder\\filename.js")).is_ok()); 175 | } 176 | #[test] 177 | fn rejects_bad_paths() { 178 | // This is a valid for the the safe path check, but due to the extension it should never be practically called 179 | // This isn't technically accurate as check_safe_path is always called after canonicalize(). However, 180 | // that function relies on the file existing which is a real mess for CI 181 | assert!(check_safe_path(Path::new("\\\\?\\C:\\Windows\\win.ini")).is_err()); 182 | } 183 | #[test] 184 | fn accepts_good_paths() { 185 | // This path isn't valid for the rest of our application as it's a folder, but it's valid for this test. 186 | assert!(check_safe_path(Path::new("C:\\Users\\public\\")).is_ok()); 187 | } 188 | } 189 | --------------------------------------------------------------------------------