Sentencia If en el lenguaje Go
Sentencias If en el lenguaje Go
En primer lugar, nuestra elección de Go se debe a que, entre los lenguajes modernos, posee el ensamblador más "elegante" y, en comparación con lenguajes clásicos, la eficiencia de su sintaxis suele ser abrumadora.
Ahora que hemos comprendido el funcionamiento básico de un programa en Go en la lección anterior, procedamos a comparar Go y Assembly línea por línea.
Código fuente
En primer lugar, al igual que ocurre en Go, incluso los compiladores modernos, incluido GCC, optimizan automáticamente las sentencias de bifurcación que carecen de propósito. Los compiladores de C como GCC y Clang realizan optimizaciones muy agresivas en -O2, que es el estándar de la industria; por lo tanto, la era en la que un programador no podía confiar plenamente en el compilador se consolidó finalmente a finales del siglo XX.
En consecuencia, solo tiene sentido proporcionar condiciones que, desde la perspectiva del compilador, sean difíciles de predecir y transformar en otras estructuras.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Si se sustituye esto por algo predecible como x = 10, lo cual permitiría eliminar la bifurcación,
10 // el compilador la optimizará y la eliminará.
11 // Por lo tanto, para observar esto directamente en ensamblador, en lenguaje C se utilizaría -O0,
12 // o se emplearían valores externos impredecibles por el compilador.
13 // Dado que esta sección trata sobre programación moderna, no utilizaremos
14 // métodos para desactivar la optimización binaria de Go.
15 if len(os.Args) < 2 {
16 return
17 }
18 x, _ := strconv.Atoi(os.Args[1])
19
20 if x < 10 {
21 println("X is smaller than 10")
22 } else {
23 println("X is larger or same as 10")
24 }
25}
En este caso, debido a que el compilador no puede predecir la entrada, la sentencia de bifurcación se traduce literalmente a lenguaje máquina.
Lenguaje ensamblador
1TEXT main.main(SB) /home/yjlee/introduction-to-golang/learn-golang/if-and-switch/golang-if/main.go
2 main.go:8 0x47a840 493b6610 CMPQ SP, 0x10(R14)
3 main.go:8 0x47a844 7670 JBE 0x47a8b6
4 main.go:8 0x47a846 55 PUSHQ BP
5 main.go:8 0x47a847 4889e5 MOVQ SP, BP
6 main.go:8 0x47a84a 4883ec10 SUBQ $0x10, SP
7 main.go:15 0x47a84e 48833d12fb0a0002 CMPQ os.Args+8(SB), $0x2
8 main.go:15 0x47a856 7c58 JL 0x47a8b0
9 main.go:15 0x47a858 488b0d01fb0a00 MOVQ os.Args(SB), CX
10 main.go:18 0x47a85f 488b4110 MOVQ 0x10(CX), AX
11 main.go:18 0x47a863 488b5918 MOVQ 0x18(CX), BX
12 main.go:18 0x47a867 e834e8ffff CALL strconv.Atoi(SB)
13 main.go:20 0x47a86c 4883f80a CMPQ AX, $0xa
14 main.go:20 0x47a870 7d1d JGE 0x47a88f
15 main.go:21 0x47a872 e809befbff CALL runtime.printlock(SB)
16 main.go:21 0x47a877 488d0519f50100 LEAQ 0x1f519(IP), AX
17 main.go:21 0x47a87e bb15000000 MOVL $0x15, BX
18 main.go:21 0x47a883 e878c6fbff CALL runtime.printstring(SB)
19 main.go:21 0x47a888 e853befbff CALL runtime.printunlock(SB)
20 main.go:21 0x47a88d eb1b JMP 0x47a8aa
21 main.go:23 0x47a88f e8ecbdfbff CALL runtime.printlock(SB)
22 main.go:23 0x47a894 488d05c8040200 LEAQ 0x204c8(IP), AX
23 main.go:23 0x47a89b bb1a000000 MOVL $0x1a, BX
24 main.go:23 0x47a8a0 e85bc6fbff CALL runtime.printstring(SB)
25 main.go:23 0x47a8a5 e836befbff CALL runtime.printunlock(SB)
26 main.go:25 0x47a8aa 4883c410 ADDQ $0x10, SP
27 main.go:25 0x47a8ae 5d POPQ BP
28 main.go:25 0x47a8af c3 RET
29 main.go:16 0x47a8b0 4883c410 ADDQ $0x10, SP
30 main.go:16 0x47a8b4 5d POPQ BP
31 main.go:16 0x47a8b5 c3 RET
32 main.go:8 0x47a8b6 e845f0feff CALL runtime.morestack_noctxt.abi0(SB)
33 main.go:8 0x47a8bb eb83 JMP main.main(SB)
Al utilizar la herramienta go tool, esta nos indica amablemente qué sentencia corresponde a qué código ensamblador.
Dado que en esta ocasión aprenderemos sobre sentencias de comparación y de bifurcación if, debemos prestar atención a algunas líneas.
Instrucción CMPQ y comando JL
La instrucción CMPQ sirve para comparar tipos de datos de 4 bytes (4 palabras), y su etimología proviene de CoMPareQuadword, abreviado como CMPQ.
Si observamos la dirección de memoria 0x47a84e, encontramos la sentencia CMPQ os.Args+8(SB), $0x2.
En este caso, el programa compara el número de argumentos recibidos con el hexadecimal 0x2 (es decir, simplemente 2).
Posteriormente, mediante JL, realiza una comparación y un salto si los argumentos son menores a 2 (es decir, si solo el programa mismo es el argumento). Esto se abrevia como Jump, Less than.
Respecto a la comparación anterior, si los argumentos eran menores a 2, salta a la dirección 0x47a8b0, donde se encuentra JGE.
Sin embargo, dado que esta sentencia utiliza el registro AX, debemos conocer la naturaleza del valor almacenado en dicho registro.
Instrucción MOVQ
A continuación, debemos comprender cómo se almacena la dirección inicial de los datos utilizando el registro 'CX' y cómo se pretende extraer los datos reales tras leer la dirección.
Si observamos el rango 0x47858-0x47863, veremos que esta operación se realiza por etapas.
Primero, la dirección inicial del arreglo de argumentos se inserta en el registro CX mediante la instrucción MOVQ os.Args(SB), CX. En este punto, debemos comprender el tipo de dato string de Go.
El string de Go es una estructura, y esta estructura consta de 16 bytes, compuesta por dos datos de 8 bytes.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Representado visualmente, los primeros 8 bytes contienen la dirección de inicio de la cadena y los 8 bytes siguientes contienen la longitud de la cadena.
Por tanto, la dirección de la cadena se almacena en el registro AX y la longitud de la cadena en el registro BX.
CALL
En publicaciones anteriores, al observar las funciones de tipo runtime, aparecía la instrucción CALL.
Esta precede a las funciones utilizadas en Go y significa, literalmente, llamar a una función. Posteriormente, se utiliza la función CALL para convertir la cadena a un entero; en este proceso, no se hace visible en la función dónde se almacena dicho entero, ya que está abstraído.
Instrucción CMPQ y comando JGE
Volviendo a la dirección 0x47a86c, ¡la instrucción está comparando la dirección de la cadena con el número 0xa (10 en decimal)!
Esto significa que, dado que el programa ya no utiliza dicho argumento, se ha sobrescrito la ubicación de la cadena para crear el espacio de la variable entera x.
Esta es la realidad de la optimización agresiva que se lleva a cabo en lenguajes como Go.
Posteriormente, aparece la instrucción JGE, que es la abreviatura de Jump, Greater or Equals. Por tanto, esta sentencia cuestiona si el valor es mayor o igual en comparación con el objetivo.
En consecuencia, no es la sentencia x < 10 tal cual, ¡sino que la dirección de comparación de la sentencia está invertida a x < 10!
Esto se debe a que, en lenguaje máquina, saltar preventivamente cuando la condición no se cumple es más intuitivo y ahorra una instrucción en comparación con realizar la comparación cuando la condición se cumple y luego verificar nuevamente si no se cumple.
Dado que esta optimización es muy clásica, a diferencia del ejemplo strconv.Atoi visto anteriormente, es un patrón frecuente incluso en compiladores con niveles de optimización bastante bajos, por lo que resulta útil conocerlo.
Por lo tanto, aplicando estos puntos, es posible obtener un código fuente que, aunque diferente, sea 100% idéntico a nivel de ensamblador.
Ejemplo de código especular
Utilizando el script a continuación, se puede verificar que el script de Bash genera dos fuentes especulares al 100% y que, al observar solo main (excluyendo los metadatos que cambian en cada ejecución), se obtiene exactamente el mismo ensamblador.
1#!/usr/bin/env bash
2
3# 1. Inicialización completa de archivos y directorios residuales previos
4echo "[1/6] Cleaning up old artifacts..."
5rm -rf test_dir main_orig main_asm orig.asm asm.asm orig_pure.asm asm_pure.asm
6mkdir -p test_dir
7
8# 2. Creación del código fuente de la versión original (main.go)
9echo "[2/6] Generating main.go..."
10cat << 'EOF' > main.go
11package main
12
13import (
14 "os"
15 "strconv"
16)
17
18func main() {
19 if len(os.Args) < 2 {
20 return
21 }
22 x, _ := strconv.Atoi(os.Args[1])
23
24 s1 := "X is smaller than 10"
25 s2 := "X is larger or same as 10"
26
27 if x < 10 {
28 println(s1)
29 } else {
30 println(s2)
31 }
32}
33EOF
34
35# 3. Creación del código fuente de la versión especular (main_from_asm.go)
36# Estructura de operadores perfectamente sincronizada simétricamente para que el compilador utilice la plantilla de optimización (JGE) tal cual
37echo "[3/6] Generating main_from_asm.go..."
38cat << 'EOF' > main_from_asm.go
39package main
40
41import (
42 "os"
43 "strconv"
44)
45
46func main() {
47 if len(os.Args) < 2 {
48 return
49 }
50 x, _ := strconv.Atoi(os.Args[1])
51
52 s1 := "X is smaller than 10"
53 s2 := "X is larger or same as 10"
54
55 // Al tomar 10 como referencia y mantener la estructura x < 10, el compilador
56 // adopta exactamente el mismo mecanismo JGE y disposición de bloques que en main.go.
57 if x < 10 {
58 println(s1)
59 } else {
60 println(s2)
61 }
62}
63EOF
64
65# 4. Compilación en el mismo entorno de ruta de directorio y nombre de archivo
66echo "[4/6] Compiling both sources inside 'test_dir'..."
67cp main.go test_dir/main.go
68cd test_dir && go build -o ../main_orig main.go && cd ..
69
70rm test_dir/main.go
71cp main_from_asm.go test_dir/main.go
72cd test_dir && go build -o ../main_asm main.go && cd ..
73
74# 5. Extracción de la función ensambladora pura main.main utilizando go tool objdump
75echo "[5/6] Extracting main.main assembly sections..."
76go tool objdump -s "main\.main" main_orig > orig.asm
77go tool objdump -s "main\.main" main_asm > asm.asm
78
79# Eliminación de direcciones virtuales, offsets y datos de bytes de lenguaje máquina
80# Filtrado solo de los campos de conjunto de instrucciones puras (Opcode & Operands) que ejecutará la CPU
81awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
82awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
83
84# 6. Verificación de diff de la estructura de instrucciones de lenguaje máquina
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
88if diff orig_pure.asm asm_pure.asm > /dev/null; then
89 echo "===> [Éxito] ¡La lógica de lenguaje máquina de main.main en ambos binarios es 100% idéntica! <==="
90 echo "Se ha obtenido el mismo ensamblador al sincronizar perfectamente las directrices de la tubería de optimización del compilador."
91else
92 echo "===> [Fallo] Se encontraron diferencias en la estructura de las instrucciones ensambladoras. <==="
93 diff -u orig_pure.asm asm_pure.asm
94fi
95echo "------------------------------------------------------------"
Al ejecutar el código, se puede obtener la siguiente información.
1[1/6] Cleaning up old artifacts...
2[2/6] Generating main.go...
3[3/6] Generating main_from_asm.go...
4[4/6] Compiling both sources inside 'test_dir'...
5[5/6] Extracting main.main assembly sections...
6[6/6] Verifying assembly structural integrity via diff...
7------------------------------------------------------------
8===> [Éxito] ¡La lógica de lenguaje máquina de main.main en ambos binarios es 100% idéntica! <===
9Se ha obtenido el mismo ensamblador al sincronizar perfectamente las directrices de la tubería de optimización del compilador.
10------------------------------------------------------------
Conclusión
Podemos observar que, aunque los lenguajes de programación ofrecen muchas abstracciones, tras estas se ocultan optimizaciones muy interesantes y agresivas. Además, aprovechando estos puntos, fue posible crear códigos especulares con fuentes diferentes pero con un ensamblador idéntico. Si tiene interés en el bajo nivel y se encuentra con software propietario desarrollado en Go, no parece imposible realizar ingeniería inversa mediante el análisis del ensamblador para recuperar el código fuente.
Próxima lección
En la siguiente sesión, exploraremos la sentencia select-case, que posee otro tipo de interés junto con la sentencia If.