A hex editor and nothing to lose - Binary patching Golang to fix net/http

Jul 23 2024

This article is going to look at patching Golang code at the assembly level to modify some behaviour in the net/http standard library. The Golang maintainers aren’t super interested in changing this bit of behavior, so lets fix it ourselves!

Given that a computer is effectively a rock that we taught how to do math really fast by filling it with lightning, Golang and its higher level concepts are really just there to make our (humans) interaction with the lightning-rock more pleasant. At the end of the day, the Golang is compiled down into machine code. Machine code that is executing on my computer. Who says we can’t reach in there and tweak some things to fix up net/http. Distasteful? Maybe. Impossible? Absolutely not.

They think they can come into my house and tell my sand-we-put-lightning-into what to do?!

The Problem

Feel free to skip this section if you’re not interested in the why, and only the how.

A while ago I wrote a little command-line HTTP intercept proxy called glorp. While using this proxy to intercept some mobile application traffic, I noticed an issue. The mobile application expected an x-header-value header returned from the server, and for some reason when glorp was intercepting traffic X-Header-Value arrived instead. Turns out this is due to Golang’s underlying net/http object canonicalizing any headers it’s parsing. According the the HTTP RFC, clients are supposed to ignore header cases. In my use case, the proxy is meant to be more-or-less invisible and the client’s RFC compliance shouldn’t matter.

The Golang devs don’t seem particularly interested in changing this behavior. There are a few ways to get around it if you’re manually making requests, in my case I’m using the Martian HTTP proxy library and don’t have that level of control.

And so, we reach the crux of the problem. Any http.Request or http.Response object will have its headers magically canonicalized. Golang doesn’t provide us with any official method for overwriting these library methods globally, and so we need to find a different solution! In this case - figuring out which methods are responsible for the canonicalization, and patching them out at the assembly level so they no longer mess with our headers. This should mean we can continue using whatever net/http fueled proxy library we like, and our non-RFC compliant clients can still be tested.

Basically: send a lower-cased header and Martian (the underlying proxy library used by glorp) uses net/http to handle the request/response, and so the headers change.

curl:

$ curl -x 127.0.0.1:8080 -v -H 'x-foo: bar' http://example.com
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET http://example.com/ HTTP/1.1
> Host: example.com
> User-Agent: curl/7.74.0
> Accept: */*
> Proxy-Connection: Keep-Alive
> x-foo: bar
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK

glorp:

╔═══════════════Request═══════════════╗┌───────────────Response───────────────┐
║GET / HTTP/1.1                       ║│HTTP/1.1 200 OK                       
║Host: example.com                    ║│Content-Length: 1256                  
║Content-Length: 0                    ║│Accept-Ranges: bytes                  
║Accept: */*                          ║│Age: 493434                           
║Accept-Encoding: gzip                ║│Cache-Control: max-age=604800         
║Proxy-Connection: Keep-Alive         ║│Content-Type: text/html; charset=UTF-8│
║User-Agent: curl/7.74.0              ║│Date: Wed, 17 Jul 2024 11:46:39 GMT   
X-Foo: bar                           ║│Etag: "3147526947"                    

Here’s another example, the httpstat.us service supports client and server headers through a X-HttpStatus-Response- header. net/http canonicalizes the request header to X-Httpstatus-Response (with a lower case s) and the functionality breaks (no Foo: bar returned in the 2nd example).

~$ curl -i -H "X-HttpStatus-Response-Foo: bar" http://httpstat.us/200 ; echo
HTTP/1.1 200 OK
Content-Length: 6
Content-Type: text/plain
Date: Thu, 18 Jul 2024 11:11:40 GMT
Server: Kestrel
Set-Cookie: ARRAffinity=cad7544e5c977911a6e3743f3e7321e348091b23dfc10c88320a5f16d984f67b;Path=/;HttpOnly;Domain=httpstat.us
Request-Context: appId=cid-v1:3548b0f5-7f75-492f-82bb-b6eb0e864e53
Foo: bar   <-- this should always appear

200 OK


~$ curl -x 127.0.0.1:8080 -i -H "X-HttpStatus-Response-Foo: bar" http://httpstat.us/200 ; echo
HTTP/1.1 200 OK
Content-Length: 6
Content-Type: text/plain
Date: Thu, 18 Jul 2024 11:11:47 GMT
Request-Context: appId=cid-v1:3548b0f5-7f75-492f-82bb-b6eb0e864e53
Server: Kestrel
Set-Cookie: ARRAffinity=cad7544e5c977911a6e3743f3e7321e348091b23dfc10c88320a5f16d984f67b;Path=/;HttpOnly;Domain=httpstat.us
         <-- where is my header?

200 OK

Update 2024-08-22 - Golang Builds and Overlays

Golang supports an -overlay flag which lets us overwrite filesystem paths at build time. We can use this to replace the relevant internal library files on disk in the Golang build, and avoid binary patching all together! Shout out to Quentin Quaadgras from the IQ Hive team for putting me onto this. This technique will also help when Go inevitably removes the go:linkname compiler directive. Thanks Quentin!

Using overlays is pretty simple. We copy out the file we want to replace (in this case the textproto reader.go file), modify it, then use the overlay when building to point /usr/local/go/src/net/textproto/reader.go to our patched file.

:~/go/src/github.com/denandz/glorp$ cat overlay.json 
{"Replace":{
  "/usr/local/go/src/net/textproto/reader.go": "patches/reader.go"
}}
:~/go/src/github.com/denandz/glorp$ go build --overlay=overlay.json

The patches/reader.go file has the modifications explained further in this article, and now glorp no longer canonicalizes header without us having to get our hands dirty with hand-rolled bytecode. Success!

       ID                      URL                StatusSizeTimeDate               Method 41398e27fa72ac91│http://httpstat.us/200            │200   │338 │375 │22-08-2024 01:32:48│GET    
                                                                                                 
                                                                                                 
                                                                                                 
                                                                                                 
                                                                                                 
                                                                                                 
                                                                                                 
                                                                                                 
                                                                                                 
┌───────────────────Request────────────────────┐┌───────────────────Response────────────────────┐
│GET /200 HTTP/1.1                             ││HTTP/1.1 200 OK                                
│Host: httpstat.us                             ││Content-Length: 6                              
│Content-Length: 0                             ││Content-Type: text/plain                       
│Accept: */*                                   ││Date: Thu, 22 Aug 2024 01:32:48 GMT            
│Accept-Encoding: gzip                         ││Request-Context: appId=cid-v1:3548b0f5-7f75-492│
│Proxy-Connection: Keep-Alive                  ││f-82bb-b6eb0e864e53                            
│User-Agent: curl/7.74.0                       ││Server: Kestrel                                
X-HttpStatus-Response-lowercaaaase: wehhh     ││Set-Cookie: ARRAffinity=2cadefbda2fb46191065e60│
                                              ││33735a4420f58c895b9dce0facf057d5b1d7c0332;Path=│
│⠀                                             ││/;HttpOnly;Domain=httpstat.us                  
                                              ││lowercaaaase: wehhh                            
                                              ││                                               
                                              ││200 OK⠀                                        
                                              ││                                               
                                              ││                                               
                                              ││                                               
└──────────────────────────────────────────────┘└───────────────────────────────────────────────┘

The Plan

The plan is relatively simple. Find the net/http function that’s responsible for canonicalizing header keys in the compiled assembly, and replace it. Operating at this level means we can alter functionality in the stdlib used by all the other libraries without having to worry about any of the higher-level programming concepts Golang may or may not have.

We need to understand Golang’s calling convention (https://go.googlesource.com/go/+/refs/heads/dev.regabi/src/cmd/compile/internal-abi.md) and how the resulting assembly is put together. An easy way to start is to put together a simple Go program and then pick it apart with the Rizin disassembler. I’m using the Rizin CLI for this article since it gives me nice terminal output I can copy-paste, I suggest checking out Cutter (the GUI version) instead if you’re just getting started.

Here’s a simple program that defines two methods, each one takes a single integer parameter and returns a string.

package main
import (
        "fmt"
)

//go:noinline
func retString1(i int) string {
        if i == 1 {
                return "foo"
        } else {
                return "bar"
        }
}

//go:noinline
func retString2(i int) string {
        return "baz"
}

func main() {
        fmt.Printf("%v %v\n", retString1, retString2)
        r := retString1(1)
        fmt.Println(r)
}

The noinline makes sure the Go compiler doesn’t inline these very simple functions directly into main. Executing the above code prints the memory address of the two methods and the string “foo”:

$ ./main 
0x482360 0x4823a0
foo

After building with go build main.go, we can check out those two methods with Rizin. If the commands don’t make much sense, you can look at the rizin handbook. What you mainly need to know for this article is:

afl - list functions
pdf - disassemble the function at the current address
s - seek to an address
pd - print disassembly
wa - write assembly
:~/go/src/test$ rizin -Aw main
[x] Analyze all flags starting with sym. and entry0 (aa)
[x] Find function and symbol names from golang binaries
[x] Found go 1.20+ pclntab data.
[x] Recovered 1551 symbols and saved them at sym.go.*
[x] Recovered 28 go packages
[x] Analyze all flags starting with sym.go. (aF @@f:sym.go.*)
[x] Recovering go strings from bin maps
[x] Analyze all instructions to recover all strings used in sym.go.*
[x] Recovered 1932 strings from the sym.go.* functions.
[x] Analyze function calls
[x] Analyze len bytes of instructions for references
[x] Check for classes
[x] Analyze local variables and arguments
[x] Type matching analysis for all functions
[x] Applied 0 FLIRT signatures via sigdb
[x] Propagate noreturn information
[x] Integrate dwarf function information.
[x] Resolve pointers to data sections
[x] Use -AA or aaaa to perform additional experimental analysis.
 -- Enhance your graphs by increasing the size of the block and graph.depth eval variable.
[0x00463940]> afl | grep retString
0x00482360    3 33           dbg.main.retString1
0x004823a0    1 13           dbg.main.retString2
[0x00463940]> s 0x00482360
[0x00482360]> pdf
            ; CALL XREF from dbg.main.main @ 0x482439
            ;-- sym.go.main.retString1:
 void main.retString1(int i, struct string ~r0)
           ; arg int i @ rax
           ; arg struct string ~r0 @ ...
           0x00482360      cmp   rax, 1                               ; main.go:9 if i == 1 { ; 1
       ┌─< 0x00482364      jne   0x482373
          0x00482366      lea   rax, [str.foo]                       ; main.go:10 return "foo" ; 0x49ccc1 ; "foobarbaznil125625NaNEOFintmapptr...finobjgc %: gp  *(in  n= -   P m=  MPC=],  < end > ]:\n???pc=  Gadxaesshaavxfmatrue3125-Inf+"
          0x0048236d      mov   ebx, 3
          0x00482372      ret
       └─> 0x00482373      lea   rax, [str.bar]                       ; main.go:12 return "bar" ; 0x49ccc4 ; "barbaznil125625NaNEOFintmapptr...finobjgc %: gp  *(in  n= -   P m=  MPC=],  < end > ]:\n???pc=  Gadxaesshaavxfmatrue3125-Inf+Inf"
           0x0048237a      mov   ebx, 3
           0x0048237f      nop
           0x00482380      ret
[0x00482360]> s 0x004823a0
[0x004823a0]> pdf
            ;-- sym.go.main.retString2:
 void main.retString2(int i, struct string ~r0)
           ; arg int i @ rax
           ; arg struct string ~r0 @ ...
           0x004823a0      lea   rax, [str.baz]                       ; main.go:18 return "baz" ; 0x49ccc7 ; "baznil125625NaNEOFintmapptr...finobjgc %: gp  *(in  n= -   P m=  MPC=],  < end > ]:\n???pc=  Gadxaesshaavxfmatrue3125-Inf+Inffil"
           0x004823a7      mov   ebx, 3
           0x004823ac      ret
[0x004823a0]> 

The first argument gets passed via rax, as is the return value. Golang used to use stack-addresses for passing parameters, but that was changed to using a register based calling convention. I mention this as who knows what the Golang calling convention will be in the future. Don’t take my word for it, and instead check the ABI documentation.

The plan at this stage is to overwrite the first instruction of retString1 with a near-jump to retString. Since they take the same parameters and return the same types, this should be no problem! We’re using a near-jump so we don’t have to touch any of the registered used for passing arguments.

[0x00482360]> afl | grep retStri
0x00482360    3 33           dbg.main.retString1
0x004823a0    1 13           dbg.main.retString2
[0x004823a0]> s 0x00482360
[0x00482360]> pd 2
            ; CALL XREF from dbg.main.main @ 0x482439
            ;-- sym.go.main.retString1:
 void main.retString1(int i, struct string ~r0)
           ; arg int i @ rax
           ; arg struct string ~r0 @ ...
           0x00482360      cmp   rax, 1                               ; main.go:9 if i == 1 { ; 1
       ┌─< 0x00482364      jne   0x482373
[0x00482360]> wa "jmp 0x004823a0"
[0x00482360]> pd 2
            ; CALL XREF from dbg.main.main @ 0x482439
            ;-- sym.go.main.retString1:
 void main.retString1(int i, struct string ~r0)
           ; arg int i @ rax
           ; arg struct string ~r0 @ ...
       ┌─< 0x00482360      jmp   dbg.main.retString2                  ; main.go:9 if i == 1 { ; dbg.main.retString2
          0x00482362      clc
:~/go/src/test$ ./main 
0x482360 0x4823a0
baz

Et voila, we have a plan. Identify the parts in the net/http library that are responsible for canonicalizing headers and overwrite them with jump instructions to our own methods that are loyal to us.

Test Code

First, I set up some toy code that I could use to test out the techniques without having to deal with the wider glorp binary and all its associated baggage. This was a simple HTTP client that made a request to localhost. I have called it httppatchy.

import (
        "bufio"
        "fmt"
        "net/http"
)

func main() {

        client := &http.Client{}
        req, _ := http.NewRequest("GET", "http://localhost:8000", nil)

        fmt.Printf("%v\n", retString)

        req.Header.Set("foo", "bar")
        resp, err := client.Do(req)
        if err != nil {
                panic(err)
        }
        defer resp.Body.Close()

        fmt.Println("Response status: ", resp.Status)

        scanner := bufio.NewScanner(resp.Body)
        for i := 0; scanner.Scan() && i < 5; i++ {
                fmt.Println(scanner.Text())
        }

        if err := scanner.Err(); err != nil {
                panic(err)
        }
}

Running a netcat listener and executing the code shows the issue:

$ nc -vv -k -l -p 8000
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on :::8000
Ncat: Listening on 0.0.0.0:8000
Ncat: Connection from ::1.
Ncat: Connection from ::1:42428.
GET / HTTP/1.1
Host: localhost:8000
User-Agent: Go-http-client/1.1
Foo: bar
Accept-Encoding: gzip

foo: bar is changed to Foo: bar. Excellent! We can now attempt to patch this file to change that behavior in net/http. Digging through the https://pkg.go.dev/net/http#Header code, we can find that the header Get, Set and Add methods all call textProto.MIMEHeader(h):

// Add adds the key, value pair to the header.
// It appends to any existing values associated with key.
// The key is case insensitive; it is canonicalized by
// [CanonicalHeaderKey].
func (h Header) Add(key, value string) {
        textproto.MIMEHeader(h).Add(key, value)
}

// Set sets the header entries associated with key to the
// single element value. It replaces any existing values
// associated with key. The key is case insensitive; it is
// canonicalized by [textproto.CanonicalMIMEHeaderKey].
// To use non-canonical keys, assign to the map directly.
func (h Header) Set(key, value string) {
        textproto.MIMEHeader(h).Set(key, value)
}

// Get gets the first value associated with the given key. If
// there are no values associated with the key, Get returns "".
// It is case insensitive; [textproto.CanonicalMIMEHeaderKey] is
// used to canonicalize the provided key. Get assumes that all
// keys are stored in canonical form. To use non-canonical keys,
// access the map directly.
func (h Header) Get(key string) string {
        return textproto.MIMEHeader(h).Get(key)
}

Some more digging into MIMEHeader finds CanonicalMIMEHeaderKey, which we’ll need to overwrite.

// CanonicalMIMEHeaderKey returns the canonical format of the
// MIME header key s. The canonicalization converts the first
// letter and any letter following a hyphen to upper case;
// the rest are converted to lowercase. For example, the
// canonical key for "accept-encoding" is "Accept-Encoding".
// MIME header keys are assumed to be ASCII only.
// If s contains a space or invalid header field bytes, it is
// returned without modifications.
func CanonicalMIMEHeaderKey(s string) string {
        // Quick check for canonical encoding.
        upper := true
        for i := 0; i < len(s); i++ {
                c := s[i]
                if !validHeaderFieldByte(c) {
                        return s
                }
                if upper && 'a' <= c && c <= 'z' {
                        s, _ = canonicalMIMEHeaderKey([]byte(s))
                        return s
                }
                if !upper && 'A' <= c && c <= 'Z' {
                        s, _ = canonicalMIMEHeaderKey([]byte(s))
                        return s
                }
                upper = c == '-'
        }
        return s
}

We need to patch this function to simply return whatever string is passed to it. Now, you may be thinking…

If the first argument and the return value are both passed in rax, can’t we just replace the first instruction with a ret and it’ll all magically work?!

Probably! But we also need to patch func canonicalMIMEHeaderKey(a []byte) (_ string, ok bool) for glorp and its underlying proxy library to work right. Let’s stick with the plan! Define a new function that does what we want, and then modify the binary to jump to it. I like to think a simple jump is more likely to tolerate other calling convention changes Golang may make in the future. Only time will tell for that one, though.

We add the following code to httppatchy.go and then do the Rizin dance again. The pointer print is to make sure the compiler doesn’t optimise out the uncalled method.

package main

import (
        "bufio"
        "fmt"
        "net/http"
)

//go:noinline
func retString(s string) string {
        return s
}

func main() {

        client := &http.Client{}
        req, _ := http.NewRequest("GET", "http://localhost:8000", nil)

        fmt.Printf("%v\n", retString)

We go build and the patch the binary:

[0x0046ed60]> afl | grep retS
0x0062cb20    1 6            dbg.main.retString
[0x0046ed60]> afl | grep Canonical
0x00531520   12 262          dbg.crypto/internal/edwards25519.(*Scalar).SetCanonicalBytes
0x005d0700   15 261          dbg.net/textproto.CanonicalMIMEHeaderKey
[0x0046ed60]> s 0x005d0700
[0x005d0700]> pd 2
            ; XREFS(27)
            ;-- net/textproto.CanonicalMIMEHeaderKey:
            ;-- sym.go.net_textproto.CanonicalMIMEHeaderKey:
            ;-- dbg.net_textproto.CanonicalMIMEHeaderKey:
 void net/textproto.CanonicalMIMEHeaderKey(struct string s, struct string ~r0)
           ; var int64_t var_48h @ stack - 0x48
           ; var int64_t var_28h @ stack - 0x28
           ; var int64_t arg_8h @ stack + 0x8
           ; var int64_t arg_10h @ stack + 0x10
           ; arg struct string s @ COMPOSITE
           ; arg struct string ~r0 @ ...
           ; var bool upper @ rdx
           0x005d0700      cmp   rsp, qword [r14 + 0x10]              ; reader.go:632 func CanonicalMIMEHeaderKey(s string) string {
       ┌─< 0x005d0704      jbe   0x5d07e4
[0x005d0700]> wa "jmp 0x0062cb20"
[0x005d0700]> pd 2
            ; XREFS(27)
            ;-- net/textproto.CanonicalMIMEHeaderKey:
            ;-- sym.go.net_textproto.CanonicalMIMEHeaderKey:
            ;-- dbg.net_textproto.CanonicalMIMEHeaderKey:
 void net/textproto.CanonicalMIMEHeaderKey(struct string s, struct string ~r0)
           ; var int64_t var_48h @ stack - 0x48
           ; var int64_t var_28h @ stack - 0x28
           ; var int64_t arg_8h @ stack + 0x8
           ; var int64_t arg_10h @ stack + 0x10
           ; arg struct string s @ COMPOSITE
           ; arg struct string ~r0 @ ...
           ; var bool upper @ rdx
       ┌─< 0x005d0700      jmp   dbg.main.retString                   ; reader.go:632 func CanonicalMIMEHeaderKey(s string) string { ; dbg.main.retString
          0x005d0705      xchg  dl, bl

Re-run ./main and hey presto, no more canonicalization:

~/go/src/httppatchy$ nc -vv -k -l -p 8000
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on :::8000
Ncat: Listening on 0.0.0.0:8000
Ncat: Connection from ::1.
Ncat: Connection from ::1:37298.
GET / HTTP/1.1
Host: localhost:8000
User-Agent: Go-http-client/1.1
foo: bar
Accept-Encoding: gzip

Patching Glorp

The plan seemed to work. Let’s apply the same principle to glorp. Write some methods and patch in some jump instructions! I’ve added in the methods into the proxy.go file:

diff --git a/proxy/proxy.go b/proxy/proxy.go
index 6c21480..3762feb 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -17,6 +17,18 @@ import (
        "github.com/google/martian/v3/mitm"
 )
 
+
+// NonCanonicalMIMEHeaderKey
+func NonCanonicalMIMEHeaderKey(s string) string {
+       return s
+}
+
+// noncanonicalMIMEHeaderKey
+func noncanonicalMIMEHeaderKey(a []byte) (_ string, ok bool) {
+       return string(a), true
+}
+
+
 // Config - struct that holds the proxy config
 type Config struct {
        Port  uint   // port to listen on, default 8080
@@ -46,6 +58,8 @@ func StartProxy(logger *modifier.Logger, config *Config) *martian.Proxy {
 
        config.checkConfig()
 
+       log.Printf("%v %v\n", NonCanonicalMIMEHeaderKey, noncanonicalMIMEHeaderKey)
+
        p := martian.NewProxy()
 
        tr := &http.Transport{

Again, compiled and patched:

$ rizin -Aw glorp 
[x] Analyze all flags starting with sym. and entry0 (aa)
[x] Find function and symbol names from golang binaries
[x] Found go 1.20+ pclntab data.
[x] Recovered 7365 symbols and saved them at sym.go.*
[x] Recovered 179 go packages
[x] Analyze all flags starting with sym.go. (aF @@f:sym.go.*)
[x] Recovering go strings from bin maps
[x] Analyze all instructions to recover all strings used in sym.go.*
[x] Recovered 9747 strings from the sym.go.* functions.
[x] Analyze function calls
[x] Analyze len bytes of instructions for references
[x] Check for classes
[x] Analyze local variables and arguments
[x] Type matching analysis for all functions
[x] Applied 0 FLIRT signatures via sigdb
[x] Propagate noreturn information
[x] Integrate dwarf function information.
[x] Resolve pointers to data sections
[x] Use -AA or aaaa to perform additional experimental analysis.
 -- Use 'rz-bin -ris' to get the import/export symbols of any binary.
[0x00470820]> afl | grep CanonicalMIME
0x00615160   15 261          dbg.net/textproto.CanonicalMIMEHeaderKey
0x0072c180    1 6            dbg.github.com/denandz/glorp/proxy.NonCanonicalMIMEHeaderKey
[0x00470820]> s 0x00615160
[0x00615160]> wa "jmp 0x0072c180"
^C
[0x00615160]> 
[0x00615160]> afl | grep canonicalMIME
0x00615280   25 421          dbg.net/textproto.canonicalMIMEHeaderKey
0x0072c1a0    3 86           dbg.github.com/denandz/glorp/proxy.noncanonicalMIMEHeaderKey
[0x00615160]> pd 2
            ; XREFS(21)
            ;-- net/textproto.CanonicalMIMEHeaderKey:
            ;-- sym.go.net_textproto.CanonicalMIMEHeaderKey:
            ;-- dbg.net_textproto.CanonicalMIMEHeaderKey:
 void net/textproto.CanonicalMIMEHeaderKey(struct string s, struct string ~r0)
           ; var int64_t var_48h @ stack - 0x48
           ; var int64_t var_28h @ stack - 0x28
           ; var int64_t arg_8h @ stack + 0x8
           ; var int64_t arg_10h @ stack + 0x10
           ; arg struct string s @ COMPOSITE
           ; arg struct string ~r0 @ ...
           ; var bool upper @ rdx
       ┌─< 0x00615160      jmp   dbg.github.com/denandz/glorp/proxy.NonCanonicalMIMEHeaderKey ; reader.go:632 func CanonicalMIMEHeaderKey(s string) string { ; dbg.github.com/denandz/glorp/proxy.NonCanonicalMIMEHeaderKey
          0x00615165      xchg  dl, bl
[0x00615160]> s 0x00615280
[0x00615280]> wa "jmp 0x0072c1a0"
[0x00615280]> pd 2
            ; CALL XREF from dbg.net/textproto.readMIMEHeader @ 0x614a8c
            ; CALL XREFS from dbg.net/textproto.CanonicalMIMEHeaderKey @ 0x615212, 0x61522d
            ; CODE XREF from dbg.net/textproto.canonicalMIMEHeaderKey @ 0x615420
            ;-- net/textproto.canonicalMIMEHeaderKey:
            ;-- sym.go.net_textproto.canonicalMIMEHeaderKey:
            ;-- dbg.net_textproto.canonicalMIMEHeaderKey:
 void net/textproto.canonicalMIMEHeaderKey(struct []uint8 a, struct string ~r0, bool ok)
           ; var int64_t arg4 @ rcx
           ; var runtime.hmap *h @ rsi
           ; var int64_t arg_8h @ stack + 0x8
           ; var int64_t arg_18h @ stack + 0x18
           ; arg struct []uint8 a @ COMPOSITE
           ; arg struct string ~r0 @ ...
           ; arg bool ok @ ...
           ; var bool noCanon @ rdx
           ; var bool upper @ rdx
       ┌─< 0x00615280      jmp   dbg.github.com/denandz/glorp/proxy.noncanonicalMIMEHeaderKey ; reader.go:727 func canonicalMIMEHeaderKey(a []byte) (_ string, ok bool) { ; dbg.github.com/denandz/glorp/proxy.noncanonicalMIMEHeaderKey
          0x00615285      xchg  byte [rsi + 1], ch
[0x00615280]> exit

And finally testing with a curl command:

:~$ curl -x 127.0.0.1:8080 -i -H "X-HttpStatus-Response-lowercaaaase: wehhh" http://httpstat.us/200 ; echo
HTTP/1.1 200 OK
Content-Length: 6
Content-Type: text/plain
Date: Thu, 18 Jul 2024 11:18:07 GMT
Request-Context: appId=cid-v1:3548b0f5-7f75-492f-82bb-b6eb0e864e53
Server: Kestrel
Set-Cookie: ARRAffinity=cad7544e5c977911a6e3743f3e7321e348091b23dfc10c88320a5f16d984f67b;Path=/;HttpOnly;Domain=httpstat.us
lowercaaaase: wehhh

200 OK
       ID                URL          StatusSizeTimeDate               M…151f2cd7c211b6f2│http://httpstat.us/200│200   │338 │431 │18-07-2024 11:18:07│G…
                                                                                
                                                                                
                                                                                
                                                                                
                                                                                
                                                                                
                                                                                
                                                                                
┌───────────────Request────────────────┐┌───────────────Response───────────────┐
│GET /200 HTTP/1.1                     ││HTTP/1.1 200 OK                       
│Host: httpstat.us                     ││Content-Length: 6                     
│Content-Length: 0                     ││Content-Type: text/plain              
│Accept: */*                           ││Date: Thu, 18 Jul 2024 11:18:07 GMT   
│Accept-Encoding: gzip                 ││Request-Context: appId=cid-v1:3548b0f5│
│Proxy-Connection: Keep-Alive          ││-7f75-492f-82bb-b6eb0e864e53          
│User-Agent: curl/7.74.0               ││Server: Kestrel                       
│X-HttpStatus-Response-lowercaaaase: we││Set-Cookie: ARRAffinity=cad7544e5c9779│
│hhh                                   ││11a6e3743f3e7321e348091b23dfc10c88320a│
                                      ││5f16d984f67b;Path=/;HttpOnly;Domain=ht│
│⠀                                     ││tpstat.us                             
                                      ││lowercaaaase: wehhh                   
                                      ││                                      
└──────────────────────────────────────┘└──────────────────────────────────────┘
1 Proxy  2 Sitemap  3 Replay  4 Log  5 Save/Load                                

Success! No more header canonicalization!

Dynamically patching at runtime

Tweaking things with Rizin is fine and dandy, but it would be nice to have this just Magically Work (tm). At least on amd64 linux where I’m using glorp most often. Again, all its Golang-ness aside, it’s a Linux ELF binary. So, we can tweak the permissions on the right memory page at run time, assemble a few jump instructions by hand, and live happily ever after. The function byte code we care about is all in the .text segment of the ELF binary:

[0x00615160]> iS
paddr      size     vaddr      vsize    align perm name               type     flags           
-----------------------------------------------------------------------------------------------
0x00000000 0x0      ---------- 0x0      0x0   ----                    NULL     
0x00001000 0x34c176 0x00401000 0x34c176 0x0   -r-x .text              PROGBITS alloc,execute
0x0034d180 0x260    0x0074d180 0x260    0x0   -r-x .plt               PROGBITS alloc,execute

The .text segment is mapped read-execute, so we will need to remap it read-write-execute before modifying the byte-code. This is done with an mprotect syscall, that needs to be aligned to a memory page boundary.

Next, we need to figure out how to write the jmp instruction in binary. A near jmp is relative to the RIP register, meaning the hex will look like e9 <relative position to the current RIP register + 5>. The + 5 is since the CPU calculates the jmp location from the memory location after the jump instruction, and the jump instruction will be 5 bytes long. We can check this out with Rizin to get a feel for it:

[0x00000000]> s 0x00414141
[0x00414141]> wa "jmp 0x00470000"
[0x00414141]> pd 1
       ┌─< 0x00414141      jmp   0x470000                             ; dbg.reflect.makeMethodValue+0x60
[0x00414141]> px 5
- offset -   0 1  2 3  4 5  6 7  8 9  A B  C D  E F  0123456789ABCDEF
0x00414141  e9ba be05 00                             .....
[0x00414141]> wa "jmp 0x00400000"
[0x00414141]> pd 1
       └─< 0x00414141      jmp   segment.LOAD0
[0x00414141]> px 5
- offset -   0 1  2 3  4 5  6 7  8 9  A B  C D  E F  0123456789ABCDEF
0x00414141  e9ba befe ff                             .....
[0x00414141]> 

We have an e9 followed by the rip relative memory address, either positive or calculated negative using twos-complement. Implementing the mprotect and hand-rolled jump instruction ends up looking like this:

func binaryPatch(address uintptr, jumpLocation uintptr) {
        if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
                fmt.Printf("ADDR 0x%x JMP Location 0x%x\n", address, jumpLocation)

                // set the page to RWX
                pageSize := syscall.Getpagesize()
                pageBoundary := address - (address % uintptr(pageSize))
                // create a 16 byte sized slice to pass to syscall.Mprotect, pointing to the page boundary
                pageBoundaryBuf := unsafe.Slice((*byte)(unsafe.Pointer(pageBoundary)), 16)
                fmt.Printf("Page boundary %p\n", pageBoundaryBuf)
                err := syscall.Mprotect(pageBoundaryBuf, syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC)
                if err != nil {
                        fmt.Printf("[!] Mprotect error: %s\n", err)
                        return
                }

                // Assemble the JMP instruction
                ripRelLocation := uint32((jumpLocation - address - 5))
                fmt.Printf("RIP Relative Jump Address 0x%x\n", ripRelLocation)

                addressRaw := *(*[]byte)(unsafe.Pointer(&address))
                instruction := make([]byte, 5)
                instruction[0] = 0xe9                                          // jmp
                binary.LittleEndian.PutUint32(instruction[1:], ripRelLocation) // mem address
                copy(addressRaw, instruction)

                fmt.Printf("Wrote instruction: %s\n", hex.EncodeToString(instruction))

                // set the page back to R.X
                err = syscall.Mprotect(pageBoundaryBuf, syscall.PROT_READ|syscall.PROT_EXEC)
                if err != nil {
                        fmt.Printf("[!] Mprotect error: %s\n", err)
                }
        } else {
                fmt.Printf("[!] Arch and/or OS not supported for binary patching - only linux/amd64")
        }
}

Now, we just have to feed it some pointers to a target function, and the function we want it to jump to. Golang, as per usual, is throwing a monkey wrench in the plan. If the function we want to overwrite is unexported we can’t reference it directly. Reflection doesn’t work in this case either, since the unexported function we want (canonicalizeMIMEHeaderKey) isn’t correlated to any specific type or struct. Not to worry, Golang supports a go:linkname compiler directive that lets us gain access to the right pointer (for now, atleast). Let’s add that logic to proxy.go:

+func binaryPatch(address uintptr, jumpLocation uintptr) {
+       if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
+               fmt.Printf("ADDR 0x%x JMP Location 0x%x\n", address, jumpLocation)
...yoink, you've already seen this part...
+                       fmt.Printf("[!] Mprotect error: %s\n", err)
+               }
+       } else {
+               fmt.Printf("[!] Arch and/or OS not supported for binary patching - only linux/amd64")
+       }
+}
+
+// NonCanonicalMIMEHeaderKey - see <url to article>
+func NonCanonicalMIMEHeaderKey(s string) string {
+       return s
+}
+
+// noncanonicalMIMEHeaderKey - see <url to article>
+func noncanonicalMIMEHeaderKey(a []byte) (_ string, ok bool) {
+       return string(a), true
+}
+
+//go:linkname canonicalMIMEHeaderKey net/textproto.canonicalMIMEHeaderKey
+func canonicalMIMEHeaderKey(a []byte) (_ string, ok bool)
+
 // Config - struct that holds the proxy config
 type Config struct {
        Port  uint   // port to listen on, default 8080
@@ -44,6 +103,17 @@ func StartProxy(logger *modifier.Logger, config *Config) *martian.Proxy {
                config = new(Config)
        }
 
+       // Binary patch the header canonicalization methods out of net/http
+       log.Printf("%v %v\n", textproto.CanonicalMIMEHeaderKey, NonCanonicalMIMEHeaderKey)
+       CanonicalMIMEHeaderKeyPtr := reflect.ValueOf(canonicalMIMEHeaderKey).Pointer()
+       NonCanonicalMIMEHeaderKeyPtr := reflect.ValueOf(NonCanonicalMIMEHeaderKey).Pointer()
+       binaryPatch(CanonicalMIMEHeaderKeyPtr, NonCanonicalMIMEHeaderKeyPtr)
+
+       log.Printf("%v %v\n", canonicalMIMEHeaderKey, noncanonicalMIMEHeaderKey)
+       canonicalMIMEHeaderKeyPtr := reflect.ValueOf(canonicalMIMEHeaderKey).Pointer()
+       noncanonicalMIMEHeaderKeyPtr := reflect.ValueOf(noncanonicalMIMEHeaderKey).Pointer()
+       binaryPatch(canonicalMIMEHeaderKeyPtr, noncanonicalMIMEHeaderKeyPtr)
+

And that’s it! We can now compile and test. Opening the NZ Herald web site without an ad-blocker should suffice for making sure this is all working nice and stable. Here’s what that looks like:

Delightful. Note, you can do the same things on Windows and MacOS provided you figure out the respective PE or MachO binary voodoo. Other architectures, too! Just follow the same steps for whatever processor and OS you’d like to support.

There is an edge case here where the instructions live across a bondary between two pages, which the code will be tweaked to address before I roll these changes up into glorp.

Summary

Realistically, Golang’s underlying net/http library may be a little too opinionated about the HTTP specs to be used for offensive security tooling. Vulnerabilities that rely on exploiting header casing, multiple headers with the same name, parameter passing inconsistencies, and other protocol tricks that may not be strictly RFC compliant are going to be tough to test with an opinionated HTTP library. The other Golang based HTTP security tools that are floating around have issues open for this same problem with various different ideas on how to resolve it.

This particular fix is my own quick-and-dirty solution for one annoying part of net/http. Safe to say, we won’t be retiring Portswigger’s Burp proxy for web testing any time soon.

The point of this article was more that when the code is executing on your computer, it’s really your code. If you get comfortable with assembly, the programming language constructs used to make that assembly end up more as a polite suggestion rather than a hard rule.

You aren’t trapped in here with the computers, the computers are trapped in here with you!

BONUS ROUND - aarch64

I’m mostly using glorp on linux x64 machines, but also quite frequently use the tool on aarch64 (arm64) linux installs too. The thing was designed as an easy CLI proxy for when I just want to look at something real quick, so naturally it gets a bunch of use on my personal aarch64 based laptop whenever something interesting pops up in my free time. The same tricks work just fine, swapping out our jmp instruction for a b instruction:

[0x000810f0]> afl | grep CanonicalMIME
0x001ef400   15 304  -> 300  dbg.net/textproto.CanonicalMIMEHeaderKey
0x002ee830    1 16   -> 8    dbg.github.com/denandz/glorp/proxy.NonCanonicalMIMEHeaderKey
[0x000810f0]> s 0x001ef400
[0x001ef400]> wa "b 0x002ee830"
[0x001ef400]> pd 1
            ; XREFS(21)
            ;-- net/textproto.CanonicalMIMEHeaderKey:
            ;-- sym.go.net_textproto.CanonicalMIMEHeaderKey:
            ;-- dbg.net_textproto.CanonicalMIMEHeaderKey:
 void net/textproto.CanonicalMIMEHeaderKey(struct string s, struct string ~r0)
           ; var runtime.tmpBuf *var_48h @ stack - 0x48
           ; var runtime.tmpBuf *buf @ stack - 0x28
           ; var int64_t arg_8h @ stack + 0x8
           ; var int64_t arg_10h @ stack + 0x10
           ; arg struct string s @ COMPOSITE
           ; arg struct string ~r0 @ ...
           ; var bool upper @ X3
       ┌─< 0x001ef400      b     dbg.github.com/denandz/glorp/proxy.NonCanonicalMIMEHeaderKey ; reader.go:650 func CanonicalMIMEHeaderKey(s string) string { ; dbg.github.com/denandz/glorp/proxy.NonCanonicalMIMEHeaderKey
[0x001ef400]> px 4
- offset -   0 1  2 3  4 5  6 7  8 9  A B  C D  E F  0123456789ABCDEF
0x001ef400  0cfd 0314                                ....
[0x001ef400]> afl | grep canonical
0x001ef530   25 512  -> 500  dbg.net/textproto.canonicalMIMEHeaderKey
0x002095b0    8 336  -> 328  dbg.vendor/golang.org/x/net/http/httpproxy.canonicalAddr
0x002149b0    7 208  -> 196  dbg.net/http.http2canonicalHeader
0x0023dd20    8 272  -> 264  dbg.net/http.canonicalAddr
0x002ee840    3 96           dbg.github.com/denandz/glorp/proxy.noncanonicalMIMEHeaderKey
[0x001ef400]> s 0x001ef530
[0x001ef530]> pd 1
            ; CALL XREF from dbg.net/textproto.readMIMEHeader @ 0x1eede8
            ; CALL XREFS from dbg.net/textproto.CanonicalMIMEHeaderKey @ 0x1ef4c8, 0x1ef4e8
            ; CODE XREF from dbg.net/textproto.canonicalMIMEHeaderKey @ 0x1ef720
            ;-- net/textproto.canonicalMIMEHeaderKey:
            ;-- sym.go.net_textproto.canonicalMIMEHeaderKey:
            ;-- dbg.net_textproto.canonicalMIMEHeaderKey:
 void net/textproto.canonicalMIMEHeaderKey(struct []uint8 a, struct string ~r0, bool ok)
           ; var uint8 *ptr @ x0
           ; var struct []uint8 arg3 @ x2
           ; var uint8 *arg_8h @ stack + 0x8
           ; var int n @ stack + 0x10
           ; var int64_t arg_18h @ stack + 0x18
           ; arg struct []uint8 a @ COMPOSITE
           ; arg struct string ~r0 @ ...
           ; arg bool ok @ ...
           ; var bool noCanon @ X3
           ; var bool upper @ X3
           0x001ef530      ldr   x16, [x28, 0x10]                     ; reader.go:745 func canonicalMIMEHeaderKey(a []byte) (_ string, ok bool) { ; [0x10:4]=-1 ; 16
[0x001ef530]> wa "b 0x002ee840"
[0x001ef530]> pd 1
            ; CALL XREF from dbg.net/textproto.readMIMEHeader @ 0x1eede8
            ; CALL XREFS from dbg.net/textproto.CanonicalMIMEHeaderKey @ 0x1ef4c8, 0x1ef4e8
            ; CODE XREF from dbg.net/textproto.canonicalMIMEHeaderKey @ 0x1ef720
            ;-- net/textproto.canonicalMIMEHeaderKey:
            ;-- sym.go.net_textproto.canonicalMIMEHeaderKey:
            ;-- dbg.net_textproto.canonicalMIMEHeaderKey:
 void net/textproto.canonicalMIMEHeaderKey(struct []uint8 a, struct string ~r0, bool ok)
           ; var uint8 *ptr @ x0
           ; var struct []uint8 arg3 @ x2
           ; var uint8 *arg_8h @ stack + 0x8
           ; var int n @ stack + 0x10
           ; var int64_t arg_18h @ stack + 0x18
           ; arg struct []uint8 a @ COMPOSITE
           ; arg struct string ~r0 @ ...
           ; arg bool ok @ ...
           ; var bool noCanon @ X3
           ; var bool upper @ X3
       ┌─< 0x001ef530      b     dbg.github.com/denandz/glorp/proxy.noncanonicalMIMEHeaderKey ; reader.go:745 func canonicalMIMEHeaderKey(a []byte) (_ string, ok bool) { ; dbg.github.com/denandz/glorp/proxy.noncanonicalMIMEHeaderKey
[0x001ef530]> px 4
- offset -   0 1  2 3  4 5  6 7  8 9  A B  C D  E F  0123456789ABCDEF
0x001ef530  c4fc 0314                                ....
[0x001ef530]> 


Follow us on LinkedIn