I was implementing password authentication for VNC in node.js the other day and faced a problem where it would just never successfully authenticate. I checked my implementation several times and it seemed fine. Then I tried to implement it in Python just to see if I was missing something obvious. It also didn't work. Then I tried doing the authentication through openssl and it was also giving a result I didn't expect. Finally I found some old C code and wrote node-des, which worked. I still haven't figured out why it didn't work in node, python and openssl. Perhaps my readers can help me understand what was going on!
Here is a quick overview of how VNC password authentication works. On the VNC server you set the authentication password. When a VNC client connects, the VNC server generates random 16 byte garbage called challenge and sends it to the client. The client DES encrypts the challenge with the password as the key. The result is called the response and is also 16 bytes in size. The client then sends it to the VNC server. The VNC server then also DES encrypts challenge with the password and compares it with the response. If challenge matches response, the session gets authenticated.
Let's say the password set on the VNC server is browsers
, and the challenge that the VNC server sends to the client is the following 16 bytes:
0c fd 6b 8c 5c 04 b3 e5 01 40 b9 de 33 9e 0d db
Then DES encrypting these 16 bytes with the password browsers
should produce the following response:
44 ee fc 48 83 bb 95 f1 c0 82 c3 e2 98 c3 6a 4a
I sniffed this data from a real VNC client - VNC server communication and I use this as a test case for node-des as it's the correct result. However now let's look at what happens if I encrypt the challenge with node's crypto module, with Python and with openssl.
First node's crypto module. I wrote the following program to DES encrypt the 16 challenge challenge bytes with the password browsers
.
var crypto = require('crypto');
var challenge = Buffer([
0x0c, 0xfd, 0x6b, 0x8c,
0x5c, 0x04, 0xb3, 0xe5,
0x01, 0x40, 0xb9, 0xde,
0x33, 0x9e, 0x0d, 0xdb
]);
var response = crypto
.createCipher('des', 'browsers')
.update(challenge);
console.log(Buffer(response));
When I run it, it produces 27 byte long output and it's not what I'd expect:
<Buffer c3 a9 72 c2 8b c3 a2 10 56 c2 8f c3 94 12 c2 a3 c3 a3 33 c2 ba c3 9e c2 a7 c2 96>
So node.js doesn't produce the expected 16 byte result. In fact I tried all the possible variations of the des algorithm that openssl (which node.js bases its crypto module on) supports. Perhaps I chose the wrong des implementation as there are many?
var crypto = require('crypto');
var challenge = Buffer([
0x0c, 0xfd, 0x6b, 0x8c,
0x5c, 0x04, 0xb3, 0xe5,
0x01, 0x40, 0xb9, 0xde,
0x33, 0x9e, 0x0d, 0xdb
]);
var algos = ("des des-cbc des-cfb des-ecb des-ede " +
"des-ede-cbc des-ede-cfb des-ede-ofb " +
"des-ede3 des-ede3-cbc des-ede3-cfb " +
"des-ede3-ofb des-ofb des3 desx").split(/\s+/);
algos.forEach(function (algo) {
console.log(algo);
try {
var response = crypto
.createCipher(algo, 'browsers')
.update(challenge)
console.log(Buffer(response));
}
catch (x) {
console.log("Crypto doesn't support:", algo);
}
});
It produced the following output and not a single result was what I'd expect:
des <Buffer c3 a9 72 c2 8b c3 a2 10 56 c2 8f c3 94 12 c2 a3 c3 a3 33 c2 ba c3 9e c2 a7 c2 96> des-cbc <Buffer c3 a9 72 c2 8b c3 a2 10 56 c2 8f c3 94 12 c2 a3 c3 a3 33 c2 ba c3 9e c2 a7 c2 96> des-cfb <Buffer 24 43 c2 99 11 c2 8c c3 ac c3 b2 42 c3 b1 77 c3 a2 c2 96 05 c3 b3 c3 94 02> des-ecb <Buffer c2 aa 09 c3 98 c3 92 c2 8c 69 68 c2 97 c2 88 09 c2 ae c3 89 4f 3f c3 9f c3 83> des-ede <Buffer c2 84 c2 86 3f c3 ba 2b c2 ba 4b c2 bd 50 05 c3 a8 c3 9d 37 66 15 51> des-ede-cbc <Buffer 7d 60 c2 8d 4e 27 c3 b2 0f 6f 0e 48 54 60 38 2e c2 b3 7e> des-ede-cfb <Buffer 43 4d c2 ac 19 c3 92 c2 81 c2 a2 3f c2 9a c2 be c2 8a c2 b3 72 44 37 34> des-ede-ofb <Buffer 43 4d c2 ac 19 c3 92 c2 81 c2 a2 3f 49 c3 a4 04 24 2c 77 05 40> des-ede3 <Buffer c2 a4 c2 84 c2 b2 2e 45 c2 90 c2 af c2 9c 21 5b c2 bd c3 aa c2 84 c3 9b 4c 76> des-ede3-cbc <Buffer c3 81 5c c2 b9 28 c2 be c3 b6 74 c3 89 c3 89 c2 8c 62 c2 ab 3a c2 95 6c 3f> des-ede3-cfb <Buffer 3f c3 8d 22 c3 8e 5d 01 29 c2 8b c2 85 c2 b5 11 75 6f c2 b5 5b c3 9c> des-ede3-ofb <Buffer 3f c3 8d 22 c3 8e 5d 01 29 c2 8b c2 8c 64 51 c2 8e c2 a7 2f c2 a8 c2 97> des-ofb <Buffer 24 43 c2 99 11 c2 8c c3 ac c3 b2 42 09 68 1b c3 bd 2d 79 c2 ba c3 a1> des3 <Buffer c3 81 5c c2 b9 28 c2 be c3 b6 74 c3 89 c3 89 c2 8c 62 c2 ab 3a c2 95 6c 3f> desx <Buffer 4c 45 c2 bb c2 bd 78 c2 bd c3 b5 c2 b9 c3 96 c3 b7 c3 b0 6d c3 b9 c3 8d c3 a9 2d>
I had no idea what was going on at this point, so I decided to try Python and see if I can get the result I expect through Python's DES algorithm. Worst case I could spawn Python each time I need to authenticate at VNC. I used the pyDes module for my experiment and installed it to virtualenv as following:
$ virtualenv pydes $ cd pydes $ . ./bin/activate $ easy_install pydes
Then I wrote the following test program:
from pyDes import des
challenge = "\x0c\xfd\x6b\x8c" \
"\x5c\x04\xb3\xe5" \
"\x01\x40\xb9\xde" \
"\x33\x9e\x0d\xdb";
response = des("browsers").encrypt(challenge);
print ["0x%x" % ord(b) for b in response]
The output was 16 bytes but not what I'd expect:
['0xd0', '0x6f', '0x0', '0x1a', '0x73', '0xb8', '0x24', '0xc6', '0xf1', '0x72', '0x81', '0x6e', '0x6d', '0x59', '0xc8', '0xa7']
Then I thought, perhaps the endianness was incorrect, so I reversed the words in the challenge:
from pyDes import des
challenge = "\xfd\x0c\x8c\x6b" \
"\x04\x5c\xe5\xb3" \
"\x40\x01\xde\xb9" \
"\x9e\x33\xdb\x0d";
response = des("browsers").encrypt(challenge);
print ["0x%x" % ord(b) for b in response]
But no, this was also giving some other result that wouldn't work with VNC:
['0x2e', '0xe8', '0xec', '0x7c', '0xde', '0xf7', '0x36', '0x56', '0xf0', '0x60', '0xbe', '0x6f', '0xb', '0xf8', '0x9b', '0x76']
Being totally clueless, I decided to use the plain command line openssl
tool just to see if node.js or Python were messing up the binary data or something like that. As I had sniffed the challenge from the VNC session, I put it in a file challenge
and ran the openssl
tool as following:
$ openssl des -pass pass:browsers -in challenge -e -nosalt | hexdump 0000000 72e9 e28b 5610 d48f a312 33e3 deba 96a7 0000010 d253 826d 9b86 3620
The output was again different, 24 bytes in size and it didn't match the response.
At this point I had had enough with all these different results and I started searching the web for the simplest possible DES implementation as I wasn't up to implementing DES myself. I found some C code that was public domain and wrote node-des that produces the results that I expect and that VNC server accepts.
var des = require('../build/Release/des.node');
var challenge = Buffer([
0x0c, 0xfd, 0x6b, 0x8c,
0x5c, 0x04, 0xb3, 0xe5,
0x01, 0x40, 0xb9, 0xde,
0x33, 0x9e, 0x0d, 0xdb
]);
var response = des.encrypt('browsers', challenge);
console.log(response);
Output:
$ node des.js <SlowBuffer 44 ee fc 48 83 bb 95 f1 c0 82 c3 e2 98 c3 6a 4a>
Victory! It worked and my VNC code was functional! The question is, why did every single attempt to DES encrypt with node, python and openssl fail? Any ideas? Let's get to the bottom of this in the comments!
Update
One of my readers noticed that many of the words in node's output were of the form cx xx
, so he asked me if I was sure that node wasn't converting the output to utf8? I was pretty sure it wasn't, buffers are buffers of raw bytes after all, but this looked too suspicious. I looked up in the documentation and found out that the default encoding for the buffers was utf8...
So I modified the original program and replaced console.log(Buffer(response))
with console.log(Buffer(response, 'binary'))
.
var crypto = require('crypto');
var challenge = Buffer([
0x0c, 0xfd, 0x6b, 0x8c,
0x5c, 0x04, 0xb3, 0xe5,
0x01, 0x40, 0xb9, 0xde,
0x33, 0x9e, 0x0d, 0xdb
]);
var response = crypto
.createCipher('des', 'browsers')
.update(challenge);
console.log(Buffer(response, 'binary'));
Now I do get 16 bytes in the output but it still doesn't match the sniffed response:
<Buffer e9 72 8b e2 10 56 8f d4 12 a3 e3 33 ba de a7 96>
I also modified the program that runs through all the DES algorithms by adding the 'binary'
encoding to the Buffer's constructor. The output now is 16 bytes for all algorithms:
des <Buffer e9 72 8b e2 10 56 8f d4 12 a3 e3 33 ba de a7 96> des-cbc <Buffer e9 72 8b e2 10 56 8f d4 12 a3 e3 33 ba de a7 96> des-cfb <Buffer 24 43 99 11 8c ec f2 42 f1 77 e2 96 05 f3 d4 02> des-ecb <Buffer aa 09 d8 d2 8c 69 68 97 88 09 ae c9 4f 3f df c3> des-ede <Buffer 84 86 3f fa 2b ba 4b bd 50 05 e8 dd 37 66 15 51> des-ede-cbc <Buffer 7d 60 8d 4e 27 f2 0f 6f 0e 48 54 60 38 2e b3 7e> des-ede-cfb <Buffer 43 4d ac 19 d2 81 a2 3f 9a be 8a b3 72 44 37 34> des-ede-ofb <Buffer 43 4d ac 19 d2 81 a2 3f 49 e4 04 24 2c 77 05 40> des-ede3 <Buffer a4 84 b2 2e 45 90 af 9c 21 5b bd ea 84 db 4c 76> des-ede3-cbc <Buffer c1 5c b9 28 be f6 74 c9 c9 8c 62 ab 3a 95 6c 3f> des-ede3-cfb <Buffer 3f cd 22 ce 5d 01 29 8b 85 b5 11 75 6f b5 5b dc> des-ede3-ofb <Buffer 3f cd 22 ce 5d 01 29 8b 8c 64 51 8e a7 2f a8 97> des-ofb <Buffer 24 43 99 11 8c ec f2 42 09 68 1b fd 2d 79 ba e1> des3 <Buffer c1 5c b9 28 be f6 74 c9 c9 8c 62 ab 3a 95 6c 3f> desx <Buffer 4c 45 bb bd 78 bd f5 b9 d6 f7 f0 6d f9 cd e9 2d>
But still no match.
Solution
Ryan (in the comments below) found out why this was happening. Turns out that the VNC authentication reverses the order of bits in every byte of the password.
I was basing my implementation on the RFB protocol's specification and it doesn't say a word about it.
I'm quoting that document:
The server sends a random 16-byte challenge. The client encrypts the challenge with DES, using a password supplied by the user as the key, and sends the resulting 16-byte response. There is no way I could have guessed this. How often do you think of reversing bits in every byte of the password? Never.