Codificación segura con concurrencia en Swift 4

En mi artículo anterior sobre la codificación segura en Swift, hablé de las vulnerabilidades de seguridad básicas en Swift, como los ataques de inyección. Si bien los ataques de inyección son comunes, hay otras formas en que su aplicación puede verse comprometida. Un tipo de vulnerabilidad común pero que a veces se pasa por alto son las condiciones de la raza.. 

Swift 4 introduce Acceso exclusivo a la memoria, que consiste en un conjunto de reglas para evitar el acceso a la misma área de memoria al mismo tiempo. Por ejemplo, el En fuera El argumento en Swift le dice a un método que puede cambiar el valor del parámetro dentro del método.

func changeMe (_ x: inout MyObject, yChange y: inout MyObject) 

Pero ¿qué pasa si pasamos en la misma variable para cambiar al mismo tiempo??

changeMe (& myObject, andChange: & myObject) // ???

Swift 4 ha realizado mejoras que impiden que esto se compile. Pero mientras Swift puede encontrar estos escenarios obvios en tiempo de compilación, es difícil, especialmente por razones de rendimiento, encontrar problemas de acceso a la memoria en código concurrente, y la mayoría de las vulnerabilidades de seguridad existen en forma de condiciones de carrera..

Condiciones de carrera

Tan pronto como tenga más de un hilo que necesita escribir en los mismos datos al mismo tiempo, puede ocurrir una condición de carrera. Las condiciones raciales causan corrupción de datos. Para este tipo de ataques, las vulnerabilidades suelen ser más sutiles y las vulnerabilidades más creativas. Por ejemplo, podría haber la capacidad de alterar un recurso compartido para cambiar el flujo de código de seguridad que ocurre en otro hilo, o en el caso del estado de autenticación, un atacante podría aprovechar un intervalo de tiempo entre el momento de la verificación. y el tiempo de uso de una bandera.

La forma de evitar las condiciones de carrera es sincronizar los datos. Sincronizar los datos generalmente significa "bloquearlos" de modo que solo un hilo pueda acceder a esa parte del código a la vez (se dice que es un mutex para la exclusión mutua). Mientras que usted puede hacer esto explícitamente usando el NSLock clase, existe la posibilidad de pasar por alto lugares donde el código debería haber sido sincronizado. Hacer un seguimiento de los bloqueos y si ya están bloqueados o no puede ser difícil.

Grand Central Dispatch

En lugar de usar bloqueos primitivos, puede usar Grand Central Dispatch (GCD): la moderna API de concurrencia de Apple diseñada para el rendimiento y la seguridad. Usted no necesita pensar en las cerraduras usted mismo; hace el trabajo por ti detrás de las escenas. 

DispatchQueue.global (qos: .background) .async // cola concurrente, compartida por el sistema // realiza un trabajo de larga ejecución en segundo plano aquí // ... DispatchQueue.main.async // cola en serie // Actualizar la interfaz de usuario - mostrar los resultados de nuevo en el hilo principal

Como puede ver, es una API bastante simple, así que use GCD como su primera opción al diseñar su aplicación para la concurrencia.

Las verificaciones de seguridad en tiempo de ejecución de Swift no se pueden realizar a través de subprocesos GCD porque crean un impacto significativo en el rendimiento. La solución es utilizar la herramienta Thread Sanitizer si está trabajando con varios subprocesos. La herramienta Thread Sanitizer es excelente para encontrar problemas que nunca podría encontrar al mirar el código usted mismo. Se puede habilitar yendo a Producto> Esquema> Editar esquema> Diagnóstico, y revisando el Desinfectante de hilo opción.

Si el diseño de su aplicación lo hace trabajar con múltiples subprocesos, otra forma de protegerse de los problemas de seguridad de la concurrencia es a través de Intenta diseñar tus clases para que estén libres de bloqueo. por lo que en primer lugar no es necesario ningún código de sincronización. Esto requiere una reflexión real sobre el diseño de su interfaz, e incluso puede considerarse un arte separado en sí mismo.!

El verificador de hilo principal

Es importante mencionar que la corrupción de datos también puede ocurrir si realiza actualizaciones de la interfaz de usuario en cualquier subproceso que no sea el subproceso principal (cualquier otro subproceso se conoce como un subproceso en segundo plano). 

A veces ni siquiera es obvio que estás en un hilo de fondo. Por ejemplo, SESIÓNes delegateQueue, cuando se establece en nulo, Por defecto volverá a llamar en un hilo de fondo. Si realiza actualizaciones de la interfaz de usuario o escribe sus datos en ese bloque, existe una buena posibilidad de que se cumplan las condiciones de la carrera. (Solucione esto envolviendo las actualizaciones de la interfaz de usuario en DispatchQueue.main.async o pasar en OperationQueue.main como la cola de delegado) 

Nuevo en Xcode 9 y habilitado de forma predeterminada es el verificador de subprocesos principal (Producto> Esquema> Editar esquema> Diagnóstico> Comprobación de API en tiempo de ejecución> Comprobador de subprocesos principal). Si su código no está sincronizado, los problemas aparecerán en la Problemas de tiempo de ejecución en el panel izquierdo del navegador de Xcode, así que presta atención al probar tu aplicación. 

Para codificar por seguridad, cualquier devolución de llamada o manejador de finalización que escriba debe documentarse, ya sea que regresen al hilo principal o no. Mejor aún, siga el nuevo diseño de API de Apple que le permite pasar un completarQueue en el método para que pueda decidir claramente y ver en qué hilo regresa el bloque de finalización.

Un ejemplo del mundo real

¡Basta de hablar! Vamos a sumergirnos en un ejemplo..

class Transaction //… class Transactions private var lastTransaction: Transaction? func addTransaction (_ source: Transaction) // ... lastTransaction = source // First thread transaction.addTransaction (transaction) // Segunda thread thread.addTransaction (transaction)

Aquí no tenemos sincronización, pero más de un hilo accede a los datos al mismo tiempo. Lo bueno de Thread Sanitizer es que detectará un caso como este. La forma moderna de GCD para solucionar este problema es asociar sus datos con una cola de envío en serie.

clase Transacciones private var lastTransaction: Transaction? private var queue = DispatchQueue (label: "com.myCompany.myApp.bankQueue") func addTransaction (_ source: Transaction) queue.async // ... self.lastTransaction = source

Ahora el código está sincronizado con el .asíncrono bloquear. Quizás te preguntes cuándo elegir. .asíncrono y cuando usar .sincronizar. Puedes usar .asíncrono cuando su aplicación no necesita esperar hasta que finalice la operación dentro del bloque. Podría explicarse mejor con un ejemplo..

let queue = DispatchQueue (label: "com.myCompany.myApp.bankQueue") var transactionIDs: [String] = ["00001", "00002"] // Primera hebra queue.async transactionIDs.append ("00003") // no proporciona ninguna salida, así que no es necesario esperar a que finalice // Otro hilo queue.sync si transactionIDs.contains ("00001") // ... ¡Tengo que esperar aquí! imprimir ("Transacción ya completada")

En este ejemplo, el subproceso que pregunta a la matriz de transacciones si contiene una transacción específica proporciona resultados, por lo que debe esperar. El otro hilo no realiza ninguna acción después de agregarse a la matriz de transacciones, por lo que no necesita esperar hasta que se complete el bloqueo.

Estos bloques de sincronización y asíncronos pueden incluirse en métodos que devuelven sus datos internos, como los métodos de obtención..

obtener return queue.sync transactionID

La dispersión de bloques de GCD en todas las áreas de su código que acceden a datos compartidos no es una buena práctica, ya que es más difícil hacer un seguimiento de todos los lugares que deben sincronizarse. Es mucho mejor intentar mantener toda esta funcionalidad en un solo lugar.. 

Un buen diseño utilizando métodos de acceso es una forma de resolver este problema. El uso de métodos de obtención y configuración y solo el uso de estos métodos para acceder a los datos significa que puede sincronizar en un solo lugar. Esto evita tener que actualizar muchas partes de su código si está cambiando o refactorizando el área GCD de su código.

Estructuras

Mientras que las propiedades individuales almacenadas se pueden sincronizar en una clase, el cambio de propiedades en una estructura afectará a toda la estructura. Swift 4 ahora incluye protección para métodos que mutan las estructuras. 

Primero veamos cómo se ve una corrupción de estructura (llamada "Carrera de acceso rápido").

struct Transaction private var id: UInt32 private var timestamp: Double // ... mutating func begin () id = arc4random_uniform (101) // 0 - 100 // ... mutating func finish () // ... timestamp = NSDate ( ) .timeIntervalSince1970

Los dos métodos en el ejemplo cambian las propiedades almacenadas, por lo que están marcados mutando. Digamos hilo 1 llamadas empezar() e hilo 2 llamadas terminar(). Incluso si empezar() solo cambios carné de identidad y terminar() solo cambios marca de tiempo, Sigue siendo una carrera de acceso. Aunque normalmente es mejor bloquear los métodos de acceso, esto no se aplica a las estructuras, ya que toda la estructura debe ser exclusiva. 

Una solución es cambiar la estructura a una clase al implementar su código concurrente. Si necesitara la estructura por algún motivo, podría, en este ejemplo, crear un Banco clase que almacena Transacción estructuras Luego se pueden sincronizar los llamadores de las estructuras dentro de la clase.. 

Aquí hay un ejemplo:

clase Bank private var currentTransaction: Transaction? private var queue: DispatchQueue = DispatchQueue (label: "com.myCompany.myApp.bankQueue") func doTransaction () queue.sync currentTransaction? .begin () //…

Control de acceso

Sería inútil tener toda esta protección cuando su interfaz expone un objeto mutante o un objeto UnsafeMutablePointer a los datos compartidos, porque ahora cualquier usuario de su clase puede hacer lo que quiera con los datos sin la protección de GCD. En su lugar, devolver copias a los datos en el getter. El diseño cuidadoso de la interfaz y la encapsulación de datos son importantes, especialmente cuando se diseñan programas concurrentes, para asegurar que los datos compartidos estén realmente protegidos.

Asegúrate de que las variables sincronizadas estén marcadas privado, Opuesto a abierto o público, lo que permitiría a los miembros de cualquier archivo fuente acceder a él. Un cambio interesante en Swift 4 es que el privado El alcance del nivel de acceso se amplía para estar disponible en extensiones. Anteriormente, solo podía usarse dentro de la declaración adjunta, pero en Swift 4, un privado Se puede acceder a la variable en una extensión, siempre que la extensión de esa declaración esté en el mismo archivo fuente.

Las variables no solo están en riesgo de corrupción de datos, sino también de archivos. Utilizar el Administrador de archivos Clase de fundación, que es segura para subprocesos, y verifica los indicadores de resultados de sus operaciones de archivo antes de continuar en su código.

Interfaz con Objective-C

Muchos objetos Objective-C tienen una contraparte mutable representada por su título. NSStringse nombra la versión mutable NSMutableString, NSArrayes NSMutableArray, y así. Además del hecho de que estos objetos se pueden mutar fuera de la sincronización, los tipos de punteros que provienen de Objective-C también subvierten las opciones Swift. Existe una buena posibilidad de que pueda estar esperando un objeto en Swift, pero desde Objective-C se devuelve como nulo. 

Si la aplicación falla, proporciona información valiosa sobre la lógica interna. En este caso, podría ser que la entrada del usuario no se haya verificado correctamente y valga la pena ver el área del flujo de la aplicación para probar y explotar.

La solución aquí es actualizar su código de Objective-C para incluir anotaciones de nulabilidad. Podemos tomar una ligera desviación aquí ya que este consejo se aplica a la interoperabilidad segura en general, ya sea entre Swift y Objective-C o entre otros dos lenguajes de programación. 

Prefacio sus variables Objective-C con anulable cuando nil puede ser devuelto, y no nulo cuando no deberia.

- (NSString * no nulo) myStringFromString: (NSString * anulable) cadena;

También puedes añadir anulable y no nulo a la lista de atributos de las propiedades de Objective-C.

@property (anulable, atómico, fuerte) NSDate * date;

La herramienta Static Analyzer en Xcode siempre ha sido excelente para encontrar errores Objective-C. Ahora, con las anotaciones de nulabilidad, en Xcode 9 puede usar el Analizador estático en su código de Objective-C y encontrará inconsistencias de nulabilidad en su archivo. Haga esto navegando a Producto> Realizar acción> Analizar.

Aunque está habilitado de forma predeterminada, también puede controlar las comprobaciones de nulabilidad en LLVM con -Capacidad de transporte * banderas.

Los controles de nulabilidad son buenos para encontrar problemas en tiempo de compilación, pero no encuentran problemas de tiempo de ejecución. Por ejemplo, a veces asumimos en una parte de nuestro código que siempre existirá un valor opcional y usaremos la fuerza de desenvolvimiento ! en eso. Este es un opcional implícitamente sin envolver, pero realmente no hay garantía de que siempre existirá. Después de todo, si estuviera marcado como opcional, es probable que sea nulo en algún momento. Por lo tanto, es una buena idea evitar el desenvolvimiento de la fuerza con !. En su lugar, una solución elegante es verificar en tiempo de ejecución así:

guarda let dog = animal.dog () else // maneja este caso retorno // continuar ... 

Para ayudarlo aún más, hay una nueva función agregada en Xcode 9 para realizar verificaciones de nulabilidad en tiempo de ejecución. Es parte del Desinfectante de comportamiento indefinido, y aunque no está habilitado de forma predeterminada, puede habilitarlo yendo a Configuraciones de compilación> Desinfectante de comportamiento indefinido y configuración para Habilitar los controles de anotación de nulabilidad.

Legibilidad

Es una buena práctica escribir sus métodos con una sola entrada y un punto de salida. Esto no solo es bueno para la legibilidad, sino también para el soporte avanzado de subprocesos múltiples.. 

Digamos que una clase fue diseñada sin la concurrencia en mente. Más tarde, los requisitos cambiaron de modo que ahora debe soportar la .bloquear() y .desbloquear() métodos de NSLock. Cuando llegue el momento de colocar bloqueos alrededor de partes de su código, es posible que deba volver a escribir muchos de sus métodos solo para estar seguro de subprocesos. Es fácil perderse un regreso oculto en medio de un método que luego se suponía que debía bloquear su NSLock instancia, que luego puede causar una condición de carrera. Además, declaraciones como regreso no desbloqueará automáticamente el bloqueo. Otra parte de su código que asume que el bloqueo está desbloqueado e intenta bloquearlo de nuevo bloqueará la aplicación (la aplicación se congelará y, finalmente, el sistema la terminará). Los bloqueos también pueden ser vulnerabilidades de seguridad en el código multiproceso si los archivos de trabajo temporales nunca se limpian antes de que finalice el hilo. Si tu código tiene esta estructura:

si x si y devuelve true, devuelve false ... devuelve false

En su lugar, puede almacenar el Booleano, actualizarlo en el camino y luego devolverlo al final del método. Luego, el código de sincronización puede envolverse fácilmente en el método sin mucho trabajo.

var success = false // <--- lock if x if y success = true… // < --- unlock return success

los .desbloquear() método debe ser llamado desde el mismo hilo que llama .bloquear(),  de lo contrario se traduce en un comportamiento indefinido.

Pruebas

A menudo, la búsqueda y corrección de vulnerabilidades en el código concurrente se reduce a la búsqueda de errores. Cuando encuentras un error, es como sostener un espejo para ti mismo: una gran oportunidad de aprendizaje. Si olvidó sincronizar en un lugar, es probable que el mismo error esté en otra parte del código. Tomarse el tiempo para revisar el resto de su código por el mismo error cuando se encuentra con un error es una forma muy eficaz de prevenir vulnerabilidades de seguridad que seguirían apareciendo una y otra vez en futuras versiones de aplicaciones.. 

De hecho, muchos de los recientes jailbreaks de iOS se deben a los repetidos errores de codificación encontrados en IOKit de Apple. Una vez que conozca el estilo del desarrollador, puede consultar otras partes del código para detectar errores similares.

Encontrar errores es una buena motivación para reutilizar el código. Saber que solucionó un problema en un lugar y no tiene que buscar todos los mismos casos en el código de copiar / pegar puede ser un gran alivio.

Las condiciones de la carrera pueden ser complicadas de encontrar durante las pruebas, ya que la memoria puede tener que estar dañada de la manera correcta para ver el problema y, a veces, los problemas aparecen mucho más tarde en la ejecución de la aplicación.. 

Cuando estés probando, cubre todo tu código. Ir a través de cada flujo y caso y probar cada línea de código al menos una vez. A veces es útil ingresar datos aleatorios (difuminar los datos) o elegir valores extremos con la esperanza de encontrar un caso límite que no sería obvio al mirar el código o usar la aplicación de una manera normal. Esto, junto con las nuevas herramientas de Xcode disponibles, puede contribuir en gran medida a prevenir vulnerabilidades de seguridad. Si bien ningún código es 100% seguro, seguir una rutina, como las pruebas funcionales iniciales, las pruebas unitarias, las pruebas del sistema, las pruebas de estrés y de regresión, realmente valdrá la pena.

Más allá de la depuración de su aplicación, una cosa que es diferente para la configuración de la versión (la configuración para las aplicaciones publicadas en la tienda) es que se incluyen las optimizaciones de código. Por ejemplo, lo que el compilador piensa es que una operación no utilizada puede optimizarse, o una variable puede no permanecer más tiempo del necesario en un bloque concurrente. Para su aplicación publicada, su código realmente se modifica o es diferente del que probó. Esto significa que se pueden introducir errores que solo existen una vez que lanza su aplicación. 

Si no está utilizando una configuración de prueba, asegúrese de probar su aplicación en el modo de lanzamiento navegando a Producto> Esquema> Editar esquema. Seleccionar correr de la lista de la izquierda, y en la Información panel a la derecha, cambio Construir la configuración a Lanzamiento. Si bien es bueno cubrir toda la aplicación en este modo, debe saber que debido a las optimizaciones, los puntos de interrupción y el depurador no se comportarán como se espera. Por ejemplo, es posible que las descripciones de las variables no estén disponibles aunque el código se ejecute correctamente..

Conclusión

En esta publicación, analizamos las condiciones de la carrera y cómo evitarlas mediante la codificación segura y el uso de herramientas como Thread Sanitizer. También hablamos sobre el acceso exclusivo a la memoria, que es una excelente adición a Swift 4. Asegúrese de que esté configurado para Cumplimiento completo en Configuraciones de compilación> Acceso exclusivo a la memoria

Recuerde que estas implementaciones solo están activadas para el modo de depuración, y si aún está utilizando Swift 3.2, muchas de las implementaciones mencionadas vienen solo en forma de advertencias. Así que tome las advertencias en serio, o mejor aún, haga uso de todas las nuevas funciones disponibles al adoptar Swift 4 hoy!

Y mientras estás aquí, echa un vistazo a algunas de mis otras publicaciones sobre codificación segura para iOS y Swift!