Updated: March 2014
Preamble
This workbench shows, as simply as possible, how to tighten security a great deal in a SQL Server database by implementing a schema-based security system. SQL Server Security is sometimes rather a blind spot tapplication developers. This has been widely illustrated by the success of very simple attacks on database-driven websites, which would have been prevented by even moderate security measures.
In this workbench we will show how to do simple schema-based security and show how it can, to a great extent, prevent SQL Injection. We’ll also show how, with careless code within a stored procedure, you can still accidentally introduce a vulnerability.
It is impossible, purely from the database layer of an application alone, to prevent SQL injection attacks happening, but you can, with care, render them harmless. If you are accustomed to give your application logins DBO rights in your database, then it is time to tighten security.
We’ll illustrate schema-based security by creating a simple database, creating an example of all the common database objects
Preventing any damage through SQL Injection is a relatively simple matter. You should prevent any direct access to the base tables by the application. All database access should be through a set of stored procedures, views and functions. This can make things trickier for programmers in the short-term, but the result will be a secure database, and one that is easier to refactor. The neatest way of doing this is by Schema-based security. In previous versions of SQL Server, it had to be done by assigning permissions to the application-interface explicitly, but now you can do this just by placing the routines (aka Modules) that make up the application interface into a schema that is made for the purpose.
Firstly create a database called SecurityWorkbench
for the workbench just to try things out. Then…
1 |
use SecurityWorkbench; |
For this part of the exercise, log in as database owner and execute the following section. This builds a couple of base tables and puts some pretty sensitive information in one of them that you wouldn’t want anyone but the sysadmin to see. Then we’ll create various modules that will each access the data and extract just safe parts of it. We’ll create a chain to show ownership chaining. We’ll also show how to use dynamic SQL safely, and how to do it recklessly and stupidly in a way that allows access to the base tables even when you have no direct permissions to SELECT
from it.
Building the database
Delete the table we create if it exists, so we can do it over and again.
1 2 3 4 |
if exists (Select * from information_schema.tables where TABLE_NAME like 'Customer' and TABLE_SCHEMA= 'dbo') drop table Customer; |
The customer
table in the dbo
schema. In reality, we probably wouldn’t do this, of course but it serves for a demo.
1 2 3 4 5 6 7 8 9 10 11 12 |
CREATE TABLE dbo.Customer( Customer_ID int IDENTITY NOT NULL PRIMARY KEY, Firstname varchar(50) NULL, Surname varchar(50) NOT NULL, Password varchar(50) NULL, [User_ID] varchar(20) NOT NULL, CreditCardNo char(16) NULL, SortCode varchar(20) NULL, AccountNo varchar(20) NULL, InsertionDate datetime NOT NULL default GETDATE() ) ON [PRIMARY]; go |
And we’ll now add some data to it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
--pop some spoof data into the table.... INSERT INTO dbo.Customer (Firstname, Surname, USER_ID, Password, Creditcardno, SortCode, AccountNo) SELECT 'Joe', 'McTavish','Foo','plasticShoe', '7666923165777980','23-45-67','040592739'; INSERT INTO Customer (Firstname, Surname, USER_ID, Password, Creditcardno, SortCode, AccountNo) SELECT 'Lars', 'Porsenna','Abe','ninegods', '5960711184930897','76-54-23','014354678'; INSERT INTO Customer (Firstname, Surname, USER_ID, Password, Creditcardno, SortCode, AccountNo) SELECT 'Abou', 'Ben-Adam','Tribe','increase', '9807493817364950','08-48-37','003948673'; INSERT INTO Customer (Firstname, Surname, USER_ID, Password, Creditcardno, SortCode, AccountNo) SELECT 'Phil', 'Factor','jig','flutersball', '7666923165777980','22-45-44','020594835'; Go |
Now we’ll add an address table in dbo schema. First we delete it if it exists, so we can do this test over and again.
1 2 3 4 5 |
if exists (Select * from information_schema.tables where TABLE_NAME like 'Address' and TABLE_SCHEMA= 'dbo') drop table dbo.Address; go |
Create the address table in dbo
schema.
1 2 3 4 5 6 7 8 |
Create table dbo.Address ( Address_ID int identity NOT NULL PRIMARY KEY, FirstLineAddress varchar(50) NOT NULL, SecondLineAddress varchar(50) NULL, Town varchar(50) NOT NULL, County varchar(50) NOT NULL, PostCode Varchar(12) NOT NULL); Go |
SQL Injection
SQL Injection usually comes through the failure of the application programmer to filter the input from the user to ‘escape’ or otherwise change SQL string-delimiters in input from the user, or pass numbers unvalidated. So, when a user types in CM5 4RS
for his postcode, then the application programmer might take it into his or her head to construct, on the fly, the SQL string…
1 2 |
INSERT INTO address (FirstLineAddress, SecondLineAddress, Town, County, PostCode) VALUES ('The acacias','Holly Avenue', 'Good Easter', 'Chelmsford' ,'CM5 4RS'); |
Instead of using parameters, the progreammer does it simply by concatenating the components of the SQL String together. If so then it is the work of a moment for a hacker to type the following into the postcode field of the application.
Instead of:
1 |
CM5 4RS |
He types …
1 |
CM5 4RS') Select * from customer select (' |
… which is then automatically converted into:
1 2 |
INSERT INTO address (FirstLineAddress, SecondLineAddress, Town, County, PostCode) VALUES ('The acacias','Holly Avenue', 'Good Easter', 'Chelmsford' ,'CM5 4RS') Select * from customer select (''); |
With obvious results, and no apparent error.
Let’s simulate this entirely in SQL just to show you how easily this happens. Firstly we’ll put in innocent parameters and then some malicious ones.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
Go Declare @FirstLineAddress varchar(50), @SecondLineAddress varchar(50),@Town varchar(50),@County varchar(50), @Postcode varchar(12) --we put in a legitimate postcode Select @FirstLineAddress='The Hall', @SecondLineAddress='Factor Road', @Town='Little Snoring',@County='Suffolk', @PostCode ='C09 5RT' --this code is soooo Baaaad Execute ('INSERT INTO address (FirstLineAddress, SecondLineAddress, Town, County, PostCode) VALUES ('''+@FirstLineAddress+''','''+@SecondLineAddress+''','''+ @Town+''','''+@County+''','''+@Postcode+''')'); --seems OK, the row got inserted and ... -- (1 row(s) affected) go Declare @FirstLineAddress varchar(50), @SecondLineAddress varchar(50),@Town varchar(50),@County varchar(50), @Postcode varchar(80) --we put in a SQL Injection postcode Select @FirstLineAddress='The Hall', @SecondLineAddress='Factor Road', @Town='Little Snoring',@County='Suffolk', @PostCode ='C09 5RT'') Select * from customer select ('''; --this is going to end in tears! Execute ( 'INSERT INTO address (FirstLineAddress, SecondLineAddress, Town, County, PostCode) VALUES ('''+@FirstLineAddress+''','''+@SecondLineAddress+''','''+ @Town+''','''+@County+''','''+@Postcode+''')'); --ouch! All the passwords and credit card numbers |
Now we are going to show how you can avoid this sort of thing happenening, and a lot more besides, but implementing schema-based security. Firstly, we’ll add a schema, put some objects in it, and show how one can prevent sensitive data being accessed from the table.
In our database we create a database user. We will log in as WebsiteUser
with what we hope will be the ability to access just the data that he or she is entitled to but no more. (it is worth doing a windows user as well, to test that too.).
1 2 3 4 5 6 7 |
if not exists (select name from sys.syslogins where name like 'Workbench') Raiserror ('you''ll need a login called ''Workbench'' for this test',16,1) if exists (select name from sys.sysusers where name like 'WebsiteUser') drop user WebsiteUser go CREATE USER [WebsiteUser] FOR LOGIN [Workbench] WITH DEFAULT_SCHEMA=[WidgetShopSite]; --CREATE USER [Tom] FOR LOGIN [MyDomain\Tom] WITH DEFAULT_SCHEMA=[WidgetShopSite] GO |
And in reality, there will be a lot of them so we’d want to create a database role that we will assign to our schema by default, with SELECT
and EXECUTE
permissions.
1 2 3 4 |
IF DATABASE_PRINCIPAL_ID('AllWebsiteUsers') IS not NULL drop role AllWebsiteUsers go CREATE ROLE [AllWebsiteUsers]; go |
Now, the most important component, is a schema that acts as an application interface.
1 2 3 4 5 6 7 8 9 10 11 12 |
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'WidgetShopSite') BEGIN -- The schema must be run in its own batch! EXEC( 'CREATE SCHEMA WidgetShopSite' ); END go -- add role member WebsiteUser to the role EXEC sp_addrolemember N'AllWebsiteUsers', N'WebsiteUser' --Now we allow our role to execute or select anything in the schema GRANT EXECUTE ON SCHEMA::[WidgetShopSite] TO [AllWebsiteUsers]; GRANT SELECT ON SCHEMA::[WidgetShopSite] TO [AllWebsiteUsers]; GO |
Now we create a stored procedure in the WidgetShopSite
schema that checks the application’s User ID and password for their website users, so it is unnecessary to expose the information outside the database. We wont give it any permission to the table but because of ownership chaining, the code can access the table. The user has no permission to alter the procedure so it is safe.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
if exists (Select * from information_schema.routines where ROUTINE_NAME like 'spLogMeIn' and ROUTINE_SCHEMA= 'WidgetShopSite') drop PROCEDURE WidgetShopSite.spLogMeIn; go ----- CREATE PROCEDURE WidgetShopSite.spLogMeIn @User_ID VARCHAR(50), @Password VARCHAR(50), @Success INT output AS BEGIN SET NOCOUNT ON; SELECT @success = CASE WHEN EXISTS (SELECT 1 FROM dbo.customer WHERE [user_ID] =@User_ID AND Password=@password ) THEN -1 ELSE 0 END; END; |
Now it is likely that we will want to allow the application access to a view of the customer information without the sensitive information.
Here is a simple view in the WidgetShopSite
schema to illustrate such a view….
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Go if exists (Select * from information_schema.tables where TABLE_NAME like 'vCustomer' and TABLE_SCHEMA= 'WidgetShopSite' and TABLE_TYPE='VIEW') drop VIEW WidgetShopSite.vCustomer; go CREATE VIEW WidgetShopSite.vCustomer AS SELECT Customer_ID, Firstname, Surname, USER_ID, InsertionDate FROM dbo.Customer WITH CHECK OPTION --prevent untoward changes via an update GO |
We’ll also create a procedure in the WidgetShopSite
schema that returns a table that would provide a result. This will just serve to prove to you that it works.
1 2 3 4 5 6 7 8 9 10 11 |
if exists (Select * from information_schema.routines where ROUTINE_NAME like 'Customer' and ROUTINE_SCHEMA= 'WidgetShopSite') drop PROCEDURE WidgetShopSite.Customer; go ----- CREATE PROCEDURE WidgetShopSite.Customer AS SELECT Customer_ID, Firstname, Surname, USER_ID, InsertionDate FROM dbo.Customer; GO |
And now we do a stored procedure in the WidgetShopSite
schema which uses ‘Dynamic’ SQL. We’ll do it to show that this won’t access the table even though the role we’ve created has permission to execute this.
1 2 3 4 5 6 7 8 9 10 |
if exists (Select * from information_schema.routines where ROUTINE_NAME like 'CustomerWithDynamicSQL' and ROUTINE_SCHEMA= 'WidgetShopSite') drop PROCEDURE WidgetShopSite.CustomerWithDynamicSQL; go CREATE PROCEDURE WidgetShopSite.CustomerWithDynamicSQL AS EXECUTE ('SELECT Customer_ID, Firstname, Surname, User_ID, InsertionDate FROM dbo.Customer'); GO |
And now we do a second stored procedure in the WidgetShopSite
schema which uses ‘Dynamic’ SQL. This time, we do it to show that this will successfully access the table even though the role we’ve created has permission to execute this. This may look dangerous, but the attacker can’t alter the code and there are no parameters anyway that he could use. Yes, it is safe.
1 2 3 4 5 6 7 8 9 10 11 |
if exists (Select * from information_schema.routines where ROUTINE_NAME like 'CustomerWithDynamicSQLAsOwner' and ROUTINE_SCHEMA= 'WidgetShopSite') drop PROCEDURE WidgetShopSite.CustomerWithDynamicSQLAsOwner; go ----- CREATE PROCEDURE WidgetShopSite.CustomerWithDynamicSQLAsOwner with execute as owner --don't use if there is any possibility of injection AS EXECUTE ('SELECT Customer_ID, Firstname, Surname, User_ID, InsertionDate FROM dbo.Customer'); go |
And, we do a function the WidgetShopSite
schema just to see if that provides the right access successfully.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
if exists (Select * from information_schema.routines where ROUTINE_NAME like 'TheCustomer' and ROUTINE_SCHEMA= 'WidgetShopSite' and ROUTINE_TYPE='FUNCTION') drop function WidgetShopSite.TheCustomer; go ----- CREATE FUNCTION WidgetShopSite.TheCustomer ( ) RETURNS @Results TABLE ( [Customer_ID] [int] , [Firstname] [varchar](50), [Surname] [varchar](50), [User_ID] [varchar](20), [InsertionDate] [datetime] ) AS BEGIN INSERT INTO @Results (Customer_ID, Firstname,Surname,[User_ID],InsertionDate) SELECT Customer_ID, Firstname,Surname,[User_ID],InsertionDate FROM dbo.customer; RETURN; END; GO |
And a view that encapsulates a function just to test out a short chain:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
if exists (Select * from information_schema.tables where TABLE_NAME like 'vCustomerViaFunction' and TABLE_SCHEMA= 'WidgetShopSite' and TABLE_TYPE='VIEW') drop VIEW WidgetShopSite.vCustomerViaFunction; go CREATE VIEW WidgetShopSite.vCustomerViaFunction AS SELECT Customer_ID, Firstname, Surname, USER_ID, InsertionDate FROM WidgetShopSite.TheCustomer(); GO |
Now we create a function that is vulnerable to SQL Injection.
1 2 3 4 5 6 7 8 |
if exists (Select * from information_schema.routines where ROUTINE_NAME like 'vulnerableCode_DontUse' and ROUTINE_SCHEMA= 'WidgetShopSite' and ROUTINE_TYPE='PROCEDURE') drop procedure WidgetShopSite.vulnerableCode_DontUse; go ----- Create procedure WidgetShopSite.vulnerableCode_DontUse @FirstLineAddress varchar(50), @SecondLineAddress varchar(50),@Town varchar(50),@County varchar(50), @Postcode varchar(80) |
Note that our postcode should not be 80 characters. 16 chars internationally is safe. Obviously, in reality, you’d always validate all your parameters.
To inject, try…
1 2 |
Execute WidgetShopSite.vulnerableCode_DontUse @FirstLineAddress='The Hall', @SecondLineAddress='Factor Road', @Town='Little Snoring',@County='Suffolk', @PostCode ='C09 5RT'') Select * from customer select (''' |
… but you could do it by injecting in any parameter – if you have the patience.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
with execute as owner Execute ( 'INSERT INTO address (FirstLineAddress, SecondLineAddress, Town, County, PostCode) VALUES ('''+@FirstLineAddress+''','''+@SecondLineAddress+''','''+ @Town+''','''+@County+''','''+@Postcode+''')'); go if exists (Select * from information_schema.routines where ROUTINE_NAME like 'SaferDynamicSQL' and ROUTINE_SCHEMA= 'WidgetShopSite' and ROUTINE_TYPE='PROCEDURE') drop procedure WidgetShopSite.SaferDynamicSQL; go Create procedure WidgetShopSite.SaferDynamicSQL @FirstLineAddress varchar(50), @SecondLineAddress varchar(50),@Town varchar(50),@County varchar(50), @Postcode varchar(16) |
But it is very rare that you’d need to do this. Dynamic pivots, maybe, but not for the general run.
1 2 |
Execute WidgetShopSite.SaferDynamicSQL @FirstLineAddress='The Hall', @SecondLineAddress='Factor Road', @Town='Little Snoring',@County='Suffolk', @PostCode ='C09 5RT'') Select * from customer select (''' |
1 2 3 4 5 6 7 |
with execute as owner as execute sp_ExecuteSQL N'INSERT INTO address (FirstLineAddress, SecondLineAddress, Town, County, PostCode) VALUES (@FLAddress,@SLAddress,@TheTown,@TheCounty,@ThePostcode)', N'@FLAddress varchar(50),@SLAddress varchar(50),@TheTown varchar(50),@TheCounty varchar(50),@ThePostcode varchar(80)', @FLAddress=@FirstLineAddress, @SLAddress=@SecondLineAddress, @TheTown=@town, @TheCounty=@county, @ThePostcode=postcode; go |
Trying out the Test harness
Now the test database is constructed, we can use it to try out different security models.
You have two approaches to testing various ideas out. You can use two query windows or just one.
For the two-window approach…
Firstly, open up a new window in SSMS or Query Manager, but using the workbench
login ID and mypassword
password (right-click, press ‘connection…’ in the pop-up and then ‘Change Connection …’).
To execute this following code, you must be logged in as WorkBench
(password: mypassword
). Don’t execute it whilst logged in as DBO!
You can try out the following – paste it all into the new window. Don’t execute it in this window!
For the single-query window approach use this….
We’ll use a loginless user to test out security. These login-less users are strange characters that exist as automata either to provide a convenient owner of a schema and objects used in any given application in parts of a database, break or create chains, or to mark modules with EXECUTE AS
and granting only permissions to these principals.
Here is a simple example.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
create user fred without login go execute as user = 'fred' go select '"I knew about loginless users, but have never needed them," said ' + user_name() + ' pensively. I did feel as if i was missing something in my life.' go revert go CREATE USER [The_Proletariat] WITHOUT LOGIN WITH DEFAULT_SCHEMA= WidgetShopSite; EXEC sp_addrolemember N'AllWebsiteUsers', N'The_Proletariat'; -- Step 3 : Checking access to Tables views etc. SELECT schema_name(schema_id)+'.'+name, Type_desc, type FROM sys.objects where schema_name(schema_id) <>'sys'; -- Step 4 : Changing the execution context EXECUTE AS USER = 'The_Proletariat'; GO Select Schema_Name(); /* should be 'WidgetShopSite' if you are 'The_Proletariat' or 'dbo' if you are still there as DBO. To switch to 'The_Proletariat' execute EXECUTE AS USER = 'The_Proletariat'; and to go back to being DBO then execute REVERT */ USE SecurityWorkbench; -- can we use a view? SELECT * FROM vcustomer ; -- what about executing a stored procedure that returns the (censored) -- data from the table EXECUTE Customer; /*can we access a view that accesses a function in the schema tha then access the table */ Select * from vCustomerViaFunction --slides down the object chain -- can we access a table function? SELECT * FROM WidgetShopSite.TheCustomer() WHERE surname LIKE 'factor'; -- or use a view containing a table function? SELECT * FROM vCustomerViaFunction; -- and lastly, can we, without having any table access, check a -- User_ID and password DECLARE @success INT EXECUTE spLogMeIn @user_id='jig',@password='flutersball', @success=@success out --fixed 9/3/2007 SELECT @success -- Can you use a stored procedure that contains dynamic SQL. If not, -- then why not (The answer is in the SQL Server Security Cribsheet) EXECUTE CustomerWithDynamicSQL; /* nope Msg 229, Level 14, State 5, Line 1 The SELECT permission was denied on the object 'Customer', database 'SecurityWorkbench', schema 'dbo'. */ EXECUTE CustomerWithDynamicSQLAsOwner; |
And here is the snag. We are still vulnerable to SQL Injection at the database level if we try to assemble dynamic SQL rather than use parameters properly.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
Execute WidgetShopSite.vulnerableCode_DontUse @FirstLineAddress='The Hall', @SecondLineAddress='Factor Road', @Town='Little Snoring',@County='Suffolk', @PostCode ='C09 5RT'') Select * from customer select (''' -- can we access a table directly? SELECT * FROM customer; /* Nope Msg 229, Level 14, State 5, Line 1 The SELECT permission was denied on the object 'Customer', database 'SecurityWorkbench', schema 'dbo'. */ -- or insert into a view? INSERT INTO vcustomer (firstname, surname,[user_ID]) SELECT 'Akund','Swat','Who' ; /* Nope. Good Msg 229, Level 14, State 5, Line 1 The INSERT permission was denied on the object 'vCustomer', database 'SecurityWorkbench', schema 'WidgetShopSite'. */ -- or indulge in wickedness? master..xp_cmdshell 'Dir c:\'; --The EXECUTE permission was denied on the object 'xp_cmdshell', DROP TABLE dbo.customer; --Cannot drop the table 'Customer', because it does not exist or you do not have permission. DELETE FROM dbo.customer; --The DELETE permission was denied on the object 'Customer', database 'SecurityWorkbench', schema 'dbo'. SELECT * FROM information_schema.tables EXECUTE sp_help; --you should only see the objects you have permissions for EXECUTE sp_who ; --you should only see yourself KILL 51; /* should be Msg 6102, Level 14, State 1, Line 1 User does not have permission to use the KILL statement.*/ REVERT; |
Ownership-chaining bypasses permission checks entirely, and even takes precedence over DENY ACCESS
for all links but the first. If you wish to deny a principal usage of a chain, then you must DENY ACCESS
at the start of the chain.
So let’s demonstrate this by creating a schema.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
create schema fred go -- and a sample table with data. create table fred.tableName ( value varchar(20) ) insert into fred.tableName values ('fred') go --we now create a stored procedure in the directory create procedure fred.tablenameQuery as select * from fred.tablename go --and we create a login-less user to test with create user fred without login with default_schema = fred go --we will prevent our user from direct access to the table deny select on fred.tableName to fred -- ... but allow Fred to access the stored procedure that accesses the table grant execute on fred.tablenameQuery to fred go --for the demo, we take on Fred's persona execute as user = 'fred' go select * from fred.tablename /* Msg 229, Level 14, State 5, Line 1 The SELECT permission was denied on the object 'tableName', database 'SecurityWorkbench', schema 'fred'. */ go -- but we try using the stored procedure which just accesses the table denied to Fred exec fred.tablenameQuery; /* ---and it works! Fred can get to that table value -------------------- fred */ go revert go /* and tear-down this experiment. */ drop procedure fred.tablenameQuery drop table fred.tableName drop user fred drop schema fred |
Load comments