Descompilar filtros de excepción: el catch (e) when (...) que se vuelve una región filter y el endfilter que la ejecuta
Un bloque catch con un tipo es de lo más sencillo que lee un descompilador: una cláusula de manejador con un token de tipo, listo. Un catch con una cláusula when es de lo más espinoso, porque es un tipo de región de excepción completamente distinto. El filtro no tiene token de tipo; la prueba de tipo se traslada a un bloque de IL aparte que se ejecuta mientras la pila aún se está desenrollando, evalúa la condición when y acaba en un endfilter que devuelve 1 para ejecutar el manejador o 0 para seguir buscando. Reconstruir catch (IOException e) when (e.HResult == 5) significa reconocer esa estructura de dos bloques, elevar el objeto de excepción que el runtime puso en la pila a la variable e, extraer la prueba de tipo del filtro como el tipo del catch, y leer el resto como la expresión when, sabiendo luego que las excepciones lanzadas dentro del filtro se tragan, lo cual es una garantía real del lenguaje visible en el IL. Este artículo lee el IL exacto que emite un catch filtrado, recorre el protocolo de endfilter y muestra cómo Glass.NET reconstruye la cláusula.
La mayor parte de lo que un descompilador hace con el manejo de excepciones es emparejar patrones de lo obvio. Un try/finally son dos regiones y un leave; un catch (IOException e) es una cláusula de manejador etiquetada con un token de tipo, y el lector solo imprime el tipo. El único constructo de manejo de excepciones que de verdad merece una segunda mirada es el catch filtrado —catch (E e) when (condición)— porque no es en absoluto el mismo tipo de cláusula. No tiene token de tipo, ejecuta código antes incluso de que se elija el manejador, y acaba en una instrucción de la que la mayoría de desarrolladores de C# nunca han oído hablar: endfilter.
Este artículo lee el IL que un catch filtrado emite de verdad, recorre el protocolo de endfilter paso a paso, y muestra cómo Glass.NET reconstruye la cláusula —incluida la única garantía real del lenguaje que el filtro esconde: las excepciones lanzadas dentro de él se tragan—.
Un catch filtrado es una cláusula de dos regiones sin tipo
Aquí está el código fuente que descompilaremos:
try
{
DoIo();
}
catch (IOException e) when (e.HResult == 5)
{
Log("access denied");
}
Un catch (IOException e) corriente compilaría a una región de manejador con IOException como su tipo de cláusula. El when cambia el tipo de la cláusula. Los metadatos ahora registran una cláusula filter, cuyo campo de tipo está vacío, y el método lleva dos regiones en vez de una: una región filter y una región de manejador. Descompilado al renderizado de ILDasm, la estructura es:
.try IL_0000 to IL_0007 filter IL_0007 handler IL_0021 to IL_002e
IL_0000: call void DoIo()
IL_0005: leave.s IL_002e
// ---- filter region: the runtime pushes the exception object here ----
IL_0007: isinst [System.Runtime]System.IO.IOException
IL_000c: dup
IL_000d: brtrue.s IL_0013
IL_000f: pop
IL_0010: ldc.i4.0
IL_0011: br.s IL_001f
IL_0013: callvirt instance int32 [System.Runtime]System.Exception::get_HResult()
IL_0018: ldc.i4.5
IL_0019: ceq
IL_001b: br.s IL_001f
IL_001f: endfilter
// ---- handler region: exception object on the stack again ----
IL_0021: pop
IL_0022: ldstr "access denied"
IL_0027: call void Log(string)
IL_002c: leave.s IL_002e
IL_002e: ret
Lee la región filter como un pequeño programa que el runtime ejecuta antes de decidir si entrar en el manejador. El objeto de excepción ya está en la pila cuando la región empieza —el runtime lo puso ahí—. isinst IOException hace la prueba de tipo que pidió la parte catch (IOException ...); dup/brtrue conserva el resultado del cast si coincidió; si no coincidió, pop y ldc.i4.0 dejan 0 y saltan a endfilter, abandonando la cláusula. Si coincidió, el código evalúa e.HResult == 5, dejando el resultado de ceq —0 o 1— en la pila. Cualquier ruta llega a endfilter con exactamente un entero detrás.
endfilter es la instrucción que elige el manejador
endfilter es todo el quid, y tiene un protocolo que vale la pena enunciar con precisión. La región filter debe dejar exactamente un int32 en la pila; endfilter lo saca y se lo entrega al runtime. 1 (EXCEPTION_EXECUTE_HANDLER) significa ejecutar el manejador; 0 (EXCEPTION_CONTINUE_SEARCH) significa esta cláusula no aplica, sigue desenrollando para encontrar otra. Por eso cada región filter del mundo acaba igual —alguna comparación que produce 0 o 1, luego endfilter—. Para el descompilador, endfilter es el marcador de frontera: todo desde el inicio de la región filter hasta él es la condición when (más la prueba de tipo implícita), y todo tras la etiqueta del manejador es el cuerpo del catch.
Luego empieza la región del manejador —y el objeto de excepción está en la pila otra vez, puesto por el runtime una segunda vez al entrar al manejador—. En nuestro IL, IL_0021: pop lo descarta, porque el cuerpo solo usa la cadena literal. Si el cuerpo hubiera usado e, el compilador habría emitido stloc al slot de e en vez de pop. Reconocer ese pop-o-stloc inicial es como Glass.NET sabe dónde acaba la condición y empieza el cuerpo del catch escrito por una persona, y si la variable de excepción se lee de verdad.
Qué reconstruye Glass.NET, y desde dónde
Junta las piezas y la reconstrucción es mecánica. Glass.NET ve una cláusula filter sin token de tipo y sabe de inmediato que es un catch when. Lee el prólogo de la región filter —el isinst contra IOException, el baile dup/brtrue/pop/ldc.i4.0 de comprobación de nulo— y eleva dos cosas de él: el destino del isinst se vuelve el tipo del catch, y el objeto de excepción que puso el runtime se vuelve la variable e. Todo entre ese prólogo y endfilter es la expresión when. El stloc/pop inicial de la región del manejador confirma si e se usa. El resultado:
catch (IOException e) when (e.HResult == 5)
{
Log("access denied");
}
—recuperado fielmente, sin PDB, porque cada parte de ello está codificada en el IL y en los metadatos de región de excepción de los que el propio runtime depende—. Un descompilador que no modelara filtros te mostraría un catch sin tipo con un bloque de IL de comparación que no supo dónde colocar, que es justo el tipo de brecha de “el código corre pero la descompilación está mal” que un lector navegando un binario desconocido no puede permitirse.
La garantía escondida en el filtro: las excepciones lanzadas se tragan
Una cosa más que el IL hace legible, y es una regla real del lenguaje, no una peculiaridad. Si la región filter misma lanza —digamos que e.HResult se reemplazara por una propiedad que pudiera lanzar— el CLR captura esa excepción y trata al filtro como si hubiera devuelto 0, continuar búsqueda. La condición when que lanza no cuelga tu programa y no escapa; solo hace que ese catch se omita. Esto es comportamiento especificado, y es por lo que una cláusula when debería ser barata y libre de efectos secundarios: una condición que puede lanzar es un bug sutil, porque desactiva en silencio el catch que creías haber escrito.
Glass.NET renderiza la cláusula como el catch ... when (...) que fue y deja el comportamiento de tragar como lo que es —una propiedad de los filtros que el lector necesita entender, no algo que un descompilador deba reescribir—. Hacer visible esa estructura es el valor: el código descompilado te dice que esto es un filtro, y un lector que conoce los filtros sabe que una condición que lanza aquí falla la coincidencia en silencio. Esa es la diferencia entre una descompilación que reproduce los bytes y una que reproduce el significado.
La conclusión
El catch filtrado es el constructo de excepción donde IL y código fuente dejan de ser uno a uno, y leerlo es una buena prueba para un descompilador. No hay token de tipo —el tipo se trasladó a un isinst dentro de una región filter aparte—. La condición corre antes de que se elija el manejador, contra un objeto de excepción que el runtime pone en la pila. endfilter saca el 0-o-1 que lo decide todo. Y un lanzamiento dentro del filtro se traga como garantía del lenguaje. Glass.NET modela todo ello: la estructura de dos regiones, el tipo elevado del filtro, la variable de la excepción puesta, la condición hasta endfilter y el manejador tras ella. Lo que recuperas es el catch (E e) when (…) que escribiste —y una imagen precisa del único sitio donde el manejo de excepciones de C# hace algo que la mayoría nunca supo que hacía—.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.