GoSuda

Sentencia If en el lenguaje Go

By Lee Yunjin
views ...

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.

struct8 byte8 byte
stringmem addressstring 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.