<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: manuelerastus</title>
    <description>The latest articles on DEV Community by manuelerastus (@manuelerastus).</description>
    <link>https://dev.to/manuelerastus</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F763549%2F8c70f048-3d6b-4d63-a411-44188732a682.jpeg</url>
      <title>DEV Community: manuelerastus</title>
      <link>https://dev.to/manuelerastus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/manuelerastus"/>
    <language>en</language>
    <item>
      <title>We rebuilt a compromised server. Six hours later it was infected again.</title>
      <dc:creator>manuelerastus</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:00:59 +0000</pubDate>
      <link>https://dev.to/manuelerastus/we-rebuilt-a-compromised-server-six-hours-later-it-was-infected-again-1omb</link>
      <guid>https://dev.to/manuelerastus/we-rebuilt-a-compromised-server-six-hours-later-it-was-infected-again-1omb</guid>
      <description>&lt;p&gt;Rebuilding a compromised server from scratch is the textbook response. It is also not enough on its own. This is the story of a website server I rebuilt cleanly, watched get re-infected within six hours, and the one command that explained why.&lt;/p&gt;

&lt;p&gt;Details that would identify the organisation, hosts or attacker infrastructure have been removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first compromise
&lt;/h2&gt;

&lt;p&gt;The server was a small Linux container running a marketing website: a Next.js frontend and a Strapi CMS, both managed by PM2. During a routine check I found it in bad shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A cryptominer running from &lt;code&gt;/tmp&lt;/code&gt; at 200% CPU for about five days, talking to a mining pool on port 33333.&lt;/li&gt;
&lt;li&gt;A world-writable binary in &lt;code&gt;/usr/sbin&lt;/code&gt; registered as a systemd service, holding a connection open on port 443 (almost certainly command-and-control).&lt;/li&gt;
&lt;li&gt;A root crontab that recreated a hidden UID-0 user every 30 minutes, restarted a hidden Docker container, and every midnight fetched a fresh script from the internet and piped it to &lt;code&gt;/bin/sh&lt;/code&gt; using whichever of curl, wget, python3 or perl was available.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last item settled the response. Any in-place cleanup would have been silently undone at 00:00. So the plan was: contain, preserve evidence, rebuild.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Contain.&lt;/strong&gt; I took the container's network interface down from inside. On Proxmox, &lt;code&gt;pct exec&lt;/code&gt; and &lt;code&gt;pct pull&lt;/code&gt; go through the host, so I could still work on it with no network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preserve.&lt;/strong&gt; I pulled the application data out through the host and took a full forensic backup of the container, malware included, kept offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate.&lt;/strong&gt; I rotated every secret that had lived on the box: the database role password and all the CMS signing keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild.&lt;/strong&gt; I destroyed the container and rebuilt it with the same address. It came back without Docker-in-container support (which the site never needed), with key-only SSH and a host firewall.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Later the same day I hardened it further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No root at runtime.&lt;/strong&gt; The app moved from root's PM2 to an unprivileged &lt;code&gt;app&lt;/code&gt; user, and the user's passwordless sudo rule was removed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing executes from temp.&lt;/strong&gt; &lt;code&gt;/tmp&lt;/code&gt; and &lt;code&gt;/var/tmp&lt;/code&gt; became &lt;code&gt;noexec&lt;/code&gt; tmpfs mounts, set at the hypervisor level because an unprivileged container can't remount them itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress filtering.&lt;/strong&gt; Outbound traffic was default-deny, with an allow-list of DNS, HTTP/S, NTP, mail and the one database endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity checks.&lt;/strong&gt; A 15-minute timer now watches for extra UID-0 users, new crontabs, and Docker binaries reappearing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site came back up and every check passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two days later
&lt;/h2&gt;

&lt;p&gt;Two days later I looked again. A miner was running, this time from the &lt;code&gt;app&lt;/code&gt; user's home directory, with a second one staged in &lt;code&gt;/tmp&lt;/code&gt; and waiting.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;app&lt;/code&gt; user's crontab had the &lt;strong&gt;exact same persistence pattern&lt;/strong&gt; as before: create the hidden root user, start the hidden container. The system log showed that cron entry had been firing every 30 minutes since roughly six hours after the rebuild.&lt;/p&gt;

&lt;p&gt;But this time it had achieved almost nothing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The hidden root user was never created. &lt;code&gt;app&lt;/code&gt; had no sudo left, so every attempt failed.&lt;/li&gt;
&lt;li&gt;The hidden container never started. There was no Docker on the box.&lt;/li&gt;
&lt;li&gt;The staged miner in &lt;code&gt;/tmp&lt;/code&gt; could not run, because &lt;code&gt;/tmp&lt;/code&gt; was &lt;code&gt;noexec&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No SSH sessions appeared in the login records at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hardening had done its job: the attacker was stuck at the application user's privilege level. But something was clearly still letting them in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the wrong lead first
&lt;/h2&gt;

&lt;p&gt;My first theory was stolen credentials. Around the same time we had found malware on an admin workstation, so a reused host credential was plausible. It would also explain the missing login records, because commands run from the hypervisor don't appear in the container's login history.&lt;/p&gt;

&lt;p&gt;I couldn't prove or disprove it from the logs. So I tested the other obvious theory: a poisoned npm dependency. Nothing in either &lt;code&gt;node_modules&lt;/code&gt; tree had changed since the rebuild's install, and the only &lt;code&gt;postinstall&lt;/code&gt; script was a well-known benign one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The answer was one command away
&lt;/h2&gt;

&lt;p&gt;Then I ran the check I should have run first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm audit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deployed frontend was pinned to a Next.js version inside the affected range of &lt;a href="https://github.com/advisories/GHSA-9qr9-h5gf-34mp" rel="noopener noreferrer"&gt;GHSA-9qr9-h5gf-34mp&lt;/a&gt;: a &lt;strong&gt;critical, unauthenticated remote code execution&lt;/strong&gt; in the React flight protocol.&lt;/p&gt;

&lt;p&gt;That one finding explained everything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Explanation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No login records&lt;/td&gt;
&lt;td&gt;It was an HTTP exploit, not a login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compromise limited to the &lt;code&gt;app&lt;/code&gt; user&lt;/td&gt;
&lt;td&gt;That is exactly the privilege the Next.js process runs with&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reinfected about 6 hours after the rebuild&lt;/td&gt;
&lt;td&gt;The rebuild redeployed the same vulnerable version, and scanners for a public RCE find exposed sites quickly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rebuild had cleaned the server and then reinstalled the hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;I bumped Next.js to a patched version on the live deployment, rebuilt the app, and confirmed with &lt;code&gt;npm audit&lt;/code&gt; that the critical finding was gone. The CMS had its own critical advisories (an SQL injection among them), so it got the same treatment.&lt;/p&gt;

&lt;p&gt;One trap: a hotfix on the server is not a fix in the repository. Until the version bump is committed, the next pipeline deploy quietly reinstalls the vulnerable version. That handoff to the developers matters as much as the patch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A clean rebuild of a vulnerable app is a vulnerable app.&lt;/strong&gt; Before redeploying after an incident, audit the exact versions you are about to ship, not just the server underneath them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the cheap, likely explanation first.&lt;/strong&gt; A known public RCE in an internet-facing framework is far more likely than an attacker with stolen keys, and &lt;code&gt;npm audit&lt;/code&gt; takes seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defence in depth bought me time.&lt;/strong&gt; A non-root runtime, no sudo, no Docker, &lt;code&gt;noexec&lt;/code&gt; temp and egress filtering turned a second root compromise into a contained, app-level nuisance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the persistence pattern your detection.&lt;/strong&gt; Attackers reuse playbooks. The same crontab lines showed up twice. Watching for them (new UID-0 users, unexpected crontabs) is cheap and specific.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on boring signals.&lt;/strong&gt; The first miner ran at 200% CPU for about five days unnoticed. A sustained-CPU alert would have caught it on day one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you run Next.js or Strapi on the open internet, run &lt;code&gt;npm audit&lt;/code&gt; against what is actually deployed today. Not what's in your repo: what's running.&lt;/p&gt;

</description>
      <category>security</category>
      <category>incidentresponse</category>
      <category>nextjs</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your FortiGate static route is perfect. SD-WAN is ignoring it.</title>
      <dc:creator>manuelerastus</dc:creator>
      <pubDate>Wed, 04 Nov 2026 07:57:59 +0000</pubDate>
      <link>https://dev.to/manuelerastus/your-fortigate-static-route-is-perfect-sd-wan-is-ignoring-it-2789</link>
      <guid>https://dev.to/manuelerastus/your-fortigate-static-route-is-perfect-sd-wan-is-ignoring-it-2789</guid>
      <description>&lt;p&gt;I lost the better part of three days to one FortiOS behaviour while connecting two sites over IPsec. It bit me four times: three different destination subnets, then once more in the return direction. Each time the firewall policy, the phase 2 selectors and the static route all looked correct, and traffic still vanished or leaked out to the internet.&lt;/p&gt;

&lt;p&gt;This post is the explanation I wish I'd had, and the diagnostic order that finds it in minutes. Addresses and interface names are changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Two sites, each behind a FortiGate, joined by route-based IPsec tunnels. Site A has two internet links bundled into SD-WAN. Someone had added an SD-WAN rule to load-balance internet traffic across both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt; &lt;span class="n"&gt;sdwan&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;
        &lt;span class="n"&gt;edit&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
            &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="s2"&gt;"wan_load_balance"&lt;/span&gt;
            &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;-&lt;span class="n"&gt;balance&lt;/span&gt;
            &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="s2"&gt;"all"&lt;/span&gt;
            &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s2"&gt;"all"&lt;/span&gt;
            ...
        &lt;span class="n"&gt;next&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt;
&lt;span class="n"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I added a new remote subnet, &lt;code&gt;10.2.0.0/24&lt;/code&gt;, behind the tunnel &lt;code&gt;to-siteB&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config router static
    edit 0
        set dst 10.2.0.0 255.255.255.0
        set device "to-siteB"
    next
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firewall policy allowing LAN to the tunnel: done. Phase 2 selectors covering both subnets: done. Tunnel up: yes.&lt;/p&gt;

&lt;p&gt;From a real host on the LAN, nothing reached Site B.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the static route loses
&lt;/h2&gt;

&lt;p&gt;FortiOS doesn't go straight to the routing table. Roughly, the forwarding decision checks, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Policy routes (&lt;code&gt;config router policy&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SD-WAN service rules&lt;/li&gt;
&lt;li&gt;The routing table (FIB)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SD-WAN service rules are policy routes under the hood. A rule matching &lt;code&gt;src all&lt;/code&gt; / &lt;code&gt;dst all&lt;/code&gt; therefore claims &lt;strong&gt;every&lt;/strong&gt; forwarded packet before the routing table is consulted. However specific your static route is, it never gets a vote.&lt;/p&gt;

&lt;p&gt;The packet goes out of an SD-WAN member link, source-NATed to that link's public IP, towards a private address on the internet. It doesn't arrive anywhere, and nothing logs a "deny", because by its own rules nothing was denied.&lt;/p&gt;

&lt;p&gt;Note that the built-in &lt;em&gt;implicit&lt;/em&gt; SD-WAN rule follows the routing table. The trap is an explicit catch-all rule, which many deployments add for load balancing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it looked fine at first
&lt;/h2&gt;

&lt;p&gt;My first test was a ping from the FortiGate itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;execute ping-options source 10.1.0.1
execute ping 10.2.0.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. Self-originated traffic isn't forwarded through the same policy-route path as traffic arriving from a LAN host, so it takes the static route and goes into the tunnel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson one: test with real traffic from a real host.&lt;/strong&gt; A ping from the firewall proves the tunnel works, not that forwarding works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part one: outbound
&lt;/h2&gt;

&lt;p&gt;Add a policy route ahead of the SD-WAN rules for the new destination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;
    &lt;span class="n"&gt;edit&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;-&lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="s2"&gt;"lan"&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s2"&gt;"10.1.0.0/255.255.255.0"&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="s2"&gt;"10.2.0.0/255.255.255.0"&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;-&lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="s2"&gt;"to-siteB"&lt;/span&gt;
    &lt;span class="n"&gt;next&lt;/span&gt;
&lt;span class="n"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traffic from the LAN now entered the tunnel. The remote side saw the packets arrive and replied.&lt;/p&gt;

&lt;p&gt;Replies still never reached the LAN host.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part two: the return path
&lt;/h2&gt;

&lt;p&gt;This is the one that cost the most time. A reply comes out of the tunnel, gets decrypted, and then needs a &lt;strong&gt;second&lt;/strong&gt; routing decision to reach the directly connected LAN. That decision goes through the same order, and the catch-all SD-WAN rule claims it too.&lt;/p&gt;

&lt;p&gt;The symptoms looked like a hardware fault:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tunnel counters showed decrypted packets increasing.&lt;/li&gt;
&lt;li&gt;The sniffer showed nothing leaving the LAN interface.&lt;/li&gt;
&lt;li&gt;No session, no log entry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;diagnose debug flow&lt;/code&gt; finally showed what was happening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Match policy routing id=...: to 10.1.0.25 via ifindex-&amp;lt;wan member&amp;gt;
find a route: gw-&amp;lt;isp gateway&amp;gt; via &amp;lt;wan member&amp;gt;
...
policy-0 is matched, act-drop
Denied by forward policy check (policy 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Policy 0 is FortiOS's implicit deny. The packet only reached it because routing had already chosen the WAN link for a reply meant for the LAN, and no firewall policy allows tunnel-to-WAN.&lt;/p&gt;

&lt;p&gt;The fix is the mirror image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;
    &lt;span class="n"&gt;edit&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;-&lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="s2"&gt;"to-siteB"&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s2"&gt;"10.2.0.0/255.255.255.0"&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="s2"&gt;"10.1.0.0/255.255.255.0"&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;-&lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="s2"&gt;"lan"&lt;/span&gt;
    &lt;span class="n"&gt;next&lt;/span&gt;
&lt;span class="n"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson two: every new tunnel destination needs a policy route in both directions.&lt;/strong&gt; The return direction is not inherited.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic order that finds it fast
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use a real host.&lt;/strong&gt; Generate traffic from a LAN machine, not the firewall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sniff it.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   diagnose sniffer packet any &lt;span class="s2"&gt;"host 10.2.0.10"&lt;/span&gt; 4 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see it leaving a WAN member with a NATed source instead of the tunnel, SD-WAN has it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check which policy routes are being hit.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   diagnose firewall proute list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;hit_count&lt;/code&gt; of 0 on your new rule means it never matches. Compare it with a working sibling rule.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clear stale sessions before retesting.&lt;/strong&gt; Offloaded sessions keep their old path until cleared:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   diagnose sys session filter dst 10.2.0.10
   diagnose sys session clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trace the flow.&lt;/strong&gt; Still stuck? &lt;code&gt;diagnose debug flow&lt;/code&gt; names the exact policy route and gateway chosen. It's easiest to capture from the GUI's debug flow tool, because streaming live debug over a non-interactive SSH session is unreliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust &lt;code&gt;get&lt;/code&gt; over &lt;code&gt;show&lt;/code&gt;.&lt;/strong&gt; In a few edge cases &lt;code&gt;show&lt;/code&gt; omitted fields that were set. &lt;code&gt;get&lt;/code&gt; inside &lt;code&gt;edit &amp;lt;id&amp;gt;&lt;/code&gt; gives the live values.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What didn't help
&lt;/h2&gt;

&lt;p&gt;I tried these before finding the cause. None of them touch the routing decision, so none of them helped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disabling ASIC/NPU offload on the firewall policy&lt;/li&gt;
&lt;li&gt;Disabling NPU offload on the phase 1 interface&lt;/li&gt;
&lt;li&gt;Widening the firewall policy to &lt;code&gt;all&lt;/code&gt; / &lt;code&gt;all&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the sniffer shows the packet leaving the wrong interface, the problem is routing. Firewall policy changes won't fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An SD-WAN rule matching &lt;code&gt;dst all&lt;/code&gt; outranks every static route for forwarded traffic.&lt;/li&gt;
&lt;li&gt;Add explicit policy routes for internal and tunnel destinations, in &lt;strong&gt;both&lt;/strong&gt; directions.&lt;/li&gt;
&lt;li&gt;Prove forwarding with traffic from a real host. A firewall-originated ping proves only the tunnel.&lt;/li&gt;
&lt;li&gt;When a decrypted packet "disappears", trace the second routing decision before blaming the hardware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Better still, scope the load-balancing rule to internet destinations in the first place, for example with a negated RFC 1918 destination group. Then internal traffic never matches it.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>fortinet</category>
      <category>vpn</category>
      <category>troubleshooting</category>
    </item>
  </channel>
</rss>
