Reverse Engineering Fortinet's FortiClient Download Protocol (*September 2, 2025*) 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 if you have a license. The online installer downloads the software at runtime, which seems unnecessarily complicated for a free product so I looked into it further. 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. 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`. ![[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 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. All this investigation revealed a straightforward answer. 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. It's important to recognize 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.