Mark-of-the-Web: Additional Guidance

I’ve been writing about the Mark-of-the-Web (MotW) security primitive in Windows for decades now, with 2016’s Downloads and MoTW being one of my longer posts that I’ve updated intermittently over the last few years. If you haven’t read that post already, you should start there.

Advice for Implementers

At this point, MotW is old enough to vote and almost old enough to drink, yet understanding of the feature remains patchy across the Windows developer ecosystem.

MotW, like most security primitives (e.g. HTTPS) only works if you use it. Specifically, an application which generates local files from untrusted data (i.e. anywhere on “The Internet”) must ensure that the files bear a MoTW to ensure that the Windows Shell and other applications recognize the files’ origins and treat them with appropriate caution. Such treatment might include running anti-malware checks, prompting the user before running unsafe executables, or opening the files in Office’s Protected View.

Similarly, if you build an application which consumes files, you should carefully consider whether files from untrusted origins should be treated with extra caution in the same way that Microsoft’s key applications behave — locking down or prompting users for permission before the file initiates any potentially-unwanted actions, more-tightly sandboxing parsers, etc.

Writing MotW

The best way to write a Mark-of-the-Web to a file is to let Windows do it for you, using the IAttachmentExecute::Save() API. Using the Attachment Execution Services API ensures that the MotW is written (or not) based on the client’s configuration. Using the API also provides future-proofing for changes to the MotW format (e.g. Win10 started preserving the original URL information rather than just the ZoneID).

If the URL is not known, but you wish to ensure Internet Zone handling, use the special url about:internet.

You should also use about:internet if the URL is longer than 2083 characters (INTERNET_MAX_URL_LENGTH), or if the URL’s scheme isn’t one of HTTP/HTTPS/FILE.

Ensure that you write the MotW to any untrusted file written to disk, regardless of how that happened. For example, one mail client would properly write MotW when the user used the “Save” command on an attachment, but failed to do so if the user drag/dropped the attachment to their desktop. Similarly, browsers have written MotW to “downloads” for decades, but needed to add similar marking when the File Access API was introduced.

Take care with anything that would prevent proper writing of the MotW– for example, if you build a decompression utility for ZIP files, ensure that you write the MotW before your utility applies any readonly bit to the newly extracted file, otherwise the tagging will fail.

Beware Race Conditions

In certain (rare) scenarios, there’s the risk of a race condition whereby a client could consume a file before your code has had the chance to tag it with the Mark-of-the-Web, resulting in a security vulnerability. For instance, consider the case where your app (1) downloads a file from the internet, (2) streams the bytes to disk, (3) closes the file, finally (4) calls IAttachmentExecute::Save() to let the system tag the file with the MotW. If an attacker can induce the handler for the new file to load it between steps #3 and #4, the file could be loaded by a victim application before the MotW is applied.

Unfortunately, there’s not generally a great way to prevent this — for example, the Save() call can perform operations that depend on the file’s name and content (e.g. an antivirus scan) so we can’t simply call the API against an empty file or against a bogus temporary filename (i.e. inprogress.temp).

The best approach I can think of is to avoid exposing the file in a predictable location until the MotW marking is complete. For example, you could download the file into a randomly-named temporary folder (e.g. %TEMP%\InProgress\{guid}\setup.exe), call the Save() method on that file, then move the file to the predictable location.

Note: This approach (extracting to a randomly-named temporary folder, carefully named to avoid 8.3 filename collisions that would reduce entropy) is now used by Windows 10+’s ZIP extraction code.

Respecting MotW

To check the Zone for a URL, use the MapUrlToZone (sometimes called MUTZ) function in URLMon.dll. Because the MotW is typically stored as a simple key-value pair within a NTFS alternate data stream:

…it’s tempting to think “My code can just read the ZoneId directly.”

Unfortunately, doing so is a recipe for failure.

Firstly, consider the simple corner cases you might miss. For instance, if you try to open with read/write permissions the Zone.Identifier stream of a file whose readonly bit is set, the attempt to open the stream will fail because the file isn’t writable.

Aside: A 2023-era vulnerability in Windows was caused by failure to open the Zone.Identifier due to an unnecessary demand for write permission.

Second, there’s a ton of subtlety in performing a proper zone mapping.

2a: For example, files stored under certain paths or with certain Integrity Levels are treated as Internet Zone, even without a Zone.Identifier stream:

2b: Similarly, files accessed via a \\UNC share are implicitly not in the Local Machine Zone, even if they don’t have a Zone.Identifier stream.

Aside: The February 2024 security patch (CVE-2024-21412) fixed a vulnerability where the handler for InternetShortcut (.url) files was directly checking for a Zone.Identifier stream rather than calling MUTZ. That meant the handler failed to recognize that file://attacker_smbshare/attack.url should be treated with suspicion and suppressed an important security prompt.

2c: As of the latest Windows 11 updates, if you zone-map a file contained within a virtual disk (e.g. a .iso file), that file will inherit the MotW of the containing .iso file, even though the embedded file has no Zone.Identifier stream.

2d: For HTML files, a special saved from url comment allows specification of the original url of the HTML content. When MapUrlToZone is called on a HTML file URL, the start of the file is scanned for this comment, and if found, the stored URL is used for Zone Mapping:

Finally, the contents of the Zone.Identifier stream are subject to change in the future. New key/value fields were added in Windows 10, and the format could be changed again in the future.

MutZ Performance

One important consideration when calling MapUrlToZone() is that it is a blocking API which can take from milliseconds (common case) to tens of seconds (worst case) to complete. As such, you should NOT call this API on a UI thread– instead, call it from a background thread and asynchronously report the result up to the UI thread.

It’s natural to wonder how it’s possible that this API takes so long to complete in the worst case. While file system performance is unpredictable, even under load it rarely takes more than a few milliseconds, so checking the Zone.Identifier is not the root cause of slow performance. Instead, the worst performance comes when the system configuration enables the Local Intranet Zone, with the option to map to the Intranet Zone any site that bypasses the proxy server:

In this configuration, URLMon may need to discover a proxy configuration script (potentially taking seconds), download that script (potentially taking seconds), and run the FindProxyForURL function inside the script. That function may perform a number of expensive operations (including DNS resolutions), potentially taking seconds.

Fortunately, the “worst case” performance is not common after Windows 7 (the WinHTTP Proxy Service means that typically much of this work has already been done), but applications should still take care to avoid calling MapUrlToZone() on a UI thread, lest an annoyed user conclude that your application has hung and kill it.

Comparing Zone Ids

In most cases, you’ll want to use < and > comparisons rather than exact Zone comparisons; for example, when treating content as “trustworthy”, you’ll typically want to check Zone<3, and when deeming content risky, you’ll check Zone>3.

Tool: Simple MapUrlToZone caller

Compile from a Visual Studio command prompt using csc mutz.cs:

using System;
using System.IO;
using System.Runtime.InteropServices;
namespace MUTZ
{
[ComImport, GuidAttribute("79EAC9EE-BAF9-11CE-8C82-00AA004BA90B")]
[InterfaceTypeAttribute(ComInterfaceType.InterfaceIsIUnknown)]
public interface IInternetSecurityManager
{
[return: MarshalAs(UnmanagedType.I4)][PreserveSig]
int SetSecuritySite([In] IntPtr pSite);
[return: MarshalAs(UnmanagedType.I4)][PreserveSig]
int GetSecuritySite([Out] IntPtr pSite);
[return: MarshalAs(UnmanagedType.I4)][PreserveSig]
int MapUrlToZone([In,MarshalAs(UnmanagedType.LPWStr)] string pwszUrl,
ref UInt32 pdwZone, UInt32 dwFlags);
[return: MarshalAs(UnmanagedType.I4)][PreserveSig]
int GetSecurityId([MarshalAs(UnmanagedType.LPWStr)] string pwszUrl,
[MarshalAs(UnmanagedType.LPArray)] byte[] pbSecurityId,
ref UInt32 pcbSecurityId, uint dwReserved);
[return: MarshalAs(UnmanagedType.I4)][PreserveSig]
int ProcessUrlAction([In,MarshalAs(UnmanagedType.LPWStr)] string pwszUrl,
UInt32 dwAction, out byte pPolicy, UInt32 cbPolicy,
byte pContext, UInt32 cbContext, UInt32 dwFlags,
UInt32 dwReserved);
[return: MarshalAs(UnmanagedType.I4)][PreserveSig]
int QueryCustomPolicy([In,MarshalAs(UnmanagedType.LPWStr)] string pwszUrl,
ref Guid guidKey, ref byte ppPolicy, ref UInt32 pcbPolicy,
ref byte pContext, UInt32 cbContext, UInt32 dwReserved);
[return: MarshalAs(UnmanagedType.I4)][PreserveSig]
int SetZoneMapping(UInt32 dwZone,
[In,MarshalAs(UnmanagedType.LPWStr)] string lpszPattern,
UInt32 dwFlags);
[return: MarshalAs(UnmanagedType.I4)][PreserveSig]
int GetZoneMappings(UInt32 dwZone, out System.Runtime.InteropServices.ComTypes.IEnumString ppenumString,
UInt32 dwFlags);
}
public class MUTZ
{
private readonly static Guid CLSID_SecurityManager = new Guid("7b8a2d94-0ac9-11d1-896c-00c04fb6bfc4");
public static int Main(string[] args)
{
UInt32 iZone=0;
string sURL = "https://example.com/&quot;;
if (args.Length > 0)
{
sURL = args[0];
}
else
{
Console.WriteLine("Usage: mutz.exe https://host/path?query#fragment\n\n");
}
Type t = Type.GetTypeFromCLSID(CLSID_SecurityManager);
object securityManager = Activator.CreateInstance(t);
IInternetSecurityManager ISM = securityManager as IInternetSecurityManager;
ISM.MapUrlToZone(sURL, ref iZone, 0); // TODO: Allow specification of flags https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/dd759042(v=vs.85)
Marshal.ReleaseComObject(securityManager);
string sZone;
switch (iZone)
{
case 0: sZone = "LocalMachine"; break;
case 1: sZone = "LocalIntranet"; break;
case 2: sZone = "Trusted"; break;
case 3: sZone = "Internet"; break;
case 4: sZone = "Restricted"; break;
default: sZone = "~custom~"; break;
}
Console.WriteLine($"URL: {sURL}");
Console.WriteLine($"Zone: {iZone} ({sZone})");
Uri uri;
if (Uri.TryCreate(sURL, UriKind.Absolute, out uri)) {
if (uri.IsFile) {
string strPath = uri.LocalPath;
Console.WriteLine($"Filesystem Path: {strPath}");
Console.WriteLine($"IsUnc: {uri.IsUnc}");
if (uri.IsUnc) {
// 0x00000400 – MUTZ require saved file check
}
/*
// It would be nice if this worked, but it doesn't because .NET Framework doesn't support opening the alternate stream.
// See https://stackoverflow.com/questions/604960/how-to-read-and-modify-ntfs-alternate-data-streams-using-net
try {
string strMotW = File.ReadAllText($"{strPath}:Zone.Identifier");
Console.WriteLine(":ZoneIdentifier\n{strMotW}\n————————————-\n\n");
} catch (Exception eX) {
Console.WriteLine($"ZoneIdentifier stream could not be read ({eX.Message})");
}
*/
}
}
return (int)iZone;
}
}
}
view raw mutz.cs hosted with ❤ by GitHub

Published by ericlaw

Impatient optimist. Dad. Author/speaker. Created Fiddler & SlickRun. PM @ Microsoft 2001-2012, and 2018-, working on Office, IE, and Edge. Now a GPM for Microsoft Defender. My words are my own, I do not speak for any other entity.

Leave a comment