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 │Status│Size│Time│Date │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 aret
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 │Status│Size│Time│Date │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]>