In early January 2015, researcher Michael Heerklotz approached the Zero Day Initiative with details of a vulnerability in the Microsoft Windows operating system. We track this issue as ZDI-15-086. Unless otherwise noted, the technical details in this blog post are based on his detailed research.
To understand the significance of his report, we need to go back to the last decade.
In mid-2009, Stuxnet was released against the Iranian nuclear program. Attributed to the United States and Israel, Stuxnet used multiple zero-day attacks against Windows to attack the Iranian centrifuges. It was discovered in June 2010 by VirusBlokAda and reported to Microsoft. In February 2015, Kaspersky Labs' Global Research & Analysis Team released findings that attacks included in Stuxnet were in use as early as 2008.
The initial infection vector was a USB drive that took advantage of a vulnerability in the Windows operating system that allowed simply browsing to a directory to run arbitrary code. Windows allowed for .LNK files, which define shortcuts to other files or directories, to use custom icons from .CPL (Control Panel) files. The problem is that in Windows, icons are loaded from modules (either executables or dynamic link-libraries). In fact, .CPL files are actually DLLs. Because an attacker could define which executable module would be loaded, an attacker could use the .LNK file to execute arbitrary code inside of the Windows shell and do anything the current user could.
To prevent this attack, Microsoft put in an explicit whitelist check with MS10-046, released in early August 2010. Once that patch was applied, in theory only approved .CPL files should have been able to be used to load non-standard icons for links.
The patch failed. And for more than four years, all Windows systems have been vulnerable to exactly the same attack that Stuxnet used for initial deployment.
To see how it failed, we need to examine the fix itself. To show the vulnerability in action, we made a brief video:
The definition of the icon that will be used is extracted in a function called CControlPanelFolder::GetUiObjectOf() in Shell32.dll. We can see what changed by comparing the RTM version of Shell32.dll with the latest vulnerable version, using DarunGrim.
Figure 1 Diffing the function
We can see there are only two sections of code (highlighted above in red) that have changed in this function since release.
The first changed block looks like this:
Figure 2 The first changed block in the function (click to expand in new window)
We can see that in the event the definition calls for a custom icon (that is, has a requested icon ID of 0), we check against the registered list. If we put this snippet of assembly into C++, it looks something like this:
If the DLL isn’t on the whitelist, you cannot have the icon ID be 0, and so no custom load step. So, problem solved? Clearly not, or we wouldn’t be talking about this now. Let’s look at the other snippet of code that changed, and see if it gives us a clue.
Figure 3 The second changed block in the function
Now that’s interesting. If the module path specified contains a comma in it, we’re going to error out with an invalid argument.
It is possible that this is unrelated to the fix for Stuxnet, but it looks odd. Let’s look at the context around it.
Immediately before this block of code, there is an unchanged block that takes user-provided data and formats it -- using commas. Let’s take a look at that:
Figure 4 Unchanged adjacent code
If we put this block of assembly language into C++, it would look something like this:
With this context, the second change looks to be part of the Stuxnet fix after all. We are forcing the icon ID to be something other than 0, but we then put it into a comma delimited string. Since we’re then erroring out if the path contains a comma, that looks like a fix for embedding a fake icon ID inside of the path, which would imply that the icon ID will be parsed out of this constructed string later.
So, the obvious work-around has been closed off; we cannot spoof the formatted string to insert our own icon ID.
The next thing that happens after we have formatted and checked this string is that it gets passed to ControlExtractIcon_CreateInstance(). This function creates a CCtrlExtIconBase object, and passes it the composite string as the first argument. Let’s look at the constructor.
Figure 5 Following the string through the constructor (click to expand in new window)
If we look at what happens to that initial argument, we see it ends up (again, translating into C++) being used like this:
The buffer we have just created as 554 wide characters in length is in fact being truncated and put into a 260 wide character buffer. Not only that, but the string contains two pieces of information we know get used in icon loading – the path to the DLL and the icon ID.
Where does that information come from? It comes from a function called CControlPanelFolder::GetModuleMapped():
Figure 6 Call to CControlPanelFolder::GetModuleMapped (click to expand in new window)
If we put this into C++, it would look something like this:
There are two parts of this function that are important for us. As we can see from the code above, the caller specifies the size of the buffers that data is copied in to, and in this case, the buffers are sized for 260 wide characters. Because this data is actually extracted from the .LNK file that we control, this means we can provide a path string that is up to 260 wide characters long, and we know that there is a truncation bug that will use our data.
The second issue is actually inside of CControlPanelFolder::GetModuleMapped(), and will be one of the last hurdles to exploitation. If the module path specified does not actually exist, the path will be combined with the System (or SystemWoW64) directory. Looking at that code as C++, it looks something like this:
if ( !PathFileExistsW(pwzModuleFullPath) )
if ( fDoNotUseWoW || !CControlPanelFolder::_IsWowCPL(pControl) )
if ( PathCombineW(&wzBuffer, &wzSystemDir, pwzModuleFileName) )
This doesn’t appear to be a problem (since we do need to actually load our planted DLL to get code running), but as we’ll see later, this is actually an issue in exploitation. To see why, we need to look at where our constructed and truncated string is used.
What happens to that data? To see that, let’s look at what the actual call stack would look like when the exploit fired:
Figure 7 Call stack on the DLL load for an icon
Since we know that our constructed string is stored as a member variable in CCtrlExtIconBase, let’s go ahead and look at that call to _GetIconLocationW().
Figure 8 Parsing the constructed string in CCtrlExtIconBase ::_GetIconLocationW
If we look at the code above, we can see that we’re searching for the comma separator (the buffer itself is one we’ve copied for the caller). If we find it, we null it out, and then derive the icon ID by calling StrToInt(). Now, we know from looking at the original fix that our icon ID will be forced to be -1, but will then be truncated into a 260 wide character buffer. Since the truncation includes the null, we’ll have 259 wide characters to work with, one of which will be a comma. If we provided a 257 character path, the string that we’d parse here is “<our path>,-“, with everything after the minus sign being truncated.
And StrToIntW(L”-“) is 0.
We have bypassed the check by converting the negative value back into our desired icon ID of 0. (In fact, we can skip the check entirely and just pass in a small negative icon ID to begin with.)
Just putting in the overly long path won’t work, however; there is a problem. To see it, we need to go further down the call chain and see where our load fails. We know from the stack trace above that our call to LoadLibrary() will come from CPL_LoadCPLModule(). The problem is that CPL_LoadCPLModule() is also going to look for a manifest file. That, in and of itself, is not a problem, as it doesn’t require the manifest. The problem lies in how it looks for the file:
Figure 9 Constructing the manifest file path
If we put this into C++, it would look something like this:
if ( StringCchPrintfW(&wzManifestPath, 260u, L"%s.manifest",
pwzModuleFullPath) < 0 )
So, if our path is too long to have a “.manifest” appended (the 260 character limit we’ve been seeing throughout this is MAX_PATH), we’re not even going to try to load the DLL. As we’ve already seen, we need to take the path to 257 characters in order to force the icon ID to 0, and we need the icon ID to be 0 to even get to CPL_LoadCPLModule().
We need one more issue. To find it, we need to work back up the stack trace, and see if we can do anything about that path name passed to CPL_LoadCPLModule(). When we do that, we can see that the string is actually extracted in the function CPL_ParseCommandLine().
CPL_ParseCommandLine uses a function called CPL_ParseToSeparator() to pull the component elements out. If we look inside CPL_ParseToSeparator(), we can see that it has two options for valid separators:
Figure 10 A look inside CPL_ParseToSeparator
There is a flag which determines if only commas will be considered to be separators, or if unescaped spaces will as well. When we look at the first call to CPL_ParseToSeparator() (which extracts the module path), we can see that it has the flag set to consider spaces as valid separators:
Figure 11 Initial call to CPL_ParseSeparator
At this point, we have everything we need to get an exploit running. We’ll need to construct a malicious .LNK file which has a link path of exactly 257 characters, but uses embedded unescaped spaces to cause the extraction to truncate in CPL_ParseToSeparator(). That allows us to have a short enough path for the concatenation of the “.manifest” to the filename in CPL_LoadCPLModule() to work.
That brings us back to our earlier note that CControlPanelFolder::GetModuleMapped() will check to see if the full module path (including embedded spaces) exists. So we’ll need to have two files, one with the embedded spaces (to pass the file existence check), and one without (to actually be loaded).
Unlike a case of memory corruption, this attack doesn’t need to worry about low-level operating system mitigations. This bug has its roots in the decades-old decision to load icons by loading executable modules into the process, and because of that, there is no need to worry about any other mitigations. The Windows operating system itself will handle resolving ASLR and loading the attack into executable memory. And because of that, the attack is stable, reliable, and works cleanly across Windows versions. Microsoft has gone to a great deal of effort to make exploitation of memory corruption bugs more difficult. This is a classic example of the Defender’s Dilemma -- the defender must be strong everywhere, while the attacker needs to find only one mistake.
In a future Security Briefing, ZDI will examine MS15-020, the patch that was released today to address CVE-2015-0096, and look at how Microsoft made changes to try to prevent this attack from coming back a third time.