Recently, I had the privilege to write a detailed analysis of
CVE-2023-34362,
which is series of several vulnerabilities in the MOVEit file transfer
application that lead to remote code execution. One of the several
vulnerabilities involved an ISAPI module - specifically, the MoveITISAPI.dll
ISAPI extension. One of the many vulnerabilities that comprised the MOVEit RCE
was a header-injection issue, where the ISAPI application parsed headers
differently than the .net application. This point is going to dig into how to
analyze and reverse engineer an ISAPI-based service!
This wasn’t the first time in the recent past I’d had to work on something written as an ISAPI module, and each time I feel like I have to start over and remember how it’s supposed to work. This time, I thought I’d combine my hastily-scrawled notes with some Googling, and try to write something that I (and others) can use in the future. As such, this will be a quick intro to ISAPI applications from the angle that matters to me - how to reverse engineer and debug them!
I want to preface this with: I’m not a Windows developer, and I’ve never run an IIS server on purpose. That means that I am approaching this with brute-force ignorance! I don’t have a lot of background context nor do I know the correct terminology for a lot of this stuff. Instead, I’m going to treat these are typical DLLs from typical applications, and approach them as such.
What is ISAPI?
You can think of ISAPI as IIS’s equivalent to Apache or Nginx modules - that is, they are binaries written in a low-level language such as C, C++, or Delphi (no really, the Wikipedia page lists Delphi!) that are loaded into the IIS memory space as shared libraries. Since they’re low-level code, they can suffer from issues commonly found in low-level code, such as memory corruption. You’ve probably used Microsoft-supplied ISAPI modules without realizing it - they are used behind the scenes for .aspx applications, for example!
I found this helpful overview of ISAPI, which links to the other pages I mention below. It has a deprecation warning, but AFAICT no replacement page, so I can say it existed in June/2023 in case you need to use the Internet Archive to fetch it.
Depending on the application, ISAPI modules can either handle incoming requests
by themselves
(“ISAPI extensions”)
or modify requests en route to their final handler
(“ISAPI filters”).
They’re both implemented as .dll files, but you can distinguish one from the
other by looking at the list of exported functions (in a .dll file, an
“exported function” is a function that can be called by the service that loads
the .dll file). You can view exports in IDA Pro or Ghidra or other tools, but for
these examples I found a simple CLI tool written in Ruby tool called pedump
,
which you can install via the Rubygems command gem install pedump
.
Here are the exported functions in MOVEitISAPI.dll
, which is the ISAPI module
included with the MOVEit file transfer application:
$ pedump -E ./MOVEitISAPI.dll
=== EXPORTS ===
# module "MOVEitISAPI.dll"
# flags=0x0 ts="2106-02-07 06:28:15" version=0.0 ord_base=1
# nFuncs=3 nNames=3
ORD ENTRY_VA NAME
1 9deb0 GetExtensionVersion
2 9dfe0 HttpExtensionProc
3 9dff0 TerminateExtension
The two that we’re interested in are GetExtensionVersion
and
HttpExtensionProc
, which we’ll dig into later.
Let’s contrast the ISAPI module with an ISAPI filter - MOVEitFilt.dll
:
$ pedump -E ./MOVEitFilt.dll
=== EXPORTS ===
# module "MOVEitFilt.dll"
# flags=0x0 ts="2106-02-07 06:28:15" version=0.0 ord_base=1
# nFuncs=2 nNames=2
ORD ENTRY_VA NAME
1 1500 GetFilterVersion
2 1540 HttpFilterProc
The filter has two other functions - GetFilterVersion
and HttpFilterProc
.
So basically, you can figure out what type of ISAPI module you’re looking at by looking at the functions exported. In theory, there’s no reason why an ISAPI module can’t be both, but I’m not sure if anybody does that! Maybe for a CTF challenge I’ll try to develop an ISAPI Filter Extension that modifies its own requests :)
Finding ISAPI modules
Let’s say you’re working on an application that runs on IIS, and you want to
identify the attack surface - ie, find ISAPI modules. The probably-correct way
to answer this is to open up the IIS manager (InetMgr.exe
) and look at the
configuration. But that’s boring!
Recalling my original promise, to use “brute-force ignorance”, let’s use some commandline tools to figure out what’s going on!
The IIS process is called w3wp.exe
, so let’s use wmic
to find all instances
of w3wp.exe
running (note that I’m using MOVEit as an example, but this will
work on other applications as well):
C:\Users\Administrator>wmic process where "ExecutablePath like '%\\w3wp.exe'" get CommandLine
Which outputs:
c:\windows\system32\inetsrv\w3wp.exe -ap "moveitdmz Pool" -v "v4.0" -l "webengine4.dll" -a \\.\pipe\iisipm78b02bd1-595b-4442-9df0-a04b9d58775b -h "C:\inetpub\temp\apppools\moveitdmz Pool\moveitdmz Pool.config" -w "" -m 0 -t 20 -ta 0
c:\windows\system32\inetsrv\w3wp.exe -ap "moveitdmz ISAPI Pool" -v "v4.0" -l "webengine4.dll" -a \\.\pipe\iisipm2c508efc-6670-4302-a0b7-1ffb65ac196d -h "C:\inetpub\temp\apppools\moveitdmz ISAPI Pool\moveitdmz ISAPI Pool.config" -w "" -m 0 -t 20 -ta 0
In this case, there are two IIS services running, with two different
configurations: one is called the moveitdmz Pool
and the other is called the
moveitdmz ISAPI Pool
. We can probably guess that the latter is what we want,
but it turns out that the configurations are identical. I’m sure there’s some
meaningful difference, but this is precisely where my knowledge of IIS ends so
let’s just move on. :)
If we pick one of those configuration files and search it for “isapi”, we’ll find a ton of matches, because stuff like ASP are implemented as ISAPI modules. But if we look and search hard enough, we’ll find our modules:
[...]
<isapiFilters>
<clear />
<filter name="MOVEit Filter" path="C:\MOVEitTransfer\MOVEitISAPI\MOVEitFilt.dll" enabled="true" />
</isapiFilters>
[...]
<handlers accessPolicy="Execute">
<clear />
<add name="MOVEitISAPIExtension" path="MOVEitISAPI.dll" verb="GET,POST" modules="IsapiModule" scriptProcessor="C:\MOVEitTransfer\MOVEitISAPI\MOVEitISAPI.dll" requireAccess="Execute" />
</handlers>
[...]
This is well and good, but reading configuration files is hard and prone to missing stuff.
My recommendation, and personal approach, is to just copy the entire
application to a Linux system, then use grep
:
$ grep -Er 'Get(Extension|Filter)Version'
grep: MOVEitISAPI/MOVEitISAPI.dll: binary file matches
grep: MOVEitISAPI/MOVEitFilt.dll: binary file matches
Then work your way backwards to IIS to see how they’re served. Easy! :)
Reverse engineering ISAPI extensions
Let’s switch from generically talking about ISAPI modules to talking specifically about ISAPI Extensions - the type of module that serves a page directly, as opposed to the modules that filter requests. Filters will be similar, just different function names.
Microsoft provides an overview of ISAPI extensions here, which I’m going to use a bit.
Loading the .dll
ISAPI modules are shared libraries (ie, .dll files) that are loaded into the
address space of IIS. When any .dll file is loaded (ISAPI or otherwise), the
first function that’s called is always DllMain
:
BOOL WINAPI DllMain( HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
It’s the .dll equivalent to main()
in a typical C application, except that it’s
called multiple times in the .dll’s lifecycle. The fdwReason
argument
specifies why it’s being called:
DLL_PROCESS_DETACH
(0) - The .dll is being unloadedDLL_PROCESS_ATTACH
(1) - The .dll is being loadedDLL_THREAD_ATTACH
(2) - The process the .dll is loaded into is creating a new thread (all .dll files are alerted when this happens)DLL_THREAD_DETACH
(3) - A thread in the process is exiting cleanly
This isn’t anything special with ISAPI modules, and there’s a good chance that
the DllMain
function isn’t used at all - it simply has to return true
. If
it IS used, you’ll most likely see the DLL_PROCESS_ATTACH
and
DLL_PROCESS_DETACH
reasons being used to initialize and clean up.
GetExtensionVersion()
After the ISAPI module is loaded, IIS will call the GetExtensionVersion()
exported function. You can read about the function in Microsoft’s
documentation,
but the important part is the definition:
BOOL WINAPI GetExtensionVersion(HSE_VERSION_INFO* pVer);
HSE_VERSION_INFO
is a fairly simple structure with just two fields:
typedef struct _HSE_VERSION_INFO {
DWORD dwExtensionVersion;
CHAR lpszExtensionDesc[HSE_MAX_EXT_DLL_NAME_LEN]; // 256
} HSE_VERSION_INFO, *LPHSE_VERSION_INFO;
As far as I know, these are free-form values. I added a struct called
HSE_VERSION_INFO
into IDA Pro, and it already knew the structure:
00000000 HSE_VERSION_INFO struc ; (sizeof=0x104, align=0x4, copyof_483)
00000000 dwExtensionVersion dd ?
00000004 lpszExtensionDesc db 256 dup(?)
00000104 HSE_VERSION_INFO ends
And it let me decorate rcx
(the pVer
argument on a 64-bit machine) with the
proper field names (still using MOVEitISAPI.dll
as an example):
.text:000000018009DEB0 ; BOOL __stdcall GetExtensionVersion(HSE_VERSION_INFO *pVer)
.text:000000018009DEB0 public GetExtensionVersion
.text:000000018009DEB0 GetExtensionVersion proc near ; DATA XREF: .rdata:off_180AD21C8↓o
.text:000000018009DEB0 ; .pdata:0000000180BDE048↓o
.text:000000018009DEB0
.text:000000018009DEB0 var_18 = dword ptr -18h
.text:000000018009DEB0
.text:000000018009DEB0 sub rsp, 38h
.text:000000018009DEB4 mov [rcx+HSE_VERSION_INFO.dwExtensionVersion], 80000h
.text:000000018009DEBA movups xmm0, xmmword ptr cs:aMoveitisapiExt ; "MOVEitISAPI Extension"
.text:000000018009DEC1 movups xmmword ptr [rcx+HSE_VERSION_INFO.lpszExtensionDesc], xmm0
.text:000000018009DEC5 mov eax, dword ptr cs:aMoveitisapiExt+10h ; "nsion"
.text:000000018009DECB mov dword ptr [rcx+(HSE_VERSION_INFO.lpszExtensionDesc+10h)], eax
.text:000000018009DECE movzx eax, word ptr cs:aMoveitisapiExt+14h ; "n"
.text:000000018009DED5 mov word ptr [rcx+(HSE_VERSION_INFO.lpszExtensionDesc+14h)], ax
[...]
This function is also called exactly once in the ISAPI module lifecycle, which
means it can (and often IS) used to initialize variables. Keep an eye out in
both DllMain
and GetExtensionVersion
for initialized variables!
HttpExtensionProc()
The real meat of an ISAPI extension is HttpExtensionProc()
, which is executed
each time somebody accesses the extension. It’s where all the interesting stuff
is going to happen.
The definition of the function is:
DWORD WINAPI HttpExtensionProc(
LPEXTENSION_CONTROL_BLOCK lpECB
);
Once again, it takes exactly one argument, which is stored in rcx
(on a
64-bit host), or on top of the stack (on 32-bit). We’ll stick to 64-bit.
The argument is a pointer to a EXTENSION_CONTROL_BLOCK
structure, which has
the following definition:
typedef struct _EXTENSION_CONTROL_BLOCK EXTENSION_CONTROL_BLOCK {
DWORD cbSize;
DWORD dwVersion;
HCONN connID;
DWORD dwHttpStatusCode;
CHAR lpszLogData[HSE_LOG_BUFFER_LEN];
LPSTR lpszMethod;
LPSTR lpszQueryString;
LPSTR lpszPathInfo;
LPSTR lpszPathTranslated;
DWORD cbTotalBytes;
DWORD cbAvailable;
LPBYTE lpbData;
LPSTR lpszContentType;
BOOL (WINAPI * GetServerVariable) ();
BOOL (WINAPI * WriteClient) ();
BOOL (WINAPI * ReadClient) ();
BOOL (WINAPI * ServerSupportFunction) ();
} EXTENSION_CONTROL_BLOCK;
Once again, if you add a struct called EXTENSION_CONTROL_BLOCK
to IDA Pro, it’s
aware of the structure and size of all the fields:
00000000 EXTENSION_CONTROL_BLOCK struc ; (sizeof=0xC0, align=0x8, copyof_485)
00000000 cbSize dd ?
00000004 dwVersion dd ?
00000008 ConnID dq ? ; offset
00000010 dwHttpStatusCode dd ?
00000014 lpszLogData db 80 dup(?)
00000064 db ? ; undefined
00000065 db ? ; undefined
00000066 db ? ; undefined
00000067 db ? ; undefined
00000068 lpszMethod dq ? ; offset
00000070 lpszQueryString dq ? ; offset
00000078 lpszPathInfo dq ? ; offset
00000080 lpszPathTranslated dq ? ; offset
00000088 cbTotalBytes dd ?
0000008C cbAvailable dd ?
00000090 lpbData dq ? ; offset
00000098 lpszContentType dq ? ; offset
000000A0 GetServerVariable dq ? ; offset
000000A8 WriteClient dq ? ; offset
000000B0 ReadClient dq ? ; offset
000000B8 ServerSupportFunction dq ? ; offset
000000C0 EXTENSION_CONTROL_BLOCK ends
The most interesting fields are:
dwHttpStatusCode
- The HTTP status code that’ll be returned (you’ll see 0xc8 a lot, which is HTTP/200)lpszMethod
- Will beGET
orPOST
(or other methods), some modules will distinguish and others won’tlpszQueryString
- The HTTP query string (ie, what comes after the?
in the URL)lpszPathInfo
- What comes after the ISAPI module in the path (ie,https://example.org/isapimodule.dll/pathgoeshere
)cbTotalBytes
- The size of the HTTP body, if any (typically used in a POST)cbAvailable
- The number of bytes that have already been receivedlpbData
- A buffer of data that has already been received (more data might be queued up, if it’s longer)lpszContentType
- The request’s content-type
Additionally, four function pointers are passed in that structure:
GetServerVariable
- Used to retrieve information about the connection or serverWriteClient
- Send data to the clientReadClient
- Receive data from the clientServerSupportFunction
- Other stuff the the previous callbacks don’t do
Once you know the structure of the incoming data, you can identify a lot of what’s going on in the module; for example, this code:
.text:000000018009B98D mov r8d, 1000h
.text:000000018009B993 lea rdx, [rsp+15F38h+var_5838]
.text:000000018009B99B mov rcx, [r14+EXTENSION_CONTROL_BLOCK.lpszQueryString]
.text:000000018009B99F call sub_18006FF00
Appears to be copying the query string. We can all but confirm that in the next
line, which uses var_5838
:
.text:000000018009B9A4 mov r9d, 400h
.text:000000018009B9AA lea r8, [rsp+15F38h+ep_buffer] ; buffer
.text:000000018009B9B2 lea rdx, aEp ; "ep"
.text:000000018009B9B9 lea rcx, [rsp+15F38h+var_5838] ; querystring
.text:000000018009B9C1 call get_field_from_querystring_maybe
It passes what looks like the query string, and the literal string “ep”, to
another function. Without ever looking at that function, I named it
get_field_from_querystring_maybe
. That’s later confirmed with:
.text:000000018009BA7B lea r8, [rsp+15F38h+var_5838]
.text:000000018009BA83 lea rdx, aQueryStringS ; "Query string: %s"
.text:000000018009BA8A mov ecx, 3Ch
.text:000000018009BA8F call log_function_maybe
.text:000000018009BA94 mov r9d, 40h ; '@'
.text:000000018009BA9A lea r8, [rsp+15F38h+action_buffer]
.text:000000018009BAA2 lea rdx, parameter_name ; "action"
.text:000000018009BAA9 lea rcx, [rsp+15F38h+var_5838] ; <-- Query string
.text:000000018009BAB1 call get_field_from_querystring_maybe
We can also find the callback functions, like GetServerVariable
, being used
to read values from the environment:
.text:000000018009BABC mov [rsp+15F38h+length], 20h ; ' '
.text:000000018009BAC4 lea r9, [rsp+15F38h+length] ; lpdwSize
.text:000000018009BAC9 lea r8, [rsp+15F38h+remote_addr_buffer]
.text:000000018009BAD1 lea rdx, szVariableName ; "REMOTE_ADDR"
.text:000000018009BAD8 mov rcx, [r14+EXTENSION_CONTROL_BLOCK.ConnID] ; hConn
.text:000000018009BADC call [r14+EXTENSION_CONTROL_BLOCK.GetServerVariable]
From here, it’s a pretty typical Windows application, and can be reversed as such. That could be a good or bad thing, depending on your comfort level.. but one thing we CAN do is attach a debugger. Let’s see how!
Debugging
Thanks to the magic of “this being a normal .dll file”, we can debug this just like any program with a .dll file.
First, we need to figure out which process is actually serving that .dll. You
could look at configs and stuff, but that’s boring. You can bruteforce and
debug every w3wp.exe
process, and that’s what I normally do, but I actually
found a better way while writing this blog.. you can use tasklist /m <DLL>
to
check which processes have a specific .dll loaded:
C:\Users\Administrator>tasklist /m MOVEitISAPI.dll
Image Name PID Modules
========================= ======== ============================================
w3wp.exe 5248 MOVEitISAPI.dll
That’s kinda magic, and could have saved me SO much trouble in the past!
Anyways, once you know the PID (in this case, 5248), you can attach a debugger
such as windbg
. When you attach, you should see the ISAPI modules loaded into
memory as if they’re standard .dll files (because they are):
[...]
ModLoad: 00007ff8`8a240000 00007ff8`8a2bb000 C:\MOVEitTransfer\MOVEitISAPI\MOVEitFilt.dll
ModLoad: 00007ff8`69860000 00007ff8`6a4c7000 \\?\C:\MOVEitTransfer\MOVEitISAPI\MOVEitISAPI.dll
[...]
Due to ASLR, the addresses probably won’t match the addresses you see in other tools, but that’s a starting point!
You can use the x
command to get a list of the exported addresses:
0:012> x MOVEitISAPI!*
00007ff8`698fde80 MOVEitISAPI!GetExtensionVersion (<no parameter info>)
00007ff8`698fdfb0 MOVEitISAPI!HttpExtensionProc (<no parameter info>)
00007ff8`698fdfc0 MOVEitISAPI!TerminateExtension (<no parameter info>)
You can also put a breakpoint on the HttpExtensionProc
function (be sure to
pass in the module name, since there will be multiple ISAPI modules with the
same names):
0:012> bp MOVEitISAPI!HttpExtensionProc
0:012> bl
0 e Disable Clear 00007ff8`698fdfb0 0001 (0001) 0:**** MOVEitISAPI!HttpExtensionProc
Then send some request:
$ curl -ik 'https://10.0.0.193/moveitisapi/moveitisapi.dll' --data 'This is my postdata'
And observe in the debugger, using the field offsets we saw earlier (I imagine
you can use dx
to dump the whole object if you load the definition into
windbg, which I don’t know how to do):
Breakpoint 0 hit
MOVEitISAPI!HttpExtensionProc:
00007ff8`698fdfb0 e96bd8ffff jmp MOVEitISAPI+0x9b820 (00007ff8`698fb820)
0:005> ds rcx+0x70
000001c8`64b69f78 "/moveitisapi/moveitisapi.dll"
0:005> ds rcx+0x78
000001c8`64b69f98 "C:\MOVEitTransfer\MOVEitISAPI\moveitisapi.dll"
0:005> ds rcx+0x90
000001c8`64b68346 "application/x-www-form-urlencoded"
0:005> ds rcx+0x88
000001c8`64b69f60 "This is my postdata"
From there, you can use break-on-access (ba
) and other stuff to track the
data, if desired! Whatever you want to do!
Comments
Join the conversation on this Mastodon post (replies will appear below)!
Loading comments...