CVE-2020-7660 (published 2020-06-01T15:15Z)

serialize-javascript prior to 3.1.0 allows remote attackers to inject arbitrary code via the function "deleteFunctions" within "index.js".

Severity: MEDIUM
CVSS v2 base score: 6.8 (AV:N/AC:M/Au:N/C:P/I:P/A:P)
CVSS v3 base score: 8.1, HIGH (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-502
cwe_name: Deserialization of Untrusted Data
description: The product deserializes untrusted data without sufficiently verifying that the resulting data will be valid.
extended_description: 
            It is often convenient to serialize objects for communication or to save them for later use. However, deserialized data or code can often be modified without using the provided accessor functions if it does not use cryptography to protect itself. Furthermore, any cryptography would still be client-side security -- which is a dangerous security assumption.
            Data that is untrusted can not be trusted to be well-formed.
            When developers place no restrictions on "gadget chains," or series of instances and method invocations that can self-execute during the deserialization process (i.e., before the object is returned to the caller), it is sometimes possible for attackers to leverage them to perform unauthorized actions, like generating a shell.
         
         

Fix information:
repo_url: https://github.com/yahoo/serialize-javascript
commit_details: repo_url: https://github.com/yahoo/serialize-javascript
author: Ryan Siebert
author_date: 2020-05-20 23:52:22+10:00
msg: Don't replace regex / function placeholders within string literals (#79)

Previously we weren't checking if the quote that started the placeholder
was escaped or not, meaning an object like

    {"foo": /1"/, "bar": "a\"@__R-<UID>-0__@"}

Would be serialized as

    {"foo": /1"/, "bar": "a\/1"/}

meaning an attacker could escape out of `bar` if they controlled both
`foo` and `bar` and were able to guess the value of `<UID>`.

UID was generated once on startup, was chosen using `Math.random()` and
had a keyspace of roughly 4 billion, so within the realm of an online
attack.

Here's a simple example that will cause `console.log()` to be called when
the `serialize()`d version is `eval()`d

    eval('('+ serialize({"foo": /1" + console.log(1)/i, "bar": '"@__R-<UID>-0__@'}) + ')');

Where `<UID>` is the guessed `UID`.

This fixes the issue by ensuring that placeholders are not preceded by
a backslash.

We also switch to a higher entropy `UID` to prevent people from guessing it.

Co-authored-by: Jordan Milne <jmilne@uber.com>
Co-authored-by: Ryan Siebert <ryan.siebert@energyaustralia.com.au>
repository: repo_url: https://github.com/yahoo/serialize-javascript
repo_name: yahoo/serialize-javascript
description: Serialize JavaScript to a superset of JSON that includes regular expressions and functions.
repo_language: JavaScript
owner: yahoo
file_changes: filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -6,9 +6,12 @@ See the accompanying LICENSE file for terms.
 
 'use strict';
 
+var randomBytes = require('randombytes');
+
 // Generate an internal UID to make the regexp pattern harder to guess.
-var UID                 = Math.floor(Math.random() * 0x10000000000).toString(16);
-var PLACE_HOLDER_REGEXP = new RegExp('"@__(F|R|D|M|S|U|I)-' + UID + '-(\\d+)__@"', 'g');
+var UID_LENGTH          = 16;
+var UID                 = generateUID();
+var PLACE_HOLDER_REGEXP = new RegExp('(\\\\)?"@__(F|R|D|M|S|U|I)-' + UID + '-(\\d+)__@"', 'g');
 
 var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g;
 var IS_PURE_FUNCTION = /function.*?\(/;
@@ -31,6 +34,15 @@ function escapeUnsafeChars(unsafeChar) {
     return ESCAPED_CHARS[unsafeChar];
 }
 
+function generateUID() {
+    var bytes = randomBytes(UID_LENGTH);
+    var result = '';
+    for(var i=0; i<UID_LENGTH; ++i) {
+        result += bytes[i].toString(16);
+    }
+    return result;
+}
+
 function deleteFunctions(obj){
     var functionKeys = [];
     for (var key in obj) {
@@ -187,7 +199,14 @@ module.exports = function serialize(obj, options) {
     // Replaces all occurrences of function, regexp, date, map and set placeholders in the
     // JSON string with their string representations. If the original value can
     // not be found, then `undefined` is used.
-    return str.replace(PLACE_HOLDER_REGEXP, function (match, type, valueIndex) {
+    return str.replace(PLACE_HOLDER_REGEXP, function (match, backSlash, type, valueIndex) {
+        // The placeholder may not be preceded by a backslash. This is to prevent
+        // replacing things like `"a\"@__R-<UID>-0__@"` and thus outputting
+        // invalid JS.
+        if (backSlash) {
+            return match;
+        }
+
         if (type === 'D') {
             return "new Date(\"" + dates[valueIndex].toISOString() + "\")";
         }

programming_language: JavaScript
filename: package-lock.json
change_type: ModificationType.MODIFY
diff: @@ -1592,6 +1592,14 @@
         "fromentries": "^1.2.0"
       }
     },
+    "randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "requires": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
     "readdirp": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz",
@@ -1649,8 +1657,7 @@
     "safe-buffer": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
-      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-      "dev": true
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
     },
     "semver": {
       "version": "5.7.1",

programming_language: JavaScript
filename: package.json
change_type: ModificationType.MODIFY
diff: @@ -29,5 +29,8 @@
     "chai": "^4.1.0",
     "mocha": "^7.0.0",
     "nyc": "^15.0.0"
+  },
+  "dependencies": {
+    "randombytes": "^2.1.0"
   }
 }

programming_language: JavaScript
filename: serialize.js
change_type: ModificationType.MODIFY
diff: @@ -1,9 +1,23 @@
 /* global describe, it, beforeEach */
 'use strict';
 
+// temporarily monkeypatch `crypto.randomBytes` so we'll have a
+// predictable UID for our tests
+var crypto = require('crypto');
+var oldRandom = crypto.randomBytes;
+crypto.randomBytes = function(len, cb) {
+    var buf = Buffer.alloc(len);
+    buf.fill(0x00);
+    if (cb)
+        cb(null, buf);
+    return buf;
+};
+
 var serialize = require('../../'),
     expect    = require('chai').expect;
 
+crypto.randomBytes = oldRandom;
+
 describe('serialize( obj )', function () {
     it('should be a function', function () {
         expect(serialize).to.be.a('function');
@@ -493,4 +507,17 @@ describe('serialize( obj )', function () {
             expect(serialize([1], 2)).to.equal('[\n  1\n]');
         });
     });
+
+    describe('placeholders', function() {
+        it('should not be replaced within string literals', function () {
+            // Since we made the UID deterministic this should always be the placeholder
+            var fakePlaceholder = '"@__R-0000000000000000-0__@';
+            var serialized = serialize({bar: /1/i, foo: fakePlaceholder}, {uid: 'foo'});
+            var obj = eval('(' + serialized + ')');
+            expect(obj).to.be.a('Object');
+            expect(obj.foo).to.be.a('String');
+            expect(obj.foo).to.equal(fakePlaceholder);
+        });
+    });
+
 });

programming_language: JavaScript