Selective Privilege Escalation via Assembly References
In an ideal world, SQLCLR module permissions could be made to work like T-SQL module permissions :
outer modules would be granted the least possible privileges, but would
be able to selectively and temporarily escalate their privileges in
order to do certain operations that require more access. This would
lessen the privileged surface area significantly, which would mean that
there would be less need to do a stringent security review on outer
(less-privileged) module layers, which undoubtedly constitute the
majority of code written for a given system—the engine would make sure
they behave.
The general solution to
this problem is to split up code into separate assemblies based on
permissions requirements, but to not do so without regard for both
maintenance overhead and reuse. For example, consider the 5,000-line
module, mentioned in the previous section, that needs to read a few
lines from a text file. The entire module could be granted a high enough
level of privileges to read the file, or the code to read the file
could be taken out and placed into its own assembly. This external
assembly would expose a method that takes a filename as input and
returns a collection of lines. As I'll show in the following sections,
this solution would let you catalog the bulk of the code as SAFE
yet still do the file I/O operation. Plus, future modules that need to
read lines from text files could reference the same assembly, and
therefore not have to reimplement this logic.
The encapsulation story
is, alas, not quite as straightforward as creating a new assembly with
the necessary logic and referencing it. Due to the different behavior of
CAS and HPA exceptions, you might have to perform some analysis of what
the code is doing in order to properly encapsulate the permissions in
the inner modules. In the following sections, I'll cover each of the
permission types separately in order to illustrate how to design a
solution.
Working with Host Protection Privileges
A fairly common SQLCLR pattern is to create static
collections that can be shared among callers. However, as with any
shared data set, proper synchronization is essential in case you need to
update some of the data after its initial load. From a SQLCLR
standpoint, this gets dicey due to the fact that threading and
synchronization require UNSAFE access—granting such an open level of permission is not something to be taken lightly.
For an example of a scenario
that might make use of a static collection, consider a SQLCLR UDF used
to do currency conversions based on exchange rates:
[SqlFunction]
public static SqlDecimal GetConvertedAmount(
SqlDecimal InputAmount,
SqlString InCurrency,
SqlString OutCurrency)
{
//Convert the input amount to the base
decimal BaseAmount =
GetRate(InCurrency.Value) *
InputAmount.Value;
//Return the converted base amount
return (new SqlDecimal(
GetRate(OutCurrency.Value) * BaseAmount));
}
The GetConvertedAmount method internally makes use of another method, GetRate:
private static decimal GetRate(string Currency)
{
decimal theRate;
rwl.AcquireReaderLock(100);
try
{
theRate = rates[Currency];
}
finally
{
rwl.ReleaseLock();
}
return (theRate);
}
GetRate performs a lookup in a static generic instance of Dictionary<string, decimal>, called rates.
This collection contains exchange rates for the given currencies in the
system. In order to protect against problems that will occur if another
thread happens to be updating the rates, synchronization is handled
using a static instance of ReaderWriterLock, called rwl. Both the dictionary and the ReaderWriterLock are instantiated when a method on the class is first called, and both are marked readonly in order to avoid being overwritten after instantiation:
static readonly Dictionary<string, decimal>
rates = new Dictionary<string, decimal>();
static readonly ReaderWriterLock
rwl = new ReaderWriterLock();
If cataloged using either the SAFE or EXTERNAL_ACCESS permission sets, this code fails due to its use of synchronization (the ReaderWriterLock), and running it produces a HostProtectionException. The solution is to move the affected code into its own assembly, cataloged as UNSAFE.
Because the host protection check is evaluated at the moment of
just-in-time compilation of a method in an assembly, rather than
dynamically as the method is running, the check is done as the assembly
boundary is being crossed. This means that an outer method can be marked
SAFE and temporarily escalate its permissions by calling into an UNSAFE core.
NOTE
You might be wondering
about the validity of this example, given the ease with which this
system could be implemented in pure T-SQL, which would eliminate the
permissions problem outright. I do feel that this is a realistic
example, especially if the system needs to do a large number of currency
translations on any given day. SQLCLR code will outperform T-SQL for
even simple mathematical work, and caching the data in a shared
collection rather than reading it from the database on every call is a
huge efficiency win. I'm confident that this solution would easily
outperform any pure T-SQL equivalent.
When designing the UNSAFE
assembly, it is important from a reuse point of view to carefully
analyze what functionality should be made available. In this case, use
of the dictionary isn't causing the problem. Synchronization via the ReaderWriterLock is throwing the actual exception. However, wrapping methods around a ReaderWriterLock would probably not promote very much reuse. A better tactic, in my opinion, is to wrap the Dictionary and the ReaderWriterLock together, creating a new ThreadSafeDictionary class. This class could be used in any scenario in which a shared data cache is required.
Following is my implementation of the ThreadSafeDictionary; I have not implemented all of the methods that the generic dictionary exposes, but rather only those I commonly use:
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
namespace SafeDictionary
{
public class ThreadSafeDictionary<K, V>
{
private readonly Dictionary<K, V> dict = new Dictionary<K,V>();
private readonly ReaderWriterLock theLock = new ReaderWriterLock();
public void Add(K key, V value)
{
theLock.AcquireWriterLock(2000);
try
{
dict.Add(key, value);
}
finally
{
theLock.ReleaseLock();
}
}
public V this[K key]
{
get
{
theLock.AcquireReaderLock(2000);
try
{
return (this.dict[key]);
}
finally
{
theLock.ReleaseLock();
}
}
set
{
theLock.AcquireWriterLock(2000);
try
{
dict[key] = value;
}
finally
{
theLock.ReleaseLock();
}
}
}
public bool Remove(K key)
{
theLock.AcquireWriterLock(2000);
try
{
return (dict.Remove(key));
}
finally
{
theLock.ReleaseLock();
}
}
public bool ContainsKey(K key)
{
theLock.AcquireReaderLock(2000);
try
{
return (dict.ContainsKey(key));
}
finally
{
theLock.ReleaseLock();
}
}
}
}
This class should be placed into a new assembly, which should then be compiled and cataloged in SQL Server as UNSAFE. A reference to the UNSAFE
assembly should be used in the exchange rates conversion assembly,
after which a few lines of the example code will have to change. First
of all, the only static object that must be created is an instance of ThreadSafeDictionary:
static readonly ThreadSafeDictionary<string, decimal> rates =
new ThreadSafeDictionary<string, decimal>();
Since the ThreadSafeDictionary is already thread safe, the GetRate method no longer needs to be concerned with synchronization. Without this requirement, its code becomes greatly simplified:
private static decimal GetRate(string Currency)
{
return (rates[Currency]);
}
The exchange rates conversion assembly can still be marked SAFE, and can now make use of the encapsulated synchronization code without throwing a HostProtectionException.
And none of the code actually contained in the assembly will be able to
use resources that violate the permissions allowed by the SAFE bucket—quite an improvement over the initial implementation, from a security perspective.
NOTE
Depending on whether your database has the TRUSTWORTHY option enabled and whether your assemblies are strong named, things may not be quite
as simple as I've implied here. The examples in both this and the next
section may fail either at deployment time if your core assembly doesn't
have the correct permissions or at run time if you've decided to go
with a strong named assembly.
Working with Code Access Security Privileges
HPA-protected resources
are quite easy to encapsulate, thanks to the fact that permissions for a
given method are checked when the method is just-in-time compiled.
Alas, things are not quite so simple when working with CAS-protected
resources, due to the fact that grants are checked dynamically at run
time via a stack walk. This means that simply referencing a second
assembly is not enough—the entire stack is walked each time, without
regard to assembly boundaries.
To illustrate this
issue, create a new assembly containing the following method, which
reads all of the lines from a text file and returns them as a collection
of strings:
public static string[] ReadFileLines(string FilePath)
{
List<string> theLines = new List<string>();
using (System.IO.StreamReader sr =
new System.IO.StreamReader(FilePath))
{
string line;
while ((line = sr.ReadLine()) != null)
theLines.Add(line);
}
return (theLines.ToArray());
}
Catalog the assembly in SQL Server with the EXTERNAL_ACCESS permission set, and reference it from the assembly that contains the CAS_Exception stored procedure created in the section "SQLCLR Security and Reliability Features." Modify that stored procedure as follows:
[SqlProcedure]
public static void CAS_Exception()
{
SqlContext.Pipe.Send("Starting...");
string[] theLines =
FileLines.ReadFileLines(@"C:\b.txt");
SqlContext.Pipe.Send("Finished...");
return;
}
Note that I created my ReadFileLines method inside a class called FileLines;
reference yours appropriately depending on what class name you used.
Once you've finished the modifications, redeploy the outer assembly,
making sure that it is cataloged as SAFE.
Running the
modified version of this stored procedure, you'll find that even though
an assembly boundary is crossed, you will receive the same exception as
before. The CAS grant did not change simply because a higher privileged
assembly was referenced, due to the fact that the stack walk does not
take into account permissions held by referenced assemblies.
Working around this
issue requires taking control of the stack walk within the referenced
assembly. Due to the fact that the assembly has enough privilege to do
file operations, it can internally demand that the stack walk
discontinue checks for file I/O permissions, even when called from
another assembly that does not have the requisite permissions. This is
done by using the Assert method of the IStackWalk interface, exposed in .NET's System.Security namespace.
Taking a second look at the CAS violation shown previously, note that the required permission is FileIOPermission, which is in the System.Security.PermissionsFileIOPermission class—in addition to other "permission" classes in that namespace—implements the IStackWalk interface. To avoid the CAS exception, simply instantiate an instance of the FileIOPermission class and call the Assert method. The following code is a modified version of the ReadFileLines namespace. The method that uses this technique:
public static string[] ReadFileLines(string FilePath)
{
//Assert that anything File IO-related that this
//assembly has permission to do, callers can do
FileIOPermission fp = new FileIOPermission(
PermissionState.Unrestricted);
fp.Assert();
List<string> theLines = new List<string>();
using (System.IO.StreamReader sr =
new System.IO.StreamReader(FilePath))
{
string line;
while ((line = sr.ReadLine()) != null)
theLines.Add(line);
}
return (theLines.ToArray());
}
This version of the method instantiates the FileIOPermission class with the PermissionState.Unrestricted
enumeration, thereby enabling all callers to do whatever file
I/O-related activities that the assembly has permission to do. The use
of the term "unrestricted" in this context is not as dangerous as it
sounds; the access is unrestricted in the sense that permission is
allowed for only as much access as the assembly already has to the file
system. After making the modifications shown here and redeploying both
assemblies, the CAS exception will no longer be an issue.
To allow you to control things on a more granular level, the FileIOPermission
class exposes other constructor overloads with different options. The
most useful of these for this example uses an enumeration called FileIOPermissionAccess
in conjunction with the path to a file, allowing you to limit the
permissions the caller has to only specific operations on a specific
file. For instance, to limit access so that the caller can only read the
file specified in the input path, use the following constructor:
FileIOPermission fp = new FileIOPermission(
FileIOPermissionAccess.Read,
FilePath);
File I/O is only one of
many kinds of permissions for which you might see a CAS exception. The
important thing is being able to identify the pattern. In all cases,
violations will throw a SecurityException and reference a permission class in the System.Security.Permissions
namespace. Each class follows the same basic pattern outlined here, so
you should be able to easily use this technique in order to design any
number of privilege escalation solutions.