personnumber3377

Differential fuzzing multipart parsers in rack and golang multipart parsers…

Ok, so I had to add this stuff here:


    class InvalidContentDispositionError < StandardError
      include BadRequest
    end

to the multipart parser in rack and this:

          if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) &&
              disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES

            unless disposition.strip.downcase.start_with?("form-data")
              raise InvalidContentDispositionError, "Unexpected Content-Disposition value: #{disposition.inspect}"
            end

            i = disposition.index(';')
            disposition.slice!(0, i+1)

            param = nil
            num_params = 0

to the parsing stuff, because I had false positives during fuzzing.

Here is my golang file:

package main

import (
    "bytes"
    "encoding/json"
    "io"
    // "log"
    "mime/multipart"
    "os"
    "os/exec"
    "strings"
    "testing"
)


func containsNonASCII(data []byte) bool {
    for _, b := range data {
        if b > 0x7F {
            return true
        }
    }
    return false
}

func LoadCorpus(f *testing.F) {
        files, _ := os.ReadDir("corpus/")
        for _, file := range files {
                if data, err := os.ReadFile("corpus/" + file.Name()); err == nil {
                        f.Add(data)
                }
        }
}

func FuzzMultipartParser(f *testing.F) {
    f.Add([]byte("--RubyBoundary\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nbar\r\n--RubyBoundary--\r\n"))
    LoadCorpus(f)
    f.Fuzz(func(t *testing.T, data []byte) {
        if containsNonASCII(data) {
            return
        }
        if !strings.Contains(string(data), "Content-Disposition: form-data; ") {
            return
        }
        // 1. Parse with Go
        reader := multipart.NewReader(bytes.NewReader(data), "RubyBoundary")
        goParams := map[string]string{}
        goFiles := map[string]struct {
            Filename string
            Content  string
        }{}

        for {
            part, err := reader.NextPart()
            if err == io.EOF {
                break
            }



            if err != nil {
                if strings.Contains(err.Error(), "NextPart: EOF") {
                    // t.Logf("poopoo")
                    break
                }
                // t.Errorf("err: %s", err.Error())
                return // ignore on parse error
            }

            name := part.FormName()
            filename := part.FileName()
            content, _ := io.ReadAll(part)
            // t.Logf("Filename: %s\n", filename)
            // t.Logf("name: %s\n", name)
            // t.Logf("content: %s\n", content)
            if filename != "" {
                if name == "" || len(content) == 0 {
                    return
                }
                goFiles[name] = struct {
                    Filename string
                    Content  string
                }{
                    Filename: filename,
                    Content:  string(content),
                }
            } else {
                // return
                if name != "" && len(content) != 0 {
                    goParams[name] = string(content)
                } else {
                    return
                }

            }
        }
        // t.Errorf("feewffew")
        // 2. Write to file


	    cmd := exec.Command("ruby", "rack_parse.rb")
        stdin, err := cmd.StdinPipe()
        if err != nil {
            t.Fatalf("failed to get stdin pipe: %v", err)
        }
        stdout, err := cmd.StdoutPipe()
        if err != nil {
            t.Fatalf("failed to get stdout pipe: %v", err)
        }

        if err := cmd.Start(); err != nil {
            t.Fatalf("failed to start ruby subprocess: %v", err)
        }

        // Write data to stdin
        if _, err := stdin.Write(data); err != nil {
            t.Fatalf("failed to write to ruby stdin: %v", err)
        }
        stdin.Close()

        out, err := io.ReadAll(stdout)
        if err != nil {
            t.Fatalf("failed to read from ruby stdout: %v", err)
        }

        if err := cmd.Wait(); err != nil {
            return
            // t.Fatalf("ruby execution error: %v\nOutput: %s", err, out)
        }

        var rubyOutput map[string]any
        if err := json.Unmarshal(out, &rubyOutput); err != nil {
            t.Logf("Here is the thing: %s\n", out)
            t.Fatalf("ruby json error: %v", err)
        }
        // panic("fuck")
        // 4. Compare
        // t.Errorf("RUBY RAW: %s", string(out))

        // t.Errorf("Here is the stuff: %s\n", rubyOutput)
        if rubyFiles, ok := rubyOutput["files"].(map[string]interface{}); ok {
            // t.Errorf("Here is the thing: %s\n", rubyOutput)
            // panic("fe")
            for k, fileInfo := range rubyFiles {
                fileMap := fileInfo.(map[string]interface{})
                goFile, ok := goFiles[k]
                if !ok {
                    t.Fatalf("Go missing file for key %q", k)
                }

                // Compare filename
                if goFile.Filename != fileMap["filename"] {
                    t.Fatalf("Filename mismatch for %q: Go=%q Ruby=%q", k, goFile.Filename, fileMap["filename"])
                }

                // Compare content
                if goFile.Content != fileMap["content"] {
                    t.Fatalf("Content mismatch for %q:\nGo: %q\nRuby: %q", k, goFile.Content, fileMap["content"])
                }
            }
        }

        // 5. Check that the goFiles do not have extra files not in rubyfiles.
        for k := range goFiles {
            if _, ok := rubyOutput["files"].(map[string]interface{})[k]; !ok {
                t.Fatalf("Ruby missing file for key %q", k)
            }
        }

    })
}


and here is rack_parse.rb:

require 'rack'
require 'tempfile'
require 'json'
require 'stringio'

# Read from STDIN
input_data = STDIN.read

env = {
  "CONTENT_TYPE" => "multipart/form-data; boundary=RubyBoundary",
  "CONTENT_LENGTH" => input_data.bytesize,
  "rack.input" => StringIO.new(input_data)
}

query_parser = Rack::QueryParser.new(Rack::QueryParser::Params, 32)

parser = Rack::Multipart::Parser.new(
  env["CONTENT_TYPE"].match(/boundary=(.+)/)[1],
  ->(filename, content_type) { Tempfile.new(['RackMultipart', filename]) },
  input_data.bytesize,
  query_parser
)

parser.parse(env["rack.input"])
params = parser.result.params || {}

response = { params: {}, files: {} }

params.each do |key, value|
  if value.is_a?(Hash) &&
     value.key?(:filename) &&
     value.key?(:tempfile) &&
     value[:tempfile].respond_to?(:read)

    content = value[:tempfile].read
    value[:tempfile].rewind

    response[:files][key] = {
      filename: value[:filename],
      content: content
    }
  else
    response[:params][key] = value
  end
end

puts JSON.pretty_generate(response)