Skip to content

Commit 1c53893

Browse files
tniessenrichardlau
authored andcommitted
policy: use tamper-proof integrity check function
Using the JavaScript Hash class is unsafe because its internals can be tampered with. In particular, an application can cause Hash.prototype.digest() to return arbitrary values, thus allowing to circumvent the integrity verification that policies are supposed to guarantee. Add and use a new C++ binding internalVerifyIntegrity() that (hopefully) cannot be tampered with from JavaScript. PR-URL: nodejs-private/node-private#462 Backport-PR-URL: nodejs-private/node-private#493 Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com> CVE-ID: CVE-2023-38552
1 parent a792bbc commit 1c53893

File tree

8 files changed

+102
-13
lines changed

8 files changed

+102
-13
lines changed

lib/internal/policy/manifest.js

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ const {
1616
StringPrototypeEndsWith,
1717
StringPrototypeStartsWith,
1818
Symbol,
19-
uncurryThis,
2019
} = primordials;
2120
const {
2221
ERR_MANIFEST_ASSERT_INTEGRITY,
@@ -28,13 +27,8 @@ let debug = require('internal/util/debuglog').debuglog('policy', (fn) => {
2827
debug = fn;
2928
});
3029
const SRI = require('internal/policy/sri');
31-
const crypto = require('crypto');
32-
const { Buffer } = require('buffer');
3330
const { URL } = require('internal/url');
34-
const { createHash, timingSafeEqual } = crypto;
35-
const HashUpdate = uncurryThis(crypto.Hash.prototype.update);
36-
const HashDigest = uncurryThis(crypto.Hash.prototype.digest);
37-
const BufferToString = uncurryThis(Buffer.prototype.toString);
31+
const { internalVerifyIntegrity } = internalBinding('crypto');
3832
const kRelativeURLStringPattern = /^\.{0,2}\//;
3933
const { getOptionValue } = require('internal/options');
4034
const shouldAbortOnUncaughtException = getOptionValue(
@@ -588,16 +582,13 @@ class Manifest {
588582
// Avoid clobbered Symbol.iterator
589583
for (let i = 0; i < integrityEntries.length; i++) {
590584
const { algorithm, value: expected } = integrityEntries[i];
591-
const hash = createHash(algorithm);
592585
// TODO(tniessen): the content should not be passed as a string in the
593586
// first place, see https://github.com/nodejs/node/issues/39707
594-
HashUpdate(hash, content, 'utf8');
595-
const digest = HashDigest(hash, 'buffer');
596-
if (digest.length === expected.length &&
597-
timingSafeEqual(digest, expected)) {
587+
const mismatchedIntegrity = internalVerifyIntegrity(algorithm, content, expected);
588+
if (mismatchedIntegrity === undefined) {
598589
return true;
599590
}
600-
realIntegrities.set(algorithm, BufferToString(digest, 'base64'));
591+
realIntegrities.set(algorithm, mismatchedIntegrity);
601592
}
602593
}
603594

src/crypto/crypto_hash.cc

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ void Hash::Initialize(Environment* env, Local<Object> target) {
6969
SetMethodNoSideEffect(context, target, "getHashes", GetHashes);
7070

7171
HashJob::Initialize(env, target);
72+
73+
SetMethodNoSideEffect(
74+
context, target, "internalVerifyIntegrity", InternalVerifyIntegrity);
7275
}
7376

7477
void Hash::RegisterExternalReferences(ExternalReferenceRegistry* registry) {
@@ -78,6 +81,8 @@ void Hash::RegisterExternalReferences(ExternalReferenceRegistry* registry) {
7881
registry->Register(GetHashes);
7982

8083
HashJob::RegisterExternalReferences(registry);
84+
85+
registry->Register(InternalVerifyIntegrity);
8186
}
8287

8388
void Hash::New(const FunctionCallbackInfo<Value>& args) {
@@ -310,5 +315,50 @@ bool HashTraits::DeriveBits(
310315
return true;
311316
}
312317

318+
void InternalVerifyIntegrity(const v8::FunctionCallbackInfo<v8::Value>& args) {
319+
Environment* env = Environment::GetCurrent(args);
320+
321+
CHECK_EQ(args.Length(), 3);
322+
323+
CHECK(args[0]->IsString());
324+
Utf8Value algorithm(env->isolate(), args[0]);
325+
326+
CHECK(args[1]->IsString() || IsAnyByteSource(args[1]));
327+
ByteSource content = ByteSource::FromStringOrBuffer(env, args[1]);
328+
329+
CHECK(args[2]->IsArrayBufferView());
330+
ArrayBufferOrViewContents<unsigned char> expected(args[2]);
331+
332+
const EVP_MD* md_type = EVP_get_digestbyname(*algorithm);
333+
unsigned char digest[EVP_MAX_MD_SIZE];
334+
unsigned int digest_size;
335+
if (md_type == nullptr || EVP_Digest(content.data(),
336+
content.size(),
337+
digest,
338+
&digest_size,
339+
md_type,
340+
nullptr) != 1) {
341+
return ThrowCryptoError(
342+
env, ERR_get_error(), "Digest method not supported");
343+
}
344+
345+
if (digest_size != expected.size() ||
346+
CRYPTO_memcmp(digest, expected.data(), digest_size) != 0) {
347+
Local<Value> error;
348+
MaybeLocal<Value> rc =
349+
StringBytes::Encode(env->isolate(),
350+
reinterpret_cast<const char*>(digest),
351+
digest_size,
352+
BASE64,
353+
&error);
354+
if (rc.IsEmpty()) {
355+
CHECK(!error.IsEmpty());
356+
env->isolate()->ThrowException(error);
357+
return;
358+
}
359+
args.GetReturnValue().Set(rc.FromMaybe(Local<Value>()));
360+
}
361+
}
362+
313363
} // namespace crypto
314364
} // namespace node

src/crypto/crypto_hash.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ struct HashTraits final {
8282

8383
using HashJob = DeriveBitsJob<HashTraits>;
8484

85+
void InternalVerifyIntegrity(const v8::FunctionCallbackInfo<v8::Value>& args);
86+
8587
} // namespace crypto
8688
} // namespace node
8789

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.js text eol=lf
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const h = require('crypto').createHash('sha384');
2+
const fakeDigest = h.digest();
3+
4+
const kHandle = Object.getOwnPropertySymbols(h)
5+
.find((s) => s.description === 'kHandle');
6+
h[kHandle].constructor.prototype.digest = () => fakeDigest;
7+
8+
require('./protected.js');
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"resources": {
3+
"./main.js": {
4+
"integrity": true,
5+
"dependencies": {
6+
"./protected.js": true,
7+
"crypto": true
8+
}
9+
},
10+
"./protected.js": {
11+
"integrity": "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb",
12+
"dependencies": true
13+
}
14+
}
15+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log(require('fs').readFileSync('/etc/passwd').length);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
common.requireNoPackageJSONAbove();
7+
8+
const fixtures = require('../common/fixtures');
9+
10+
const assert = require('assert');
11+
const { spawnSync } = require('child_process');
12+
13+
const mainPath = fixtures.path('policy', 'crypto-hash-tampering', 'main.js');
14+
const policyPath = fixtures.path(
15+
'policy',
16+
'crypto-hash-tampering',
17+
'policy.json');
18+
const { status, stderr } =
19+
spawnSync(process.execPath, ['--experimental-policy', policyPath, mainPath], { encoding: 'utf8' });
20+
assert.strictEqual(status, 1);
21+
assert(stderr.includes('sha384-Bnp/T8gFNzT9mHj2G/AeuMH8LcAQ4mljw15nxBNl5yaGM7VgbMzDT7O4+dXZTJJn'));

0 commit comments

Comments
 (0)