top of page

FortiGate Config Theft: Why Patching Is Not Enough

Writer: Inception Security
Inception Security
17 hours ago
9 min read


What this means for your clients: if a firewall was open to the internet, attackers may already hold a copy of the passwords stored inside it, so installing the update is only half the job and those passwords need to be changed too.


Picture a hypothetical client firewall: patched, ticket closed. Two weeks before the patch, someone downloaded its configuration file, and that copy now sits on a machine you will never see. Inside it are the bind account passwords the firewall uses to check VPN users.


That is the reported pattern behind FortiGate config theft in the k0t campaign. Group-IB and NyxLab published the research on October 21, 2026. We worked from ctipilot.ch's October 22 summary, not Group-IB's report.


Who k0t is, and what is unconfirmed


Per ctipilot.ch summarizing Group-IB and NyxLab, k0t is a probable initial access broker. Group-IB lists CryptoFeel as a potential alias and Scrooge as a likely collaborator and possible alias. Group-IB also reports victim overlap with the Qilin and The Gentlemen leak sites. All of this is reported but unconfirmed. Group-IB itself calls the link to the Qilin and The Gentlemen operators unconfirmed.


For defenders, the name matters less than what the operator does with a stolen config.


How the config theft works


Per the same summary, a custom script exploits FortiWeb CVE-2025-64446 or tries to brute force exposed Fortinet logins as a fallback. On a compromised FortiGate, the operator downloads the configuration and decrypts its stored secrets offline with the default FortiOS key. That weakness is CVE-2019-6693.


Per ctipilot.ch, the decryption runs on the attacker's copy and leaves no trace on the appliance. The download itself may still be logged.


The sources disagree on one point. Group-IB, via ctipilot.ch, says the decryption recovers administrator credentials along with LDAP and Active Directory bind secrets. Fortinet's advisory FG-IR-19-007 says the issue affects credential data of type ENC "except the administrator's password." Until that is settled, assume the administrator passwords are exposed too.


Group-IB, via ctipilot.ch, names NetExec, Impacket and Mimikatz among the tools used. That list is reported but unconfirmed.


The four Fortinet advisories in play


Only the first two are reportedly tied to k0t, per ctipilot.ch. The other two are separate activity.


FortiWeb CVE-2025-64446 (FG-IR-25-910). A relative path traversal that lets an unauthenticated attacker run administrative commands. CVSS 9.8. Fixed in 8.0.2, 7.6.5, 7.4.10, 7.2.12 and 7.0.12. CISA KEV added it on November 14, 2025. After upgrading, Fortinet says to review the configuration and logs for unexpected changes and unauthorized admin accounts.


FortiOS CVE-2019-6693 (FG-IR-19-007). A hard-coded key protects secrets in the FortiOS configuration. Fortinet scores it 4.3 and NVD scores it 6.5. CISA KEV added it on June 25, 2025, with known ransomware use. Fortinet notes FIPS-CC devices are not affected.


PEERSTITCH and CVE-2025-61624 (FG-IR-26-122). A path traversal in the command line interpreter of FortiOS, FortiPAM, FortiProxy and FortiSwitchManager that lets a privileged attacker write or delete files. Unit 42 reports attackers used it to escalate privileges in intrusions that deployed the PEERSTITCH implant. Fortinet scores it 6.0 and NVD scores it 6.5. CISA KEV added it on October 16, 2026. FortiOS fixes are 7.6.5 and 7.4.10. FortiOS 7.2, 7.0 and 6.4 must migrate to a fixed release.


FortiMail CVE-2026-104286 (FG-IR-26-175). Unauthenticated arbitrary file write through path traversal and NULL byte handling. CVSS 9.8. CISA KEV added it on October 1, 2026, and BleepingComputer reported zero-day exploitation. Fixed in 8.0.2, 7.6.7 and 7.4.9. FortiMail 7.2 must move to 7.4 or above. The workaround is to disable IBE. Fortinet published two IOC addresses: 79[.]141.169.187 and 45[.]129.0.192. BleepingComputer notes Fortinet has not disclosed who is behind the attacks.


Why MSPs and MDR teams own the cleanup


Patching is the vendor's fix. Cleanup is yours. A stolen FortiGate config can hand over the client's directory bind account, and no update reaches a config file that has already left the device.


What to change this week


After patching, turn on private-data-encryption. Check the warning below first. It is off by default. When enabled, FortiOS encrypts config secrets with an operator key. Fortinet lists it from FortiOS 5.6.11, 6.0.7 and 6.2.1.


Fortinet warns that enabling it on FortiManager-managed or HA FortiGates can cause install errors and split-brain until FortiManager 6.2.7 and FortiGate 6.2.6, 6.4.4 or 6.6.0 and later.


Then rotate credentials, treating any configuration from an exposed or unpatched device as compromised. Rotate each LDAP and Active Directory bind account first, then administrator passwords, since the sources disagree on those.


Finally, password protect configuration backups, as Fortinet advises.


Hunts to run (test first)


These are threat hunting templates, hand checked but not run on a live instance. Each hunt comes in four query languages: Splunk, Elastic ES|QL, CrowdStrike Falcon LogScale (CQL) and Microsoft KQL for Sentinel. Field names are assumptions in every language, so map them to your own logs and parsers. The table under the hunts, "Interpret before you escalate", says what is benign and what is not.


Test query. Confirm your Fortinet data is searchable before trusting an empty hunt.


Splunk (SPL)


index=* (sourcetype=fortigate* OR sourcetype=fortiweb* OR sourcetype=fortimail*) earliest=-24h
| stats count by sourcetype, host

Elastic (ES|QL)


FROM logs-fortinet_*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY data_stream.dataset, observer.name

CrowdStrike (Falcon LogScale CQL for NG-SIEM)


// Set the time picker to the last 24 hours
#Vendor="fortinet"
| groupBy([#event.module, observer.name])

Microsoft KQL (Sentinel)


CommonSecurityLog
| where TimeGenerated > ago(24h)
| where DeviceVendor == "Fortinet"
| summarize count() by DeviceProduct, Computer

Search 1 looks for new administrator accounts on FortiWeb, per Fortinet's post upgrade steps. Map action and user to your config change fields. Your own staff's changes are the usual false positive. An empty result means no logged account creation, not proof of none.


Splunk (SPL)


index=* sourcetype=fortiweb* (msg="*admin*" AND (action=add OR action=create)) earliest=-30d
| table _time, host, user, srcip, msg

Elastic (ES|QL)


FROM logs-*
| WHERE @timestamp > NOW() - 30 days
  AND TO_LOWER(observer.product) LIKE "*fortiweb*"
  AND TO_LOWER(message) LIKE "*admin*"
  AND TO_LOWER(event.action) IN ("add", "create")
| KEEP @timestamp, observer.name, user.name, source.ip, message
| LIMIT 10000

CrowdStrike (Falcon LogScale CQL for NG-SIEM)


// Set the time picker to the last 30 days
#Vendor="fortinet" observer.product=/fortiweb/i
| Vendor.msg=/admin/i
| in(field=Vendor.action, values=["add", "create"], ignoreCase=true)
| select([@timestamp, observer.name, user.name, source.ip, Vendor.msg])

Microsoft KQL (Sentinel)


CommonSecurityLog
| where TimeGenerated > ago(30d)
| where DeviceVendor == "Fortinet" and DeviceProduct has "FortiWeb"
| where Message contains "admin" and DeviceAction in~ ("add", "create")
| project TimeGenerated, Computer, SourceUserName, SourceIP, Message

Search 2 looks for repeated login failures against FortiGate administrator and VPN logins, the brute force fallback. Map status, srcip and user. Expired passwords cause noise. Empty may mean failures are not logged.


Splunk (SPL)


index=* sourcetype=fortigate* (((action=login OR logdesc="*login*") status=failed) OR action="ssl-login-fail") earliest=-14d
| stats count, dc(user) as users by srcip
| where count > 20
| sort - count

Elastic (ES|QL)


FROM logs-fortinet_fortigate.log-*
| WHERE @timestamp > NOW() - 14 days
  AND (((TO_LOWER(fortinet.firewall.action) == "login" OR TO_LOWER(rule.description) LIKE "*login*") AND TO_LOWER(fortinet.firewall.status) == "failed")
    OR TO_LOWER(fortinet.firewall.action) == "ssl-login-fail")
| STATS count = COUNT(*), users = COUNT_DISTINCT(source.user.name) BY source.ip
| WHERE count > 20
| SORT count DESC

CrowdStrike (Falcon LogScale CQL for NG-SIEM)


// Set the time picker to the last 14 days
#Vendor="fortinet" observer.product=/fortigate/i
| ((Vendor.action=/^login$/i OR Vendor.logdesc=/login/i) AND Vendor.status=/^failed$/i) OR Vendor.action=/^ssl-login-fail$/i
| groupBy([source.ip], function=[count(as=count), count(field=user.name, distinct=true, as=users)])
| count > 20
| sort(count, order=desc, limit=10000)

Microsoft KQL (Sentinel)


CommonSecurityLog
| where TimeGenerated > ago(14d)
| where DeviceVendor == "Fortinet" and DeviceProduct has "Fortigate"
| where ((DeviceAction =~ "login" or Activity contains "login") and AdditionalExtensions contains "status=failed")
    or DeviceAction =~ "ssl-login-fail"
| summarize EventCount = count(), users = dcount(SourceUserName) by SourceIP
| where EventCount > 20
| sort by EventCount desc

Search 3 looks for configuration downloads or backups. Map logdesc and msg to your config event fields. Scheduled backup jobs are the expected false positive. Empty may mean config events are not forwarded.


Splunk (SPL)


index=* sourcetype=fortigate* (logdesc="*backup*" OR msg="*config*download*" OR msg="*backup*") earliest=-30d
| table _time, host, user, srcip, msg

Elastic (ES|QL)


FROM logs-fortinet_fortigate.log-*
| WHERE @timestamp > NOW() - 30 days
  AND (TO_LOWER(rule.description) LIKE "*backup*" OR TO_LOWER(message) LIKE "*config*download*" OR TO_LOWER(message) LIKE "*backup*")
| KEEP @timestamp, observer.name, source.user.name, source.ip, message
| LIMIT 10000

CrowdStrike (Falcon LogScale CQL for NG-SIEM)


// Set the time picker to the last 30 days
#Vendor="fortinet" observer.product=/fortigate/i
| Vendor.logdesc=/backup/i OR Vendor.msg=/config.*download/i OR Vendor.msg=/backup/i
| select([@timestamp, observer.name, user.name, source.ip, Vendor.msg])

Microsoft KQL (Sentinel)


CommonSecurityLog
| where TimeGenerated > ago(30d)
| where DeviceVendor == "Fortinet" and DeviceProduct has "Fortigate"
| where Activity contains "backup" or Message matches regex @"(?i)config.*download" or Message contains "backup"
| project TimeGenerated, Computer, SourceUserName, SourceIP, Message

Search 4 looks for Windows network logons, event 4624 logon type 3 or 8 (an LDAP simple bind logs as type 8), by the bind account from unexpected sources. svc_fortigate_ldap is a placeholder. Replace it with your own FortiGate bind account, and repeat for each one. Add your FortiGate addresses to the exclusion. Domain controllers and expected relays are the usual false positives. Rows with an UNKNOWN source are a telemetry gap to review, not proof of misuse. Empty means no match in the searched data.


Splunk (SPL)


index=* sourcetype=WinEventLog* EventCode=4624 Logon_Type IN (3,8) Account_Name="svc_fortigate_ldap" NOT src_ip IN ("FORTIGATE_IP_1","FORTIGATE_IP_2") earliest=-30d
| eval src_ip=coalesce(src_ip,"UNKNOWN"), ComputerName=coalesce(ComputerName,"UNKNOWN")
| stats count, earliest(_time) as first_seen, latest(_time) as last_seen by src_ip, ComputerName
| convert ctime(first_seen) ctime(last_seen)

Elastic (ES|QL)


FROM logs-system.security-*, logs-windows.*
| WHERE @timestamp > NOW() - 30 days
  AND event.code == "4624"
  AND winlog.event_data.LogonType IN ("3", "8")
  AND TO_LOWER(user.name) == "svc_fortigate_ldap"
| EVAL src = COALESCE(TO_STRING(source.ip), "UNKNOWN"), computer = COALESCE(host.name, "UNKNOWN")
| WHERE src NOT IN ("FORTIGATE_IP_1", "FORTIGATE_IP_2")
| STATS count = COUNT(*), first_seen = MIN(@timestamp), last_seen = MAX(@timestamp) BY src, computer
| LIMIT 10000

CrowdStrike (Falcon LogScale CQL for NG-SIEM)


// Set the time picker to the last 30 days. Needs the Falcon sensor on your domain controllers.
#event_simpleName=UserLogon
| in(field=LogonType, values=["3", "8"])
| UserName=/^svc_fortigate_ldap$/i
| default(field=[RemoteAddressIP4, ComputerName], value="UNKNOWN")
| !in(field=RemoteAddressIP4, values=["FORTIGATE_IP_1", "FORTIGATE_IP_2"])
| groupBy([RemoteAddressIP4, ComputerName], function=[count(as=count), min(@timestamp, as=first_seen), max(@timestamp, as=last_seen)])
| formatTime("%Y-%m-%d %H:%M:%S", field=first_seen, as=first_seen)
| formatTime("%Y-%m-%d %H:%M:%S", field=last_seen, as=last_seen)

Microsoft KQL (Sentinel)


SecurityEvent
| where TimeGenerated > ago(30d)
| where EventID == 4624 and LogonType in (3, 8)
| where TargetUserName =~ "svc_fortigate_ldap"
| extend src_ip = iff(isempty(IpAddress) or IpAddress == "-", "UNKNOWN", IpAddress), ComputerName = iff(isempty(Computer), "UNKNOWN", Computer)
| where src_ip !in ("FORTIGATE_IP_1", "FORTIGATE_IP_2")
| summarize EventCount = count(), first_seen = min(TimeGenerated), last_seen = max(TimeGenerated) by src_ip, ComputerName

Microsoft KQL (Defender for Identity, optional)


// A broader check. This table has no numeric logon type, so it returns every successful logon it sees for the account.
IdentityLogonEvents
| where Timestamp > ago(30d)
| where ActionType == "LogonSuccess" and AccountName =~ "svc_fortigate_ldap"
| extend src_ip = iff(isempty(IPAddress), "UNKNOWN", IPAddress), ComputerName = iff(isempty(DestinationDeviceName), "UNKNOWN", DestinationDeviceName)
| where src_ip !in ("FORTIGATE_IP_1", "FORTIGATE_IP_2")
| summarize EventCount = count(), first_seen = min(Timestamp), last_seen = max(Timestamp) by src_ip, ComputerName, Protocol

Search 5 matches the FortiMail IOCs. Fortinet's IOC log shows a FortiMail mail archive account added with a remote destination at an IOC address. The query also matches archive234, the account name in that log. Map msg, src and dest to your mail and firewall fields. Review any hit. Empty means no match in the data you searched.


Splunk (SPL)


index=* ("79.141.169.187" OR "45.129.0.192" OR "archive234") earliest=-60d
| table _time, sourcetype, host, src, dest, msg

Elastic (ES|QL)


// Narrower than the Splunk raw search: checks IP fields and message text only
FROM logs-*
| WHERE @timestamp > NOW() - 60 days
  AND (source.ip IN (TO_IP("79.141.169.187"), TO_IP("45.129.0.192"))
    OR destination.ip IN (TO_IP("79.141.169.187"), TO_IP("45.129.0.192"))
    OR message LIKE "*79.141.169.187*" OR message LIKE "*45.129.0.192*" OR TO_LOWER(message) LIKE "*archive234*")
| KEEP @timestamp, data_stream.dataset, observer.name, source.ip, destination.ip, message
| LIMIT 10000

CrowdStrike (Falcon LogScale CQL for NG-SIEM)


// Set the time picker to the last 60 days
"79.141.169.187" OR "45.129.0.192" OR /archive234/i
| select([@timestamp, #event.module, observer.name, source.ip, destination.ip, @rawstring])

Microsoft KQL (Sentinel)


// Narrower than the Splunk raw search: add any other tables that hold firewall or mail logs
search in (CommonSecurityLog, Syslog) "79.141.169.187" or "45.129.0.192" or "archive234"
| where TimeGenerated > ago(60d)
| project TimeGenerated, $table, Computer, SourceIP, DestinationIP, Message, SyslogMessage

Interpret before you escalate


Finding

What it likely means

Next step

New FortiWeb admin you did not create

Possible CVE-2025-64446 abuse

Remove it, confirm patch level, review config changes

Many failed FortiGate logins from one address

Possible brute force

Block the address, check for a later success

Config download outside a backup job

Possible config theft

Treat the config as compromised and rotate

Bind account logon from an unexpected host

Possible stolen bind credentials in use

Disable the account, rotate, scope that host

Archive account pointing at an IOC address

Possible CVE-2026-104286 activity

Remove it, apply the fix or disable IBE

No results anywhere

No matching events, or a collection gap

Check each hunt's sources and fields before closing


What to alert on


Search 3 and Search 4 are the standing-alert candidates. A config download outside a backup window, or a bind account logon from an unexpected host, should page someone.


Sources



What This Means for Your Team


Patch, enable private-data-encryption after the FortiManager and HA check, rotate bind and admin secrets from exposed devices, and run the hunts. If a device was exposed, treat it as an incident, not a patch ticket.


Inception Protection


Inception Protection is the Inception Security managed detection and response service, and it works with the Microsoft licenses you already have. We watch Defender and sign-in data in your Microsoft environment, including Active Directory sign-ins from accounts like a FortiGate bind account, and work through containment with your team. Firewall configuration and patching stay with your team.


Inception Foresight


A bind password taken from a firewall config leads straight into the directory. To find out whether your Microsoft environment would catch that account being used somewhere new, try the free Inception Foresight M365 Assessment: https://www.inceptionsecurity.com/m365assessment.


bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page