Skip to content
This repository has been archived by the owner on Dec 11, 2019. It is now read-only.

Misuse of Math.random() to generate random integers #6944

Closed
dimitri-xyz opened this issue Jan 31, 2017 · 14 comments · Fixed by #14851
Closed

Misuse of Math.random() to generate random integers #6944

dimitri-xyz opened this issue Jan 31, 2017 · 14 comments · Fixed by #14851

Comments

@dimitri-xyz
Copy link

In

var segments = ['IAB2', 'IAB17', 'IAB14', 'IAB21', 'IAB20']
var segment = segments[Math.floor(Math.random() * 4)]

That is:

var segments = ['IAB2', 'IAB17', 'IAB14', 'IAB21', 'IAB20']
var segment = segments[Math.floor(Math.random() * 4)]

The last value 'IAB20' is excluded from the range of random() and will never be chosen.

Here's another bug:

const count = Math.round(Math.random() * 100)

 const count = Math.round(Math.random() * 100)

In this case, both 0 and 100 are part of the results, but only occur half as frequently as the other numbers. To see why this happens consider:

 const zeroOneTwo = Math.round(Math.random() * 2)

all numbers from 0 to 0.49999 will return 0
all numbers from 0.5 to 1.4999 will return 1
all numbers from 1.5 to 1.9999 will return 2

So 1 occurs with probbility 1/2 and 0 and 2 are returned with probability 1/4 each.
This is very different from the expected uniform distribution (1/3,1/3,1/3).

The problem here is the flooring or rounding of floating point random numbers to obtain integers.
I think the "proper" way to obtain random integers in a given range is to use randomInt from random-lib

There are many more instances of this in the code. Just do a search for random() to find them.

@luixxiul luixxiul added this to the 0.13.2 milestone Jan 31, 2017
@luixxiul luixxiul added the bug label Jan 31, 2017
@luixxiul luixxiul modified the milestones: 0.13.3, 0.13.2 Jan 31, 2017
@bbondy bbondy modified the milestones: 0.13.5, 0.13.6 Feb 15, 2017
@bsclifton
Copy link
Member

bsclifton commented Mar 17, 2017

We do use random-lib in our ledger (payments) code; it wouldn't be hard to rework other instances to call that instead

Docs on Math.random() explaining the exclusivity issue shown in the first example (4 never being possible because 1 is excluded from Math.random results)

Good find, thanks @dimitri-xyz!

@bsclifton bsclifton removed the bug label Mar 17, 2017
@cndouglas
Copy link

Which instances are worth fixing? https://github.com/brave/browser-laptop/search?l=JavaScript&q=random&type=Code&utf8=%E2%9C%93
Here is an example for the new tab page backgrounds: (js/about/newtab.js)

@@ -19,7 +19,7 @@ const urlutils = require('../lib/urlutil')
 const siteTags = require('../constants/siteTags')
 const config = require('../constants/config')
 const backgrounds = require('../data/backgrounds')
-const {random} = require('../../app/common/lib/randomUtil')
+const random = require('random-lib')
 
 const ipc = window.chrome.ipcRenderer
 
@@ -65,7 +65,7 @@ class NewTabPage extends React.Component {
     return this.state.showImages && !!this.state.backgroundImage
   }
   get randomBackgroundImage () {
-    const image = Object.assign({}, backgrounds[Math.floor(random() * backgrounds.length)])
+    const image = Object.assign({}, backgrounds[random.randomInt({ min: 0, max: backgrounds.length })])
     image.style = {backgroundImage: 'url(' + image.source + ')'}
     return image
   }

@dimitri-xyz
Copy link
Author

dimitri-xyz commented Mar 22, 2017

@liunkae ,

Fixing this turns out to be more complicated than I thought. There are 2 separate issues here:

  1. Inadvertently excluding boundary values when generating random numbers
  2. Generating a non-uniform (i.e. biased) probability distribution

I think we should fix (1) now, but leave (2) for later.

To fix the first issue, fix all instances where a floating-point random value obtained through random() is later turned into an Integer. The conversion can be made by truncation, rounding, flooring, etc. The key is to think floating-point random number later converted into an integer. We just have to be careful to know whether the "large" boundary value should be included. Your example looks good, but I don't know the code and, thus I don't know if we should include only length-1 or should also include length. You'd have to investigate each case. Maybe @bsclifton or someone else should pitch in here on how to do that.

The second problem (non-uniform distribution) turns out to be worse than I thought. The Mozilla Docs are suggesting an incorrect way to do it (getRandomInt and getRandomIntInclusive are biased) and random-lib's randomInt() is also incorrectly generating its integers and causing a small bias. The bias is really small in most cases, so for most applications it will be fine. I will work with them to fix those issues.

The correct way to generate random integers is Rejection Sampling.

@fardog
Copy link

fardog commented Apr 22, 2017

I'm the author of random-lib.

@liunkae for the options passed to randomInt, min is inclusive and max is exclusive, so that would be the correct fix. (see docs)

As to fixing the bias issue, @dimitri-xyz has written up a very thorough blog post detailing the issue with the algorithm mozilla details and i'll be attempting to fix this issue over the next week or so (if anyone has the time to get to this faster, pull requests are very welcome!). I'll update here once a version is available that removes the bias.

Edit: and after reading their blog post thoroughly, it looks like this bias is really really small; so while I'll definitely want to get it fixed, as @dimitri-xyz highlights it's probably not a huge concern in the interim.

@luixxiul luixxiul added the bug label Jun 4, 2017
@333aleix333
Copy link

Hi! I think I have corrected all the flooring and all the rounding, but I'm new and I don't know what should I do now.
Thanks!

@fardog
Copy link

fardog commented Sep 18, 2017

@liunkae wanted to let you know this hasn't been forgotten; i've written up a separate module—rejection-sampled-int—which implements the rejection sampling algorithm and should make for unbiased integers from nodejs/browser crypto. However, I haven't had this audited yet, so I haven't integrated it into random-lib yet. It is also significantly slower, as you might imagine, so for areas where the bias is not as much of a concern, you may find it faster to use a biased method such as the ones already in random-lib, if using crypto.randomBytes is desired for entropy.

Anyhow, if anyone has a chance to audit that module, it'd be hugely appreciated. Then, brave can just use that module as necessary; it doesn't seem to use any functions of random-lib aside from random integers, so there's no need for the whole library.

(n.b. there is actually a PR up for random-lib which uses rejection-sampled-int for integers; but i'm not merging it until an audit has been done: fardog/node-random-lib#14)

@diracdeltas
Copy link
Member

@dimitri-xyz thanks for pointing out this issue and the detailed explanations. Although we are not currently using Math.random and random-lib for purposes that require cryptographic randomness (such as generating crypto keys), I labeled this issue under security to make sure that these libraries are not misused in the future.

@333aleix333 thanks. please fork our repository, push your changes to the fork, then open a pull request against our repository. see here for some instructions: https://gist.github.com/Chaser324/ce0505fbed06b947d962

@fardog
Copy link

fardog commented Jan 5, 2018

random-lib has been updated to version 3.0, which should solve this issue; see here for the changes. It's now backed by rejection-sampled-int for integers, which I also wrote. I would definitely appreciate additional sets of eyes on it, as it's not gone through any formal audit, yet I have tested the features in both libraries heavily.

The API also changed in this version as the old one had behaviors that were very awkward, however conversion to the new API is super-straightforward since there wasn't much surface there to begin with.

Let me know if you have any questions!

@arsalankhalid
Copy link
Contributor

If that's the case, you guys should close this issue! @bsclifton

@bsclifton
Copy link
Member

@arsalankhalid looks like we're still using version 2.1.0. Did you want to submit a PR to update the version? 😄 Should be as easy as:
npm -i random-lib@latest --save

@fardog
Copy link

fardog commented Apr 24, 2018

There were API changes between v2 and v3. It should not be very involved, but it will require a few more changes. See https://github.com/fardog/node-random-lib/blob/master/CHANGELOG.md#300

I had intended to submit a pr, but wasn't able to get the brave browser tests working on my local machine in the short time I was able to allocate. If someone has the chance though, it should not be a significant change set.

@NejcZdovc NejcZdovc added the help wanted The PR/issue opener needs help to complete/report the task. label May 7, 2018
@lunaticmonk
Copy link

lunaticmonk commented Jul 21, 2018

@bsclifton Do we need an update for the random-lib? If yes, can I submit a PR?

fardog added a commit to fardog/browser-laptop that referenced this issue Jul 22, 2018
@fardog fardog mentioned this issue Jul 22, 2018
10 tasks
@fardog
Copy link

fardog commented Jul 22, 2018

I just put up a PR for it. tests aren't running right on my local machine, but looks like they're passing in CI; should be good?

Note that this still needs to be updated in other brave projects (bat-publisher, ledger-publisher, etc); i'll open tickets there, but unsure when/if i'll get to it, so someone definitely should if they have the time!

@fardog
Copy link

fardog commented Jul 23, 2018

submitted prs to bat-publisher and bat-client, since the others are deprecated ^

@bsclifton bsclifton modified the milestones: Triage Backlog, Completed work Jul 23, 2018
@bsclifton bsclifton removed bug/good-first-bug help wanted The PR/issue opener needs help to complete/report the task. labels Jul 26, 2018
@bsclifton bsclifton modified the milestones: Completed work, Triage Backlog Jul 26, 2018
riastradh-brave added a commit that referenced this issue Jul 26, 2018
fix #6944

Some of these changes change the _distribution_ of probabilities to
the possible outcomes, where it seemed obvious that a uniform
distribution was intended but not previously attained.

Some of these changes also change the _support_, the set of possible
outcomes, where it made things simpler and was not obviously intended
one way or another.

Many of these are not _necessarily_ bugs, but if deviations from
uniform are actually intended they should be clearly justified.

Full details:

- Change Math.floor(Math.random() * n) to crypto.random.uniform(n).

  - app/extensions/brave/content/scripts/adInsertion.js
  - js/about/newtab.js (with care to preserve test spying in newTabPageTest.js)
  - js/about/preferences.js (which used |0 rather than Math.floor)
  - test/about/bookmarksManagerTest.js
  - test/lib/brave.js

  - This was obviously intended to be a uniform distribution on
    {0,1,2,...,n-1}.  It is nonuniform only because of IEEE 754
    binary64 floating-point approximation.

- Change Math.round(Math.random() * (n-1)) to crypto.random.uniform(n)
  when used as an index into an n-element array.

  - tools/lib/randomHostname.js, _chooseRandomLetter / ALPHABET
  - tools/lib/randomHostname.js, tld / TLDS
  - tools/lib/synopsisHelpers.js, publisher / PROTOCOL_PREFIXES
  - tools/lib/transactionHelpers.js, generateContribution: amount
    - (Adjusted to use the actual length of the array, not just the
      first four elements of an eight-element array.)

  - This gave half the probability to 0 and n - 1 that it gave to 1,
    2, 3, ..., n - 3, n - 2, a much bigger deviation from uniform than
    IEEE 754 binary64 floating-point approximations of the above.
    There was no obvious reason why this nonuniformity was desirable.

- Change Math.round(Math.random() * n) to crypto.random.uniform(n)
  when _not_ used as an index into an array.

  - tools/lib/synopsisHelpers.js, numHosts
  - tools/lib/synopsisHelpers.js, numVisits
  - tools/lib/transactionHelpers.js, generateTransaction: count
  - tools/lib/synopsisHelpers.js, duration

  - This actually changes the _support_ of the distribution to exclude
    n, but in the only places where this was used, it is not otherwise
    obvious that the caller intended to include n in the support.  It
    could have been a typo for Math.floor(Math.random() * n) without
    adverse consequences in these contexts.

- Change Math.round(Math.random()) to crypto.random.uniform(2).

  - tools/lib/randomHostname.js, numParts

  - This was obviously intended to be a uniform distribution on {0,1},
    and actually it may even be uniform under Math.random as typically
    implemented, but writing crypto.random.uniform(2) is clearer to
    flip a coin and this way we just avoid Math.random() altogether.

- Obscure corner cases:

  - app/renderer/rendererTabEvents.js, nonce
    - This used Math.random().toString() to generate a string `that is
      unlikely to collide'.  There are only 2^64 64-bit floating-point
      values, of which Math.random() returns a small subset.  A
      collision is expected after only a few billion uniform random
      samples, which is far from impossible.  I replaced it by a
      uniform random 256-bit quantity in hex, which will never collide
      even in the view of a conservative paranoid cryptographer unless
      there is a deeper bug breaking the PRNG.

  - tools/lib/randomHostname.js, partLen
    - The distribution originally used here puts higher probability on
      the minimum part length than everything else, and lower
      probability on the maximum part length than anything else.  I
      see no reason why this should not just be uniformly distributed
      on part lengths {minLength, minLength + 1, ..., maxLength - 1,
      maxLength}, so I replaced it by minLength + uniform(maxLength -
      minLength + 1).

  - tools/lib/transactionHelpers.js, generateBallots: votesToCast

    - The previous expression, Math.min(Math.round(Math.random() *
      votesRemaining) + 1, votesRemaining), assigned half weight to 1
      and half-again weight to votesRemaining - 1 (as compared to all
      other numbers) for no obvious reason.  I changed it to use 1, if
      only 1 vote is remaining, or 1 + uniform(votesRemaining - 1), if
      more than one vote is remaining.

P.S.  It is a bit confusing that we have a module named `brave-crypto`
which is usually used as `const crypto = require('brave-crypto')`
while there is also a standard nodejs module called `crypto` which is
usually used as `const crypto = require('crypto')`.  In one case these
are used in the same file, so I named ours `braveCrypto`.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.