There are some special features in NaN. For example, it is a variable in global scope,
NaN !== NaN, etc. They are interesting but not the point of here. The point is, how we hide secrets in NaN.
According to the protocol:
Binary format NaNs are represented with the exponential field filled with ones (like infinity values), and some non-zero number in the significand field (to make them distinct from infinity values).
So actually only some bits in 64-bit number are used to represent NaN, and the remaining bits are not used and are called payload bits. In other words, we can make use of these bits to hide our secrets.
Now enough with the theory, let get our hands dirty to see how it can be done.
First, we need to see the bits of NaN. We can use the typed array to do it. First use Float64Array to get the NaN buffer, then convert it into Uint8Array to see the real bits.
> b = new Uint8Array(new Float64Array([NaN]).buffer) Uint8Array(8) [ 0, 0, 0, 0, 0, 0, 248, 127 ]
As you may guess, it is in little-endian form. Now let's see the bits that make the value a NaN.
> (127 << 8 | 248).toString(2) '111111111111000'
So there are still 3 bits left in the last 2 bytes. So we have 6 bytes plus 3 bits, 51 bits in total in our hand.
Say our secret message is
hello, let's write it in these remaining bits.
> const message = [..."hello"].map(c => c.charCodeAt(0)) > message [ 104, 101, 108, 108, 111 ] > message.forEach((v,i) => b[i] = v) > b Uint8Array(8) [ 104, 101, 108, 108, 111, 0, 248, 127 ]
OK. The message is written. Let's see it in NaN form.
> const nan = new Float64Array(b.buffer); > nan NaN
Yeah. It is a valid NaN.
And lastly, let's decode our secret from this NaN value.
> b = new Uint8Array(new Float64Array([nan]).buffer) Uint8Array(8) [ 104, 101, 108, 108, 111, 0, 248, 127 ] > [...b.slice(0, 5)].map(v => String.fromCharCode(v)).join(""); 'hello'