# Reverse Engineering FortiClient's Hidden Download Protocol
Fortinet doesn't provide direct download links to the free FortiClient product publicly however it is available via an online installer, or via the support portal (license required?). The online installer downloads the software at runtime, which seems unnecessarily complicated for a free product, so I decided to investigate what was actually happening behind the scenes.
What I found was multi-server infrastructure with custom binary protocols, OpenSSL implementations, and enterprise-grade networking that represents engineering overkill that only makes sense if controlling access is more important than user convenience which seems to be the case as you used to be able to download it directly.
## Initial Binary Analysis with Ghidra
Loading `FortiClientVPNInstaller.exe` into Ghidra immediately revealed different networking behavior. The import table showed some socket-level functions:
![[b29bbdd669d7595454c9b2b01199c35b.png]]
What's missing is any standard Windows HTTP library like `WININET.DLL`, `WINHTTP.DLL`, or `URLMON.DLL`. This installer implements networking at the raw socket level, which seems like overkill for downloading a file.
String analysis made things even more interesting. The binary contained references to OpenSSL internals scattered throughout:
![[e5a0060bd9afd71eb42c20d7ccb9967f.png]]
These strings show that FortiClient uses OpenSSL's BIO (Binary Input/Output) abstraction layer for networking.
The decompiled code showed a complex state machine managing the entire connection process:
```c
int FUN_00719270(int param_1, int *param_2)
{
code *pcVar1;
uint uVar2;
int iVar3;
int iVar4;
int iVar5;
undefined8 uVar6;
undefined4 uVar7;
undefined4 uVar8;
iVar5 = -1;
iVar4 = *param_2;
pcVar1 = (code *)param_2[7];
do
{
if (6 < iVar4 - 1U)
{
LAB_00719652:
if (pcVar1 == (code *)0x0)
{
return iVar5;
}
iVar4 = (*pcVar1)(param_1, *param_2, iVar5);
return iVar4;
}
switch (iVar4 - 1U)
{
case 0:
if ((param_2[2] == 0) && (param_2[3] == 0))
{
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0x5a, "conn_state");
FUN_005a26c0(0x20, 0x90, "hostname=%s service=%s", param_2[2], param_2[3]);
goto LAB_00719652;
}
*param_2 = 2;
break;
case 1:
iVar4 = param_2[1];
if (iVar4 == 4)
{
uVar8 = 2;
}
else if (iVar4 == 6)
{
uVar8 = 0x17;
}
else
{
if (iVar4 != 0x100)
{
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0x7b, "conn_state");
FUN_005a26c0(0x20, 0x92, 0);
goto LAB_00719652;
}
uVar8 = 0;
}
iVar4 = FUN_005cb430(param_2[2], param_2[3], 0, uVar8, 1, param_2 + 5);
if (iVar4 == 0)
goto LAB_00719652;
if (param_2[5] == 0)
{
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0x84, "conn_state");
FUN_005a26c0(0x20, 0x8e, 0);
goto LAB_00719652;
}
param_2[6] = param_2[5];
*param_2 = 3;
break;
case 2:
uVar7 = 0;
uVar8 = FUN_005cb120();
FUN_005cb170(param_2[6], uVar8, uVar7);
uVar8 = FUN_005cb0d0(param_2[6]);
iVar5 = FUN_00622a80(uVar8);
if (iVar5 == -1)
{
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0x90, "conn_state");
iVar4 = param_2[3];
uVar6 = CONCAT44(param_2[2], "calling socket(%s, %s)");
iVar3 = WSAGetLastError();
FUN_005a26c0(2, iVar3, uVar6, iVar4);
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0x93, "conn_state");
FUN_005a26c0(0x20, 0x76, 0);
goto LAB_00719652;
}
*(int *)(param_1 + 0x24) = iVar5;
*param_2 = 4;
break;
case 3:
FUN_005957f0();
FUN_005a3ff0();
uVar2 = param_2[4] | 4;
uVar8 = FUN_005cb0c0();
iVar5 = FUN_006224f0(*(undefined4 *)(param_1 + 0x24), uVar8, uVar2);
*(undefined4 *)(param_1 + 0x20) = 0;
if (iVar5 == 0)
{
iVar4 = FUN_00622230(0);
if (iVar4 != 0)
{
FUN_00596c30();
*param_2 = 6;
*(undefined4 *)(param_1 + 0x20) = 2;
FUN_005a3dc0();
goto LAB_00719652;
}
iVar4 = FUN_005cb110(param_2[6]);
param_2[6] = iVar4;
if (iVar4 == 0)
{
FUN_005a2f70();
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0xb1, "conn_state");
iVar4 = param_2[3];
uVar6 = CONCAT44(param_2[2], "calling connect(%s, %s)");
iVar3 = WSAGetLastError();
FUN_005a26c0(2, iVar3, uVar6, iVar4);
*param_2 = 7;
}
else
{
FUN_006224d0(*(undefined4 *)(param_1 + 0x24));
*param_2 = 3;
FUN_005a3dc0();
}
}
else
{
FUN_005a2f70();
LAB_00719486:
*param_2 = 5;
}
break;
case 4:
iVar5 = 1;
goto LAB_00719652;
case 5:
FID_conflict:
__time64((__time64_t *)0x0);
iVar4 = FUN_005caf50(*(undefined4 *)(param_1 + 0x24), 0);
if (iVar4 != 0)
{
iVar4 = FUN_005cacb0(*(undefined4 *)(param_1 + 0x24));
if (iVar4 == 0)
goto LAB_00719486;
uVar8 = 0xf;
iVar3 = param_1;
FUN_005957f0();
iVar3 = FUN_005cb110(param_2[6], iVar3, uVar8);
param_2[6] = iVar3;
if (iVar3 == 0)
{
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0xcd, "conn_state");
FUN_005a26c0(2, iVar4, "calling connect(%s, %s)", param_2[2], param_2[3]);
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0xd0, "conn_state");
FUN_005a26c0(0x20, 0x6e, 0);
iVar5 = 0;
goto LAB_00719652;
}
FUN_006224d0(*(undefined4 *)(param_1 + 0x24));
*param_2 = 3;
}
break;
case 6:
FUN_005a2510();
FUN_005a2610("crypto\\bio\\bss_conn.c", 0xe2, "conn_state");
FUN_005a26c0(0x20, 0x67, 0);
iVar5 = 0;
goto LAB_00719652;
}
if ((pcVar1 != (code *)0x0) && (iVar5 = (*pcVar1)(param_1, *param_2, iVar5), iVar5 == 0))
{
return 0;
}
iVar4 = *param_2;
} while (true);
}
```
This function manages multiple connection states, handles errors, and implements retry logic. The parameters suggested that `param_1` contained the target hostname and `param_2` contained the port.
## Dynamic Analysis with x64dbg
Static analysis only shows you potential behavior. To see what actually happens at runtime, I loaded the installer into x64dbg and set breakpoints:
![[4dfe64864232d2aef8c4d524a69ec4b6.png]]
```plaintext
bp ws2_32.send # Capture outbound requests
bp ws2_32.recv # Monitor server responses
bp ws2_32.connect # Track connection attempts
bp ws2_32.getaddrinfo # DNS resolution monitoring
```
Running the installer revealed that it doesn't connect to just one server but maintains a list of CDN endpoints with load balancing. Here's what the stack memory showed during execution:
![[d2bf7a63c92439af2a0db59c649713ba.png]]
```plaintext
Stack Memory - Server 1:
02F4F078 "POST /fdsupdate HTTP/1.1\r\nUser-Agent: Mozilla/4.0 (compatible; FCT 7.4.3; Windows NT 5.1)\r\nHost: 173.243.138.76\r\nCache-Control: no-cache\r\nContent-Length: 480\r\n\r\n"
Stack Memory - Server 2:
03469990 "POST /fdsupdate HTTP/1.1\r\nUser-Agent: Mozilla/4.0 (compatible; FCT 7.4.3; Windows NT 5.1)\r\nHost: 209.40.106.66\r\nCache-Control: no-cache\r\nContent-Length: 464\r\n\r\n"
```
Two different IP addresses, both receiving POST requests to `/fdsupdate`. The installer seems to implement some sort of failover between multiple CDN servers.
![[8991d1da05f82a7be970a0f53a05283d.png]]
When I captured the actual payload being sent. This wasn't a standard HTTP request:
![[e2f8f6b51006f034353daf436107c2f6.png]]
![[e55d1840cbde3807caf39dc9e72080db.png]]
![[895d3e27f3d890a7b8dae96d31ef70b2.png]]
The installer was sending 464-480 bytes of structured binary data as the POST body. Decoding revealed readable strings embedded within:
```plaintext
Decoded Binary Content:
Protocol Marker: "FCPCFCP Command"
Response Type: "FCPR"
Session ID: "00000000002509012243"
+ ~400 bytes of additional binary data
```
This was a custom binary protocol running over HTTP engaging in a handshake with Fortinet's servers.
## Understanding the Custom Protocol
Understanding what was happening in this protocol exchange was tricky. The `/fdsupdate` endpoint clearly expected specific binary data. I tried replaying the captured request to see how the server would respond.
First, I had to extract the binary payload correctly:
![[1bdd273210831e9a321a662e86cf6bbb.png]]
```powershell
$base64Data = "hHhImK6W3Je6HBS2CABFAAG4i..."
[IO.File]::WriteAllBytes("$currentDir\postdata.bin", [System.Convert]::FromBase64String($base64Data))
curl -X POST http://209.40.106.66/fdsupdate -H "User-Agent: Mozilla/4.0 (compatible; FCT 7.4.3; Windows NT 5.1)" -H "Cache-Control: no-cache" -H "Content-Type: application/octet-stream" --data-binary "@postdata.bin"
```
The server returned an empty response. It wasn't accepting replayed requests, which meant the binary protocol included session validation, time-based tokens, or both. This level of protection for a file download was... unexpected.
Continuing the dynamic analysis eventually revealed the second stage:
```plaintext
Runtime Log Output:
"Mon Sep 1 23:33:23 2025 - downloading image from server: 209.40.106.66"
File System Operations:
L"FortiClientVPN.exe" # The actual installer file
L"obj_1_b12352" # Temporary object identifier
```
The process was two-stage: first, the complex binary protocol handshake with `/fdsupdate`, then the actual file download. The installer negotiated for permission before receiving the file.
## Why Build All This Complexity?
Custom OpenSSL BIO implementation for secure communications, multi-server CDN infrastructure with automatic failover, proprietary binary protocol with session management, client authentication and request validation, time-based token systems preventing replay attacks.
For a company to invest this heavily in making downloads more complicated, there has to be a strong business justification. The explanation is likely control. By forcing users through this system, Fortinet maintains complete visibility into who downloads their software, when, and what systems they're installing on. They can disable downloads remotely, implement geographic restrictions, or require updated terms before allowing downloads.
From a user perspective, this creates problems. You can't download FortiClient for offline installation on air-gapped systems. You can't maintain local mirrors for enterprise deployment. You can't verify that everyone receives identical installers, since delivery is session-based.
More fundamentally, this violates expectations about "free" software. When vendors say their product is free to download, users expect to actually be able to download it. Making downloads dependent on complex authentication that can be revoked isn't really free distribution.
## The Simple Workaround
All this reverse engineering revealed a straightforward bypass. Since I could see exactly when and where FortiClient writes the installer file, I could intercept it:
![[b4cd5dd13dfd4ff32552b7b63e8b79b1.png]]
This approach bypasses the entire protocol while getting the file you need. The irony is that Fortinet's elaborate control system makes it easy to extract the installer once you understand the mechanism.
The key insight is recognizing when engineering complexity doesn't serve users. Simple software distribution has been solved for decades. When vendors implement elaborate custom solutions for basic tasks, it's worth questioning who benefits from that complexity.
This kind of distribution mechanism should be considered a signal when evaluating software vendors. Not necessarily malicious, but indicative of priorities that favor operational control over user convenience. In security software where trust matters, unnecessary access barriers can reveal problematic vendor attitudes.
Sometimes the best approach to artificial complexity is simply bypassing it entirely.