Security Implications of net/textproto.Reader
Misuse
net/textproto.Reader
should be only used with io.LimitReader
. It turns out that often this is not the case, even in Golang standard library. This caused Out Of Memory crash vulnerabilities in Golang net/http
package (CVE-2023-45290) and other open-source projects.
Infinite read in net/textproto.Reader
In September 2023 I discovered an issue in Golang’s net/textproto.Reader
. In one of its internal methods (readLineSlice
), the code (in an infinite loop) reads data from bufio.Reader
and concatenates to line
: previously read data. It is using ReadLine
which behaves differently from other bufio.Reader
methods: this is the only method that does not return an error when internal the buffer fills full. Instead, when the buffer becomes full it returns more=true
value which indicates there is more data to be read.
52func (r *Reader) readLineSlice() ([]byte, error) {
53 r.closeDot()
54 var line []byte
55 for {
56 l, more, err := r.R.ReadLine()
57 if err != nil {
58 return nil, err
59 }
60 // Avoid the copy if the first call produced a full line.
61 if line == nil && !more {
62 return l, nil
63 }
64 line = append(line, l...)
65 if !more {
66 break
67 }
68 }
69 return line, nil
70}
If a very long line is passed to this method, it will be read into memory until \n
is found which can lead to an OOM crash. However, net/textproto.NewReader
godoc explicitly mentions this case:
NewReader returns a new Reader reading from r.
To avoid denial of service attacks, the provided bufio.Reader should be reading from an io.LimitReader or similar Reader to bound the size of responses.
CodeQL query to find net/textproto.Reader
usage
Out of curiosity, I used CodeQL to find projects which use net/textproto.Reader
. Below you can find CodeQL query to check if your code might be vulnerable (if used without io.LimitReader
or a similar limiter).
1import go
2
3predicate textprotoFunction(CallExpr cs, string name) {
4 cs.getTarget().(Function).hasQualifiedName("net/textproto", name)
5}
6
7predicate textprotoReaderMethod(CallExpr cs, string name) {
8 cs.getTarget().(Method).hasQualifiedName("net/textproto", "Reader", name)
9}
10
11class TextProto extends CallExpr {
12 TextProto() {
13 textprotoFunction(this, "Dial") or
14 textprotoFunction(this, "NewConn") or
15 textprotoFunction(this, "NewReader") or
16 textprotoReaderMethod(this, "ReadLine") or
17 textprotoReaderMethod(this, "ReadLineBytes") or
18 textprotoReaderMethod(this, "ReadContinuedLine") or
19 textprotoReaderMethod(this, "ReadContinuedLineBytes") or
20 textprotoReaderMethod(this, "ReadResponse") or
21 textprotoReaderMethod(this, "ReadCodeLine") or
22 textprotoReaderMethod(this, "ReadDotBytes") or
23 textprotoReaderMethod(this, "ReadDotLines") or
24 textprotoReaderMethod(this, "ReadMIMEHeader")
25 }
26}
27
28from TextProto call
29select call, "Direct usage of textproto.Reader methods"
Unfortunately, this query will not find all the cases and will give false positives.
First, it doesn’t check if the provided bufio.Reader
is using io.LimitReader
internally (and if it’s N
value makes sense). Second, I discovered that textproto
is used in some HTTP helper packages and CodeQL is unable to find cross-repository calls. Finally, kind of connected to the previous one, textproto.Reader
is used internally in other standard packages of Golang.
Misuse in net/http.ReadRequest
Turns out that misusing this API is very common, even in Golang standard library. The net/textproto.Reader
is used in net/http.ReadRequest
and its godoc does not even mention io.LimitReader
:
1017// ReadRequest reads and parses an incoming request from b.
1018//
1019// ReadRequest is a low-level function and should only be used for
1020// specialized applications; most code should use the Server to read
1021// requests and handle them via the Handler interface. ReadRequest
1022// only supports HTTP/1.x requests. For HTTP/2, use golang.org/x/net/http2.
1023func ReadRequest(b *bufio.Reader) (*Request, error) {
1024 req, err := readRequest(b)
1025 if err != nil {
1026 return nil, err
1027 }
1028
1029 delete(req.Header, "Host")
1030 return req, err
1031}
1032
1033func readRequest(b *bufio.Reader) (req *Request, err error) {
1034 tp := newTextprotoReader(b)
1035 defer putTextprotoReader(tp)
1036
1037 req = new(Request)
1038
1039 // First line: GET /index.html HTTP/1.0
1040 var s string
1041 if s, err = tp.ReadLine(); err != nil {
1042 return nil, err
1043 }
1044 defer func() {
1045 if err == io.EOF {
1046 err = io.ErrUnexpectedEOF
1047 }
1048 }()
1049// ...
To exploit this, an attacker can simply send an infinite byte stream in the first line of an HTTP request and they will all be loaded into memory, ultimately crashing the server.
As mentioned before, some HTTP servers and helper packages use this function without limiters. Most notably Caddy which was fixed in v2.7.5 after my report. There are more cases like this but my reports were ignored or simply have not been fixed months after the initial report.
Other usages in Golang
Here’s a list of public methods that use textproto.Reader
on a provided arguments found using my CodeQL query on Golang 1.21.5:
net/http.ReadResponse
and methods in which it’s used internally:rpc.DialHTTP
rpc.DialHTTPPath
(marked as deprecated)net/http/httputil.ClientConn.Read
net/http.ReadRequest
(previous section)(marked as deprecated)net/http/httputil.ServerConn.Read
mail.ReadMessage
smtp.NewClient
smtp.StartTLS
I reported it to Golang team but they decided not to fix and improve docs instead. Except net/http
cases, I agree with this decision: either some obscure use cases were required to trigger the vulnerable code (like a proxy used to make HTTP request had to respond in a rogue way) or the code was in deprecated packages.
Nevertheless, be extra cautious when using any of the functions above in production code!
Vulnerability in http.Request.FormValue
leads to OOM crash
Months have passed and I almost forgot about the issue but one day I noticed that there was one more usage of textproto.Reader
my CodeQL query would never find…
Linking readMIMEHeader
in mime/multipart
I was checking the mime/multipart
package and noticed a file (readmimeheader.go
) with just a couple lines:
1// Copyright 2023 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4package multipart
5
6import (
7 "net/textproto"
8 _ "unsafe" // for go:linkname
9)
10
11// readMIMEHeader is defined in package net/textproto.
12//
13//go:linkname readMIMEHeader net/textproto.readMIMEHeader
14func readMIMEHeader(r *textproto.Reader, lim int64) (textproto.MIMEHeader, error)
So basically mime/multipart
was using a function from net/textproto
but instead of using the public API, it linked a private function. Why was it done this way? Maybe to avoid circular dependency? But there was no way my CodeQL query could find it, that’s why I’ve missed it!
Parsing multipart form data format using ParseMultipartForm
Anyway, this function is using readLineSlice
internally and is used by mime/multipart.Reader.populateHeaders
method:
152func (p *Part) populateHeaders(maxMIMEHeaderSize int64) error {
153 r := textproto.NewReader(p.mr.bufReader)
154 header, err := readMIMEHeader(r, maxMIMEHeaderSize)
155 if err == nil {
156 p.Header = header
157 }
158 // TODO: Add a distinguishable error to net/textproto.
159 if err != nil && err.Error() == "message too large" {
160 err = ErrMessageTooLarge
161 }
162 return err
163}
What does it do? It parses multipart form data (when Content-Type
is multipart/form-data
). Here’s a quick reminder how a request with a multipart body looks like:
1POST / HTTP/1.0
2Host: localhost
3Content-Type: multipart/form-data; boundary=----boundary
4Content-Length: 16525
5
6------boundary
7Content-Disposition: form-data; name="data1"
8Content-Type: text/plain
9
10data here
11
12------boundary
13Content-Disposition: form-data; name="data2"
14Content-Type: text/plain
15
16data there
Thus, a body can consist of multiple parts, each of which can be of a different type and each part has a set of headers. Turns out, each part’s headers in multipart data are parsed by a function from net/textproto
package, the one which can lead to vulnerabilities if not used properly.
I quickly checked if io.LimitReader
was used internally and it was not, however multipart.NewReader
accepts io.Reader
. This means that the caller must ensure that io.Reader
should be in fact an io.LimitReader
. Similarly to other functions I reported in September 2023, this one is also missing this essential information in godoc.
Additionally, populateHeaders
requires maxMIMEHeaderSize
argument which is passed to readMIMEHeader
. However, this argument is only taken into account after reading the headers using vulnerable methods. So it should not be a problem if there is a vulnerability connected to this method.
Obviously, the next step was checking if mime/multipart.Reader
is used anywhere in Go codebase. A quick search revealed that it is only used by net/http.Request.ParseMultipartForm
. But there was a gotcha: it required maxMemory
, explained in godoc:
1316// ParseMultipartForm parses a request body as multipart/form-data.
1317// The whole request body is parsed and up to a total of maxMemory bytes of
1318// its file parts are stored in memory, with the remainder stored on
1319// disk in temporary files.
1320// ParseMultipartForm calls ParseForm if necessary.
1321// If ParseForm returns an error, ParseMultipartForm returns it but also
1322// continues parsing the request body.
1323// After one call to ParseMultipartForm, subsequent calls have no effect.
1324func (r *Request) ParseMultipartForm(maxMemory int64) error {
1325 if r.MultipartForm == multipartByReader {
1326 return errors.New("http: multipart handled by MultipartReader")
1327 }
1328 var parseFormErr error
1329 if r.Form == nil {
1330 // Let errors in ParseForm fall through, and just
1331 // return it at the end.
1332 parseFormErr = r.ParseForm()
1333 }
1334 if r.MultipartForm != nil {
1335 return nil
1336 }
1337
1338 mr, err := r.multipartReader(false)
1339 if err != nil {
1340 return err
1341 }
1342
1343 f, err := mr.ReadForm(maxMemory)
1344 if err != nil {
1345 return err
1346 }
1347// ...
So everything below maxMemory
with default value of:
1defaultMaxMemory = 32 << 20 // 32 MB
would be stored in memory, the rest on disk. However, if one can exploit readMIMEHeader
method, it’s possible to store more data in memory…
Exploiting the lack of read limits in net/http.Request.FormValue
A couple of CodeQL queries later I discovered that net/http.Request.ParseMultipartForm
is used by net/http.Request.FormValue
which is a pretty common method even for small Golang apps.
The call stack to reach readMIMEHeader
was looking like this:
net/http.Request.FormValue
net/http.Request.ParseMultipartForm
mime/multipart.Reader.ReadForm
mime/multipart.Reader.readForm
mime/multipart.Reader.nextPart
mime/multipart.nextPart
mime/multipart.populateHeaders
mime/multipart.readMIMEHeader
At this point, the last remaining part to call this a vulnerability was checking if multipart.Reader
created in net/http.Request.ParseMultipartForm
is indeed a io.LimitReader
. The easiest way was simply writing a test case and setting a proper breakpoint in a debugger.
Turns out that the reader type is net/http.body
which internally reads from io.LimitReader
. However, its N
value is set to… Content-Length
header and it’s value is never checked! What we can do is send an HTTP request with a very large Content-Length
value (like 2GiB) followed by a single part with an infinite header name, like this:
POST / HTTP/1.0
Host: localhost
Content-Type: multipart/form-data; boundary=----boundary
Content-Length: 2147483648
------boundary
Content-Disposition: form-data; name="text1"
Content-Type: text/plain
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.......
The issue was assigned the CVE-2023-45290 number and was fixed in Golang 1.21.8 and 1.22.1.