Skip to content

Argon2id.deriveKey gives segmentation fault for large parameter values #177

Open

Description

@elliotwutingfeng

I am testing out some Dart cryptography libraries for deriving Argon2id keys with large parameter values, such as the maximum allowed settings in the Bitwarden password manager, which as of today are 10 iterations, 16 parallelism, and 1024 MiB memory.

The following leads to a segmentation fault. Running the script with --old_gen_heap_size=2g sometimes gives a segmentation fault, but sometimes it works.

import 'dart:convert';
import 'dart:typed_data';

import 'package:cryptography/cryptography.dart';

Future<void> main() async {
  final Uint8List password = utf8.encode("passphrase");
  final List<int> salt =
      (await Sha256().hash(base64.decode("rNYWSe/wFO1k+Qxia0A96A=="))).bytes;

  // Bitwarden maximum settings
  final int kdfIterations = 10;
  final int kdfParallelism = 16;
  final int kdfMemory = 1024; // 1024 MiB

  final Argon2id algorithm = Argon2id(
    parallelism: kdfParallelism,
    memory: kdfMemory * 1024,
    iterations: kdfIterations,
    hashLength: 32,
  );
  final SecretKey newSecretKey =
      await algorithm.deriveKey(secretKey: SecretKey(password), nonce: salt);
  final List<int> newSecretKeyBytes = await newSecretKey.extractBytes();
  print('hashed password: $newSecretKeyBytes');
}

Equivalent working program using pointycastle.

import 'dart:convert';
import 'dart:typed_data';

import 'package:pointycastle/digests/sha256.dart';
import 'package:pointycastle/key_derivators/argon2_native_int_impl.dart';
import 'package:pointycastle/pointycastle.dart';

Future<void> main() async {
  final Uint8List password = utf8.encode("passphrase");
  final Uint8List salt =
      SHA256Digest().process(base64.decode("rNYWSe/wFO1k+Qxia0A96A=="));

  // Bitwarden maximum settings
  final int kdfIterations = 10;
  final int kdfParallelism = 16;
  final int kdfMemory = 1024; // 1024 MiB

  final List<int> newSecretKeyBytes = (Argon2BytesGenerator()
        ..init(Argon2Parameters(Argon2Parameters.ARGON2_id, salt,
            desiredKeyLength: 32,
            iterations: kdfIterations,
            memory: kdfMemory * 1024,
            lanes: kdfParallelism)))
      .process(Uint8List.fromList(password));
  print('hashed password: $newSecretKeyBytes');
}

Expected output for both programs:

hashed password: [207, 41, 183, 164, 139, 92, 97, 166, 115, 223, 111, 83, 7, 239, 193, 74, 180, 246, 103, 53, 177, 250, 203, 81, 148, 124, 62, 225, 215, 116, 170, 240]

I am aware that it is not advisable to set Argon2id KDF settings to such extreme values in either case, however, this may be worth some investigation.

Activity

  1. elliotwutingfeng commented on Oct 19, 2024

    @elliotwutingfeng
    Author

    An alternative solution would be to use the hashlib package, which is able to handle the aforementioned KDF settings (10 iterations, 16 parallelism, and 1024 MiB memory).

  2. terrier989 commented on Nov 21, 2025

    @terrier989
    Member

    Thank you for the issue report! 👍 I'll look at this later.

  3. diogogfre94-ai commented on Aug 22, 2026

    @diogogfre94-ai

    I hit this too, and I think I have the root cause. It is not memory exhaustion — it is a use-after-free, and it reproduces well below "large parameter values".

    Evidence that pointed at the mechanism

    Nine independent CI failures, same parameters (parallelism: 4, memory: 262144 (256 MiB), iterations: 3, hashLength: 32), on a 2-vCPU Linux runner. The interval between the start of the derivation and the crash was:

    10.10  10.15  10.11  10.12  10.11  10.06  10.11  10.08  10.14   (seconds)
    

    Nine out of nine within 90 ms of each other. That is not resource pressure — it is the hardcoded Duration(seconds: 10) in _sendSegmentsToIsolate (lib/src/dart/argon2_impl_default.dart).

    The mechanism

    1. getByteBuffer() allocates the block buffer with malloc and hands worker isolates only the raw address, which they rebuild with Pointer.fromAddress. The GC cannot see that reference.
    2. If a segment exceeds the 10 s deadline, the controlling isolate throws StateError('Segment processing timeout'). The worker is never told, and keeps writing.
    3. The finally in iterate() only schedules the kills — isolateFuture.then(... isolate.kill(...)) is not awaited, and Isolate.kill is itself asynchronous.
    4. Unwinding continues: deriveKeyBytesFromPrehashingDigest's finally sets _isBufferUsed = false, which disarms the guard in tryReleaseMemory(); deriveKey's finally then calls it and frees the buffer.
    5. glibc munmaps an allocation that size, so the region becomes unmapped. The still-running worker touches it and the process aborts.

    Typical crash:

    si_signo=Segmentation fault(11), si_code=SEGV_MAPERR(1), si_addr=0x7f7968700010
    isolate=argon2-0(...)
      pc ... [Optimized]   DartArgon2State.processBlock+0x575
      pc ... [Optimized]   DartArgon2State.processSegment+0x379
      pc ... [Unoptimized] DartArgon2StateImplFfi._segmentProcessingEntryPoint.<anonymous closure>
    

    Exit code 134 (SIGABRT). Sometimes two isolates die at once and the dump is truncated with Aborting reentrant request for stack trace.

    Why it is not out-of-bounds indexing

    Workers access the buffer through a typed-data view (asTypedList), which the VM bounds-checks — an out-of-range index would raise RangeError. A segfault through a bounds-checked view means the memory is no longer mapped.

    Why "large parameter values" is a red herring

    Large parameters make it likelier by making segments slower, but nothing has to be large: the deadline only has to be missed once. At 256 MiB with parallelism: 4 on a 2-vCPU machine it happened in 9 of 72 runs (12.5%). On a fast, idle machine the same code never crashed, which is why it reads as random.

    Workaround that does not require leaving the package

    Setting maxIsolates: 0 on DartArgon2id (from package:cryptography/dart.dart) avoids it entirely:

    import 'package:cryptography/dart.dart';
    
    final algorithm = DartArgon2id(
      parallelism: 4,
      memory: 262144,
      iterations: 3,
      hashLength: 32,
      maxIsolates: 0, // no isolates -> heap Uint32List, no raw pointer, no deadline, no free()
    );

    With isolateCount == 0, getByteBuffer() takes the other branch — a GC-managed Uint32List — so the raw pointer, the 10 s deadline and the free() all disappear.

    The digest is unchanged; I verified byte-for-byte against the isolate path at 8 MiB and 64 MiB (parallelism: 4), and against independently generated Argon2id reference vectors (Python argon2-cffi and Node argon2) at 256 MiB / 3 iterations / parallelism: 4.

    Cost is a slowdown, and not a uniform one: at 8 MiB it was actually faster (163 ms → 70 ms, isolate startup dominates), while at 64 MiB it was ~1.9× slower (289 ms → 542 ms).

    Possible fixes

    1. Await the isolate kills in iterate()'s finally before returning, so tryReleaseMemory() cannot race a live worker.
    2. Or never free on the timeout path — treat a timed-out state as poisoned and leak it, rather than freeing memory of unknown liveness.
    3. Have the worker acknowledge cancellation before the controller proceeds.
    4. Independently: make the deadline configurable and scale its default with memory × iterations. On its own this only makes the race rarer, but a fixed 10 s is easy to miss on a loaded machine.

    Happy to open a PR for (1) if that is the direction you would prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions