ELF-Tricks in Go


In diesem Hinweis erfahren Sie, wie Sie den Maschinencode einer Go-Funktion direkt zur Laufzeit abrufen, ihn mit einem Disassembler drucken und auf diesem Weg verschiedene Tricks wie das Abrufen der Adresse einer Funktion, ohne sie aufzurufen, ausführen.


Achtung : Dieser Mini-Artikel bringt Ihnen nichts Nützliches bei.


Funktionswert in Go


Bestimmen wir zunächst, was eine Go-Funktion ist und warum wir das Konzept des Funktionswerts benötigen.


Dies wird am besten im Dokument Funktionsaufrufe für Go 1.1 erläutert. Das Dokument ist nicht neu, aber die meisten darin enthaltenen Informationen sind immer noch relevant.


Auf der untersten Ebene ist es immer ein Zeiger auf ausführbaren Code, aber wenn wir anonyme Funktionen / Closures verwenden oder eine Funktion als interface{} , ist dieser Zeiger in einer Struktur verborgen.


Der Funktionsname selbst ist kein Ausdruck, daher funktioniert dieser Code nicht:


 // https://play.golang.org/p/wXeVLU7nLPs package main func add1(x int) int { return 1 } func main() { addr := &add1 println(addr) } 

compile: cannot take the address of add1

Gleichzeitig können wir den function value über denselben Funktionsnamen erhalten:


 // https://play.golang.org/p/oWqv_FQq4hy package main func add1(x int) int { return 1 } func main() { f := add1 // <-------- addr := &f println(addr) } 

Dieser Code wird gestartet, gibt aber die Adresse einer lokalen Variablen auf dem Stapel aus, was nicht genau das ist, was wir wollten. Wie oben erwähnt, ist die Adresse der Funktion jedoch noch vorhanden. Sie müssen nur wissen, wie Sie darauf zugreifen können.


Das reflect Paket hängt von diesem Implementierungsdetail ab, um reflect.Value.Call() erfolgreich auszuführen. Dort (reflect / makefunc.go) können Sie den nächsten Schritt ausspähen, um die Adresse der Funktion zu erhalten:


 dummy := makeFuncStub code := **(**uintptr)(unsafe.Pointer(&dummy)) 

Der obige Code demonstriert eine grundlegende Idee, die Sie zu einer Funktion verfeinern können:


 // funcAddr returns function value fn executable code address. func funcAddr(fn interface{}) uintptr { // emptyInterface is the header for an interface{} value. type emptyInterface struct { typ uintptr value *uintptr } e := (*emptyInterface)(unsafe.Pointer(&fn)) return *e.value } 

Die add1 Funktion add1 kann durch Aufrufen von funcAddr(add1) .


Block mit Maschinenfunktionscode abrufen


Nachdem wir die Adresse des Anfangs des Maschinencodes der Funktion haben, möchten wir den gesamten Maschinencode der Funktion erhalten. Hier müssen Sie feststellen können, wo der Code der aktuellen Funktion endet.


Wenn die x86-Architektur Anweisungen mit fester Länge hätte, wäre dies nicht so schwierig, und es könnten verschiedene Heuristiken hilfreich sein, darunter:


  • Am Ende des Funktionscodes steht in der Regel ein INT3 von INT3 Befehlen. Dies ist ein guter Marker für das Ende des Funktionscodes, der jedoch möglicherweise fehlt.
  • Funktionen mit einem Frame ungleich Null für den Stapel haben einen Prolog, der prüft, ob dieser Stapel erweitert werden muss. Wenn ja, wird unmittelbar nach dem Funktionscode ein Sprung zum Code und anschließend ein Sprung zum Start der Funktion ausgeführt. Der Code, an dem wir interessiert sind, wird in der Mitte sein.

Sie müssen die Anweisungen jedoch ehrlich dekodieren, da ein Byte-Bypass das INT3 Byte in einer anderen Anweisung finden kann. Das Berechnen der Länge eines zu überspringenden Befehls ist auch nicht so einfach, weil es x86 ist, Baby .


Die Adresse einer Funktion im Kontext des runtime wird manchmal als PC , um die Möglichkeit hervorzuheben, die Adresse irgendwo innerhalb der Funktion zu verwenden und nicht nur den Einstiegspunkt der Funktion. Das Ergebnis von funcAddr kann als Argument für die Funktion runtime.FuncForPC() werden, um runtime.Func ohne die Funktion selbst aufzurufen. Durch unsichere Neujahrs-Transformationen können wir auf runtime._func zugreifen, was informativ, aber nicht sehr nützlich ist: Es gibt keine Informationen über die Größe des Funktionscode-Blocks.


Es scheint, dass wir ohne die Hilfe von ELFs nicht fertig werden können.


Für Plattformen, auf denen ausführbare Dateien ein anderes Format haben, bleibt der größte Teil des Artikels relevant, Sie müssen jedoch nicht debug/elf , sondern ein anderes Paket aus debug .

Die ELF, die sich in Ihrem Programm versteckt


Die benötigten Informationen sind bereits in den Metadaten der ELF- Datei enthalten.


Über os.Args[0] wir auf die ausführbare Datei selbst zugreifen und bereits die Symboltabelle daraus os.Args[0] .


 func readELF() (*elf.File, error) { f, err := os.Open(os.Args[0]) if err != nil { return nil, fmt.Errorf("open argv[0]: %w", err) } return elf.NewFile(f) } 

Suchen Sie nach einem Zeichen in elf.File


Alle Zeichen können mit der File.Symbols() -Methode File.Symbols() werden. Diese Methode gibt []elf.Symbol , das das Feld []elf.Symbol enthält - dies ist die "Funktionsgröße", nach der wir Symbol.Size . Das Feld Symbol.Value muss mit dem von funcAddr Wert funcAddr .


Sie können nach dem gewünschten Symbol entweder anhand der Adresse ( Symbol.Value ) oder anhand des Namens ( Symbol.Name ) Symbol.Name . Wenn die Zeichen nach Namen sortiert wären, wäre es möglich, sort.Search() , aber das ist nicht so:


Die Symbole werden in der Reihenfolge aufgelistet, in der sie in der Datei erscheinen.

Wenn Sie häufig Zeichen in der Tabelle suchen müssen, sollten Sie einen zusätzlichen Index erstellen, z. B. über map[string]*elf.Symbol oder map[uintptr]*elf.Symbol .


Da wir bereits wissen, wie wir die Adresse einer Funktion anhand ihres Werts ermitteln, werden wir danach suchen:


 func elfLookup(f *elf.File, value uint64) *elf.Symbol { symbols, err := f.Symbols() if err != nil { return nil } for _, sym := range symbols { if sym.Value == value { return &sym } } return nil } 

Hinweis : Damit dieser Ansatz funktioniert, benötigen wir eine Zeichentabelle. Wenn die Binärdatei mit -ldflags "-s" , gibt elfLookup() immer nil . Wenn Sie das Programm über go run , tritt möglicherweise dasselbe Problem auf. Für Beispiele aus dem Artikel wird empfohlen, " go build " oder " go install " go install , um ausführbare Dateien abzurufen.

Funktionscode der Maschine abrufen


In Kenntnis des Adressbereichs, in dem sich der ausführbare Code befindet, muss er zur bequemen Verarbeitung nur noch in Form von []byte abgerufen werden.


 func funcCode(addr uintptr) ([]byte, error) { elffile, err := readELF() if err != nil { return nil, fmt.Errorf("read elf: %w", err) } sym := elfLookup(elffile, uint64(addr)) if sym == nil { return nil, fmt.Errorf("can't lookup symbol for %x", addr) } code := *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{ Data: addr, Len: int(sym.Size), Cap: int(sym.Size), })) return code, nil } 

Dieser Code wurde absichtlich zu Demonstrationszwecken vereinfacht. Sie sollten ELF jedes Mal lesen und eine lineare Suche in der Tabelle durchführen.


Das Ergebnis der Funktion funcCode() ist ein Slice mit Bytes des Maschinenfunktionscodes. Sie sollte funcAddr() Ergebnis des Aufrufs von funcAddr() .


 code, err := funcCode(funcAddr(add1)) if err != nil { log.Panicf("can't get function code: %v", err) } fmt.Printf("% x\n", code) // => 48 8b 44 24 08 48 ff c0 48 89 44 24 10 c3 

Maschinencode zerlegen


Um das Lesen des Maschinencodes zu vereinfachen, verwenden wir einen Disassembler.


Ich bin mit den Projekten zydis und Intel XED am besten vertraut, daher liegt meine Wahl in erster Linie bei ihnen.


Für Go können Sie die go-zydis-Bindung verwenden , die für unsere Aufgabe gut genug und einfach zu installieren ist.


Beschreiben wir die Abstraktion der "Umgehung von Maschinenbefehlen", mit deren Hilfe es dann möglich ist, andere Operationen zu implementieren:


 func walkDisasm(code []byte, visit func(*zydis.DecodedInstruction) error) error { dec := zydis.NewDecoder(zydis.MachineMode64, zydis.AddressWidth64) buf := code for len(buf) > 0 { instr, err := dec.Decode(buf) if err != nil { return err } if err := visit(instr); err != nil { return err } buf = buf[int(instr.Length):] } return nil } 

Diese Funktion nimmt einen Maschinencode als Eingabe und ruft für jeden decodierten Befehl eine Rückruffunktion auf.


Darauf basierend können wir das printDisasm schreiben, das wir printDisasm :


 func printDisasm(code []byte) error { const ZYDIS_RUNTIME_ADDRESS_NONE = math.MaxUint64 formatter, err := zydis.NewFormatter(zydis.FormatterStyleIntel) if err != nil { return err } return walkDisasm(code, func(instr *zydis.DecodedInstruction) error { s, err := formatter.FormatInstruction(instr, ZYDIS_RUNTIME_ADDRESS_NONE) if err != nil { return err } fmt.Println(s) return nil }) } 

Wenn wir printDisasm für den Funktionscode add1 , erhalten wir das lang erwartete Ergebnis:


 mov rax, [rsp+0x08] inc rax mov [rsp+0x10], rax ret 

Ergebnisvalidierung


Jetzt werden wir versuchen, sicherzustellen, dass der im vorherigen Abschnitt erhaltene Assembler-Code korrekt ist.


Da wir bereits eine kompilierte Binärdatei haben, können Sie den mit Go gelieferten objdump :


 $ go tool objdump -s 'add1' exe TEXT main.add1(SB) example.go example.go:15 0x4bb760 488b442408 MOVQ 0x8(SP), AX example.go:15 0x4bb765 48ffc0 INCQ AX example.go:15 0x4bb768 4889442410 MOVQ AX, 0x10(SP) example.go:15 0x4bb76d c3 RET 

Alles läuft zusammen, nur die Syntax ist etwas anders, was erwartet wird.


Methodenausdrücke


Wenn wir dasselbe mit Methoden tun müssen, verwenden wir anstelle des Funktionsnamens den Methodenausdruck .


Nehmen wir an, unser add1 ist nicht wirklich eine Funktion, sondern eine adder :


 type adder struct{} func (adder) add1(x int) int { return x + 2 } 

Der Aufruf zum funcAddr(adder.add1) der Adresse der Funktion sieht dann wie funcAddr(adder.add1) .


Fazit


Ich bin nicht zufällig auf diese Dinge gestoßen, und vielleicht erzähle ich Ihnen in einem der folgenden Artikel, wie es geplant war, all diese Mechanismen zu nutzen. In der Zwischenzeit schlage ich vor, diesen Artikel als oberflächliche Beschreibung zu behandeln, wie runtime und reflect unsere Go-Funktionen durch Funktionswerte darstellen.


Liste der verwendeten Ressourcen:


Source: https://habr.com/ru/post/de482392/


All Articles