CVE-2020-27544: Insufficient Information (NVD-CWE-noinfo)
value: An issue was discovered in FoldingAtHome Client Advanced Control GUI before commit 9b619ae64443997948a36dda01b420578de1af77, allows remote attackers to execute arbitrary code via crafted payload to function parse_message in file Connection.py.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 9.8
Published: 2023-08-11T14:15Z
Repository: https://github.com/FoldingAtHome/fah-control
Language: Python
Files changed: fah/Connection.py, fah/util/PYONDecoder.py, fah/util/__init__.py
Commit date: 2020-09-21 22:37:40 -0700
Commit message: Use PYONDecoder instead of eval()

Diff: diff --git a/fah/Connection.py b/fah/Connection.py
index 7e0d893..22c6b11 100644
--- a/fah/Connection.py
+++ b/fah/Connection.py
@@ -25,12 +25,14 @@ import socket
 import select
 import errno
 import time
 import sys
 import traceback
+import json
 
 from fah.util import OrderedDict
+from fah.util import PYONDecoder
 
 if sys.platform == 'win32':
     from ctypes import windll
     WSAGetLastError = windll.ws2_32.WSAGetLastError
 
@@ -202,11 +204,11 @@ class Connection:
         self.writeBuf += command + '\n'
 
 
     def parse_message(self, version, type, data):
         try:
-            msg = eval(data, {}, {})
+            msg = json.loads(data, cls = PYONDecoder)
             #if debug: print 'MSG:', type, msg
             self.messages.append((version, type, msg))
             self.last_message = time.time()
         except Exception as e:
             print('ERROR parsing PyON message: %s: %s'
diff --git a/fah/util/PYONDecoder.py b/fah/util/PYONDecoder.py
new file mode 100644
index 0000000..789f82b
--- /dev/null
+++ b/fah/util/PYONDecoder.py
@@ -0,0 +1,216 @@
+################################################################################
+#                                                                              #
+#                    Folding@Home Client Control (FAHControl)                  #
+#                   Copyright (C) 2016-2020 foldingathome.org                  #
+#                  Copyright (C) 2010-2016 Stanford University                 #
+#                                                                              #
+#      This program is free software: you can redistribute it and/or modify    #
+#      it under the terms of the GNU General Public License as published by    #
+#       the Free Software Foundation, either version 3 of the License, or      #
+#                      (at your option) any later version.                     #
+#                                                                              #
+#        This program is distributed in the hope that it will be useful,       #
+#         but WITHOUT ANY WARRANTY; without even the implied warranty of       #
+#         MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the        #
+#                  GNU General Public License for more details.                #
+#                                                                              #
+#       You should have received a copy of the GNU General Public License      #
+#     along with this program.  If not, see <http://www.gnu.org/licenses/>.    #
+#                                                                              #
+################################################################################
+
+import re
+import json
+import sys
+
+
+NUMBER_RE = re.compile(r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?',
+                       (re.VERBOSE | re.MULTILINE | re.DOTALL))
+FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
+STRINGCHUNK = re.compile(r'(.*?)(["\\\x00-\x1f])', FLAGS)
+BACKSLASH = {
+  '"': u'"', '\\': u'\\', '/': u'/',
+  'b': u'\b', 'f': u'\f', 'n': u'\n', 'r': u'\r', 't': u'\t',
+}
+
+DEFAULT_ENCODING = "utf-8"
+
+
+def linecol(doc, pos):
+  lineno = doc.count('\n', 0, pos) + 1
+  if lineno == 1:
+    colno = pos + 1
+  else:
+    colno = pos - doc.rindex('\n', 0, pos)
+  return lineno, colno
+
+
+def errmsg(msg, doc, pos, end=None):
+  # Note that this function is called from _json
+  lineno, colno = linecol(doc, pos)
+  if end is None:
+    fmt = '{0}: line {1} column {2} (char {3})'
+    return fmt.format(msg, lineno, colno, pos)
+
+  endlineno, endcolno = linecol(doc, end)
+  fmt = '{0}: line {1} column {2} - line {3} column {4} (char {5} - {6})'
+  return fmt.format(msg, lineno, colno, endlineno, endcolno, pos, end)
+
+
+def _decode_uXXXX(s, pos):
+  esc = s[pos + 1:pos + 5]
+
+  if len(esc) == 4 and esc[1] not in 'xX':
+    try:
+      return int(esc, 16)
+    except ValueError: pass
+
+  msg = "Invalid \\uXXXX escape"
+  raise ValueError(errmsg(msg, s, pos))
+
+
+def pyon_scanstring(s, end, encoding = None, strict = True,
+                    _b = BACKSLASH, _m = STRINGCHUNK.match):
+  """Scan the string s for a JSON string. End is the index of the
+  character in s after the quote that started the JSON string.
+  Unescapes all valid JSON string escape sequences and raises ValueError
+  on attempt to decode an invalid string. If strict is False then literal
+  control characters are allowed in the string.
+  Returns a tuple of the decoded string and the index of the character in s
+  after the end quote."""
+  if encoding is None: encoding = DEFAULT_ENCODING
+  chunks = []
+  _append = chunks.append
+  begin = end - 1
+
+  while True:
+    chunk = _m(s, end)
+    if chunk is None:
+      raise ValueError(
+        errmsg("Unterminated string starting at", s, begin))
+
+    end = chunk.end()
+    content, terminator = chunk.groups()
+
+    # Content is contains zero or more unescaped string characters
+    if content:
+      if not isinstance(content, unicode):
+        content = unicode(content, encoding)
+      _append(content)
+
+    # Terminator is the end of string, a literal control character,
+    # or a backslash denoting that an escape sequence follows
+    if terminator == '"': break
+    elif terminator != '\\':
+      if strict:
+        msg = "Invalid control character {0!r} at".format(terminator)
+        raise ValueError(errmsg(msg, s, end))
+      else:
+        _append(terminator)
+        continue
+
+    try:
+      esc = s[end]
+    except IndexError:
+      raise ValueError(errmsg("Unterminated string starting at", s, begin))
+
+    # If not a unicode escape sequence, must be in the lookup table
+    if esc != 'u' and esc != 'x':
+      try:
+        char = _b[esc]
+      except KeyError:
+        msg = "Invalid \\escape: " + repr(esc)
+        raise ValueError(errmsg(msg, s, end))
+      end += 1
+
+    elif esc == 'x':
+      # Hex escape sequence
+      try:
+        code = s[end + 1: end + 3]
+        char = code.decode('hex')
+      except:
+        raise ValueError(errmsg('Invalid \\escape: ' + repr(code), s, end))
+
+      end += 3
+
+    else:
+      # Unicode escape sequence
+      uni = _decode_uXXXX(s, end)
+      end += 5
+      # Check for surrogate pair on UCS-4 systems
+      if sys.maxunicode > 65535 and \
+         0xd800 <= uni <= 0xdbff and s[end:end + 2] == '\\u':
+        uni2 = _decode_uXXXX(s, end + 1)
+        if 0xdc00 <= uni2 <= 0xdfff:
+          uni = 0x10000 + (((uni - 0xd800) << 10) | (uni2 - 0xdc00))
+          end += 6
+      char = unichr(uni)
+
+    # Append the unescaped character
+    _append(char)
+
+  return u''.join(chunks), end
+
+
+
+def make_pyon_scanner(context):
+  parse_object = context.parse_object
+  parse_array = context.parse_array
+  parse_string = context.parse_string
+  match_number = NUMBER_RE.match
+  strict = context.strict
+  parse_float = context.parse_float
+  parse_int = context.parse_int
+  parse_constant = context.parse_constant
+  object_hook = context.object_hook
+  object_pairs_hook = context.object_pairs_hook
+
+
+  def scan_once(string, idx):
+    try:
+        nextchar = string[idx]
+    except IndexError:
+      raise StopIteration(idx)
+
+    if nextchar == '"': return parse_string(string, idx + 1, 'utf-8', strict)
+    elif nextchar == '{':
+      return parse_object((string, idx + 1), 'utf-8', strict, scan_once,
+                          object_hook, object_pairs_hook)
+    elif nextchar == '[':
+      return parse_array((string, idx + 1), scan_once)
+    elif nextchar == 'N' and string[idx:idx + 4] == 'None':
+      return None, idx + 4
+    elif nextchar == 'T' and string[idx:idx + 4] == 'True':
+      return True, idx + 4
+    elif nextchar == 'F' and string[idx:idx + 5] == 'False':
+      return False, idx + 5
+
+    m = match_number(string, idx)
+    if m is not None:
+      integer, frac, exp = m.groups()
+      if frac or exp:
+        res = parse_float(integer + (frac or '') + (exp or ''))
+      else: res = parse_int(integer)
+
+      return res, m.end()
+
+    elif nextchar == 'N' and string[idx:idx + 3] == 'NaN':
+      return parse_constant('NaN'), idx + 3
+
+    elif nextchar == 'I' and string[idx:idx + 8] == 'Infinity':
+      return parse_constant('Infinity'), idx + 8
+
+    elif nextchar == '-' and string[idx:idx + 9] == '-Infinity':
+      return parse_constant('-Infinity'), idx + 9
+
+    else: raise StopIteration(idx)
+
+
+  return scan_once
+
+
+class PYONDecoder(json.JSONDecoder):
+  def __init__(self, *args, **kwargs):
+    json.JSONDecoder.__init__(self, *args, **kwargs)
+    self.parse_string = pyon_scanstring
+    self.scan_once = make_pyon_scanner(self)
diff --git a/fah/util/__init__.py b/fah/util/__init__.py
index 61f8366..f05d708 100644
--- a/fah/util/__init__.py
+++ b/fah/util/__init__.py
@@ -27,10 +27,11 @@ import gtk
 
 from SingleApp import *
 from EntryValidator import *
 from PasswordValidator import *
 from OrderedDict import *
+from PYONDecoder import *
 
 
 def parse_bool(x):
     if isinstance(x, bool): return x
     return x.lower() in ['true', 't', '1', 'yes', 'y']

Vulnerable code:             msg = eval(data, {}, {})
Fixed code: import json
from fah.util import PYONDecoder
            msg = json.loads(data, cls = PYONDecoder)
################################################################################
#                                                                              #
#                    Folding@Home Client Control (FAHControl)                  #
#                   Copyright (C) 2016-2020 foldingathome.org                  #
#                  Copyright (C) 2010-2016 Stanford University                 #
#                                                                              #
#      This program is free software: you can redistribute it and/or modify    #
#      it under the terms of the GNU General Public License as published by    #
#       the Free Software Foundation, either version 3 of the License, or      #
#                      (at your option) any later version.                     #
#                                                                              #
#        This program is distributed in the hope that it will be useful,       #
#         but WITHOUT ANY WARRANTY; without even the implied warranty of       #
#         MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the        #
#                  GNU General Public License for more details.                #
#                                                                              #
#       You should have received a copy of the GNU General Public License      #
#     along with this program.  If not, see <http://www.gnu.org/licenses/>.    #
#                                                                              #
################################################################################

import re
import json
import sys


NUMBER_RE = re.compile(r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?',
                       (re.VERBOSE | re.MULTILINE | re.DOTALL))
FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
STRINGCHUNK = re.compile(r'(.*?)(["\\\x00-\x1f])', FLAGS)
BACKSLASH = {
  '"': u'"', '\\': u'\\', '/': u'/',
  'b': u'\b', 'f': u'\f', 'n': u'\n', 'r': u'\r', 't': u'\t',
}

DEFAULT_ENCODING = "utf-8"


def linecol(doc, pos):
  lineno = doc.count('\n', 0, pos) + 1
  if lineno == 1:
    colno = pos + 1
  else:
    colno = pos - doc.rindex('\n', 0, pos)
  return lineno, colno


def errmsg(msg, doc, pos, end=None):
  # Note that this function is called from _json
  lineno, colno = linecol(doc, pos)
  if end is None:
    fmt = '{0}: line {1} column {2} (char {3})'
    return fmt.format(msg, lineno, colno, pos)

  endlineno, endcolno = linecol(doc, end)
  fmt = '{0}: line {1} column {2} - line {3} column {4} (char {5} - {6})'
  return fmt.format(msg, lineno, colno, endlineno, endcolno, pos, end)


def _decode_uXXXX(s, pos):
  esc = s[pos + 1:pos + 5]

  if len(esc) == 4 and esc[1] not in 'xX':
    try:
      return int(esc, 16)
    except ValueError: pass

  msg = "Invalid \\uXXXX escape"
  raise ValueError(errmsg(msg, s, pos))


def pyon_scanstring(s, end, encoding = None, strict = True,
                    _b = BACKSLASH, _m = STRINGCHUNK.match):
  """Scan the string s for a JSON string. End is the index of the
  character in s after the quote that started the JSON string.
  Unescapes all valid JSON string escape sequences and raises ValueError
  on attempt to decode an invalid string. If strict is False then literal
  control characters are allowed in the string.
  Returns a tuple of the decoded string and the index of the character in s
  after the end quote."""
  if encoding is None: encoding = DEFAULT_ENCODING
  chunks = []
  _append = chunks.append
  begin = end - 1

  while True:
    chunk = _m(s, end)
    if chunk is None:
      raise ValueError(
        errmsg("Unterminated string starting at", s, begin))

    end = chunk.end()
    content, terminator = chunk.groups()

    # Content is contains zero or more unescaped string characters
    if content:
      if not isinstance(content, unicode):
        content = unicode(content, encoding)
      _append(content)

    # Terminator is the end of string, a literal control character,
    # or a backslash denoting that an escape sequence follows
    if terminator == '"': break
    elif terminator != '\\':
      if strict:
        msg = "Invalid control character {0!r} at".format(terminator)
        raise ValueError(errmsg(msg, s, end))
      else:
        _append(terminator)
        continue

    try:
      esc = s[end]
    except IndexError:
      raise ValueError(errmsg("Unterminated string starting at", s, begin))

    # If not a unicode escape sequence, must be in the lookup table
    if esc != 'u' and esc != 'x':
      try:
        char = _b[esc]
      except KeyError:
        msg = "Invalid \\escape: " + repr(esc)
        raise ValueError(errmsg(msg, s, end))
      end += 1

    elif esc == 'x':
      # Hex escape sequence
      try:
        code = s[end + 1: end + 3]
        char = code.decode('hex')
      except:
        raise ValueError(errmsg('Invalid \\escape: ' + repr(code), s, end))

      end += 3

    else:
      # Unicode escape sequence
      uni = _decode_uXXXX(s, end)
      end += 5
      # Check for surrogate pair on UCS-4 systems
      if sys.maxunicode > 65535 and \
         0xd800 <= uni <= 0xdbff and s[end:end + 2] == '\\u':
        uni2 = _decode_uXXXX(s, end + 1)
        if 0xdc00 <= uni2 <= 0xdfff:
          uni = 0x10000 + (((uni - 0xd800) << 10) | (uni2 - 0xdc00))
          end += 6
      char = unichr(uni)

    # Append the unescaped character
    _append(char)

  return u''.join(chunks), end



def make_pyon_scanner(context):
  parse_object = context.parse_object
  parse_array = context.parse_array
  parse_string = context.parse_string
  match_number = NUMBER_RE.match
  strict = context.strict
  parse_float = context.parse_float
  parse_int = context.parse_int
  parse_constant = context.parse_constant
  object_hook = context.object_hook
  object_pairs_hook = context.object_pairs_hook


  def scan_once(string, idx):
    try:
        nextchar = string[idx]
    except IndexError:
      raise StopIteration(idx)

    if nextchar == '"': return parse_string(string, idx + 1, 'utf-8', strict)
    elif nextchar == '{':
      return parse_object((string, idx + 1), 'utf-8', strict, scan_once,
                          object_hook, object_pairs_hook)
    elif nextchar == '[':
      return parse_array((string, idx + 1), scan_once)
    elif nextchar == 'N' and string[idx:idx + 4] == 'None':
      return None, idx + 4
    elif nextchar == 'T' and string[idx:idx + 4] == 'True':
      return True, idx + 4
    elif nextchar == 'F' and string[idx:idx + 5] == 'False':
      return False, idx + 5

    m = match_number(string, idx)
    if m is not None:
      integer, frac, exp = m.groups()
      if frac or exp:
        res = parse_float(integer + (frac or '') + (exp or ''))
      else: res = parse_int(integer)

      return res, m.end()

    elif nextchar == 'N' and string[idx:idx + 3] == 'NaN':
      return parse_constant('NaN'), idx + 3

    elif nextchar == 'I' and string[idx:idx + 8] == 'Infinity':
      return parse_constant('Infinity'), idx + 8

    elif nextchar == '-' and string[idx:idx + 9] == '-Infinity':
      return parse_constant('-Infinity'), idx + 9

    else: raise StopIteration(idx)


  return scan_once


class PYONDecoder(json.JSONDecoder):
  def __init__(self, *args, **kwargs):
    json.JSONDecoder.__init__(self, *args, **kwargs)
    self.parse_string = pyon_scanstring
    self.scan_once = make_pyon_scanner(self)
from PYONDecoder import *