Skip to content

t_status is a process-wide global on Apple platforms, so concurrent writes to two encrypted instances can lose every key #1698

Open

Description

@zoeyhhh-prog

Platforms: iOS and macOS (every Apple platform)
Versions: v1.2.0 through v2.4.3 (the latest), and dev at 33f2817
Mode: encrypted instances (cryptKey), single-process

What happens

Two encrypted MMKV instances written on different threads at the same time can corrupt each other. The key that loses reads back as garbage. Overwriting or removing that key then writes garbage key bytes to the file under a valid CRC, and the next full load fails:

[E] <MiniPBCoder_OSX.cpp:184::decodeOneMap> InvalidProtocolBuffer truncatedMessage
[I] <MMKV_IO.cpp:154::loadFromFile> loaded [store.a] with 0 key-values

onMMKVCRCCheckFail and onMMKVFileLengthError are not called, because the CRC and the length are both correct.

Cause

Core/MMKV_IO.cpp:760-765 in v2.4.3 and dev:

// for Apple watch simulator
#    if defined(TARGET_OS_SIMULATOR) && defined(TARGET_CPU_X86)
static AESCryptStatus t_status;
#    else
thread_local AESCryptStatus t_status;
#    endif

<TargetConditionals.h> defines every TARGET_* macro on every Apple platform, as 0 or 1. On an iPhone, TARGET_OS_SIMULATOR and TARGET_CPU_X86 are both 0, but both are defined, so the static branch is taken everywhere, not only on the x86 watch simulator.

Compiled for arm64-apple-ios, nm -m puts t_status in (__DATA,__bss). With the change below, it moves to (__DATA,__thread_vars).

How the global corrupts data

  1. An encrypted write of a value larger than 256 bytes (KeyValueHolderCrypt::isValueStoredAsOffset) does three things:

    • saves the crypter's state in t_status (m_crypter->getCurStatus(t_status), MMKV_IO.cpp:1035 and :1114);
    • writes the record;
    • copies t_status into the key's holder (memcpy(&kvHolder.cryptStatus, &t_status, …), :810 and :841).

    It holds only that instance's m_lock while doing so.

  2. A large write to another encrypted instance on another thread can overwrite t_status between the first and the last of those steps.

    • The holder then keeps the other instance's state, and KeyValueHolderCrypt::toMMBuffer (KeyValueHolder.cpp:165-175) decrypts the value with it, so reads return garbage.
    • The file is still correct at this point, and clearMemoryCache() repairs it.
  3. Overwriting or removing that key goes through appendDataWithKey(…, const KeyValueHolderCrypt &) (MMKV_OSX.cpp:426-438). It decrypts the key's bytes with the holder's wrong state and writes them back. The file now holds garbage key bytes, correctly encrypted, under a valid CRC.

  4. The next full load (clearMemoryCache(), a memory warning, a relaunch) stops at that record with truncatedMessage, and the non-greedy decode keeps 0 key-values.

    • Occasionally the garbage parses instead. Then the overwrite or removal is lost, or a key with a garbage name appears.

Why this has gone unnoticed. Both writes have to be larger than 256 bytes, because smaller values are kept inside the holder with no saved state. It also needs two encrypted instances written at once. And when it happens, it looks like ordinary file corruption.

It may explain some earlier reports, such as #1185 and #1691, though neither gives enough detail to be sure.

Reproduction

tstatus_race.mm (below) uses only the public ObjC API. Each replay:

  1. creates encrypted store A with three keys, and reopens it so it loads from the file;
  2. starts three threads that write values of 300–3300 B to three other encrypted stores;
  3. overwrites a 769 B value in A and reads it back;
  4. removes that key and reloads A with clearMemoryCache().

run.sh <MMKV checkout> [replays] (below) does two builds: one of the checkout as it is, and one of a copy with the fix applied. For each, it prints where t_status lands in an iPhone build, then builds the reproduction for macOS (arm64) and runs it.

Results on macOS 26.6.1, Apple M5 Pro, Xcode 27.1:

Source t_status in an arm64 iPhone build Replays Garbled read-backs Reloads with 0 keys Other damaged reloads
v2.4.3 __DATA,__bss 2,500 1,330 1,319 11
v2.4.3 + fix __DATA,__thread_vars 3,000 0 0 0
dev 33f2817 __DATA,__bss 200 100 100 0
dev 33f2817 + fix __DATA,__thread_vars 200 0 0 0

On a physical iPhone. On an iPhone 12 (iOS 26.6.1), a larger reproduction against 1.3.14 ended with 0 keys in 75 of 1,000 replays, and in 0 of 200 with the fix.

Rates vary; the build check doesn't. The failure rate depends on thread timing. Where t_status lands in the binary does not.

Suggested fix

 // for Apple watch simulator
-#    if defined(TARGET_OS_SIMULATOR) && defined(TARGET_CPU_X86)
+#    if TARGET_OS_SIMULATOR && TARGET_CPU_X86
 static AESCryptStatus t_status;
 #    else
 thread_local AESCryptStatus t_status;
  • Other platforms are unchanged. On non-Apple platforms both macros are undefined and evaluate to 0, so they keep thread_local, as today.
  • The x86 watchOS simulator keeps the static, and with it the race. If current toolchains support thread_local there, the static branch could be removed entirely.
  • Why per-thread is enough. t_status only carries a value from getCurStatus to the memcpy inside a single write, and the race needs a second thread.

I'm happy to open a PR against dev.

Workaround until a release has the fix: apps can apply the one-line change themselves, for example from a CocoaPods post_install hook.

tstatus_race.mm
// Minimal reproduction: on Apple platforms, MMKV's `t_status` is one process-wide global instead of a
// thread_local, so encrypted writes to different MMKV instances on different threads corrupt each other.
//
// Core/MMKV_IO.cpp:
//     // for Apple watch simulator
//     #    if defined(TARGET_OS_SIMULATOR) && defined(TARGET_CPU_X86)
//     static AESCryptStatus t_status;
//     #    else
//     thread_local AESCryptStatus t_status;
//     #    endif
// <TargetConditionals.h> defines both macros, as 0 or 1, on every Apple platform, so the `static` branch is
// always taken.
//
// Each replay:
//   1. creates encrypted store A with three keys and closes it, so the next open loads A from the file;
//   2. reopens A, and starts three threads that keep writing values of 300-3300 bytes to three other
//      encrypted stores;
//   3. overwrites a 769-byte value in A and reads it back;
//   4. stops the writers, deletes that key, and reloads A from the file with clearMemoryCache.
// Expected: the read-back matches, and the reload finds the other two keys, unchanged.
//
//   tstatus_race [replays] [verbose]
// Exits 1 if any replay went wrong. `verbose` turns on MMKV's info log, which shows the failing reload:
//   InvalidProtocolBuffer truncatedMessage
//   loaded [store.a] with 0 key-values

#import <Foundation/Foundation.h>
#import "MMKV.h"
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <thread>
#include <unistd.h>
#include <vector>

static NSData *makeData(NSUInteger length, uint8_t seed) {
    NSMutableData *data = [NSMutableData dataWithLength:length];
    auto *bytes = (uint8_t *) data.mutableBytes;
    for (NSUInteger i = 0; i < length; i++) {
        bytes[i] = (uint8_t) (seed + i * 13);
    }
    return data;
}

static NSData *makeKey(uint8_t seed) {
    return makeData(16, seed);
}

int main(int argc, const char *argv[]) {
    @autoreleasepool {
        int replays = argc > 1 ? atoi(argv[1]) : 200;
        bool verbose = argc > 2 && strcmp(argv[2], "verbose") == 0;
        NSString *root = [NSTemporaryDirectory()
            stringByAppendingPathComponent:[NSString stringWithFormat:@"tstatus_race.%d", getpid()]];
        [MMKV initializeMMKV:root logLevel:verbose ? MMKVLogInfo : MMKVLogNone];

        NSData *small = makeData(32, 1);
        NSData *large = makeData(4000, 2);
        int garbled = 0, emptied = 0, damaged = 0;

        for (int replay = 0; replay < replays; replay++) {
            @autoreleasepool {
                NSString *dir = [root stringByAppendingPathComponent:[NSString stringWithFormat:@"%d", replay]];

                // 1. Store A with three keys, closed so that the next open loads it from the file.
                MMKV *a = [MMKV mmkvWithID:@"store.a" cryptKey:makeKey(100) rootPath:dir];
                [a setData:small forKey:@"small"];
                [a setData:large forKey:@"large"];
                [a setData:makeData(769, 3) forKey:@"value"];
                [a close];
                a = [MMKV mmkvWithID:@"store.a" cryptKey:makeKey(100) rootPath:dir];

                // 2. Three other encrypted stores, each written on its own thread.
                std::atomic<bool> stop{false};
                std::vector<std::thread> writers;
                NSMutableArray<MMKV *> *others = [NSMutableArray array];
                for (int i = 0; i < 3; i++) {
                    MMKV *other = [MMKV mmkvWithID:[NSString stringWithFormat:@"store.other.%d", i]
                                          cryptKey:makeKey((uint8_t) (i + 1))
                                          rootPath:dir];
                    [others addObject:other];
                    writers.emplace_back([&stop, other] {
                        unsigned n = 0;
                        while (!stop.load()) {
                            @autoreleasepool {
                                [other setData:makeData(300 + (n * 977) % 3000, (uint8_t) n)
                                        forKey:[NSString stringWithFormat:@"k%u", n % 40]];
                                n++;
                            }
                        }
                    });
                }
                usleep(1000); // the race needs the writers to be running already

                // 3. Overwrite a large value in A and read it back.
                NSData *value = makeData(769, 4);
                [a setData:value forKey:@"value"];
                if (![[a getDataForKey:@"value"] isEqualToData:value]) {
                    garbled++;
                }

                stop = true;
                for (auto &writer : writers) {
                    writer.join();
                }
                for (MMKV *other in others) {
                    [other close];
                }

                // 4. Delete that key, then reload A from the file.
                [a removeValueForKey:@"value"];
                [a clearMemoryCache];
                NSSet *keys = [NSSet setWithArray:a.allKeys];
                if (keys.count == 0) {
                    emptied++;
                } else if (![keys isEqualToSet:[NSSet setWithObjects:@"small", @"large", nil]] ||
                           ![[a getDataForKey:@"small"] isEqualToData:small] ||
                           ![[a getDataForKey:@"large"] isEqualToData:large]) {
                    damaged++;
                }
                [a close];
            }
        }
        [[NSFileManager defaultManager] removeItemAtPath:root error:nil];

        printf("%d replays: %d garbled read-backs, %d reloads with 0 keys, %d other damaged reloads\n", replays,
               garbled, emptied, damaged);
        bool ok = garbled == 0 && emptied == 0 && damaged == 0;
        printf("%s\n", ok ? "PASS" : "FAIL");
        return ok ? 0 : 1;
    }
}
run.sh
#!/bin/bash
# Reproduce the global t_status bug against a Tencent/MMKV checkout, then check that the one-line fix removes it.
#
#   run.sh <path to a Tencent/MMKV checkout> [replays]
#
# For the checkout as it is, and for a copy with the fix applied, this:
#   1. compiles Core/MMKV_IO.cpp for an iPhone (arm64-apple-ios) and prints the section `nm` puts t_status in:
#      __DATA,__bss is one global for the whole process, __DATA,__thread_vars is per thread;
#   2. builds tstatus_race.mm against Core and the iOS wrapper for macOS (arm64), which takes the same
#      preprocessor branch, and runs it.
# Needs the Xcode command-line tools. The checkout itself is never modified.
set -euo pipefail

[[ $# -ge 1 ]] || { echo "usage: $0 <MMKV checkout> [replays]" >&2; exit 2; }
SRC="$(cd "$1" && pwd)"
REPLAYS="${2:-200}"
HERE="$(cd "$(dirname "$0")" && pwd)"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/tstatus_race.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT

[[ -f "$SRC/Core/MMKV_IO.cpp" && -f "$SRC/iOS/MMKV/MMKV/libMMKV.mm" ]] || { echo "not an MMKV checkout: $SRC" >&2; exit 2; }
echo "MMKV: $SRC ($(git -C "$SRC" describe --tags --always 2>/dev/null || echo 'no git'))"
grep -n -B1 "AESCryptStatus t_status;" "$SRC/Core/MMKV_IO.cpp" | head -2 | sed 's/^/  /'

# Files the MMKVCore pod compiles with ARC. Every other Core file is compiled without it.
ARC_FILES=" InterProcessLock.cpp MemoryFile_OSX.cpp MemoryFile.cpp MMKVLog.cpp PBUtility.cpp ThreadLock.cpp "

build_and_run() {
    local name="$1" core="$2" out="$WORK/$1"
    mkdir -p "$out/obj" "$out/inc/MMKVCore"

    # 1. Where t_status ends up in an iPhone build.
    clang++ -c -x objective-c++ -std=gnu++20 -fno-objc-arc -target arm64-apple-ios15.0 \
        -isysroot "$(xcrun --sdk iphoneos --show-sdk-path)" -I"$core" -I"$core/crc32" -I"$core/aes" \
        -I"$core/aes/openssl" -Os -w "$core/MMKV_IO.cpp" -o "$out/MMKV_IO.ios.o"
    local section
    section="$(nm -m "$out/MMKV_IO.ios.o" | grep 't_status' | grep -o '(__DATA,[a-z_]*)' | sort -u | tr '\n' ' ')"

    # 2. The macOS build of the reproduction. The wrapper includes Core headers as <MMKVCore/...>.
    find "$core" \( -name '*.h' -o -name '*.hpp' \) -exec ln -sf {} "$out/inc/MMKVCore/" \;
    local flags=(-arch arm64 -mmacosx-version-min=13.0 -I"$core" -I"$out/inc" -I"$core/crc32" -I"$core/aes"
                 -I"$core/aes/openssl" -Os -DNDEBUG -w)
    local f b arc
    for f in $(find "$core" -name '*.cpp' -not -path '*/cbridge/*'); do
        b="$(basename "$f")"
        case "$b" in *_Android.cpp|*_Win32.cpp|*_Linux.cpp|*_OHOS.cpp) continue ;; esac
        if [[ "$ARC_FILES" == *" $b "* ]]; then arc=-fobjc-arc; else arc=-fno-objc-arc; fi
        clang++ -c -x objective-c++ -std=gnu++20 $arc "${flags[@]}" "$f" -o "$out/obj/${b%.cpp}.o" &
    done
    for f in $(find "$core" -name '*.S'); do
        clang -c "${flags[@]}" "$f" -o "$out/obj/$(basename "${f%.S}").S.o" &
    done
    wait
    clang++ -c -x objective-c++ -std=gnu++17 -fno-objc-arc "${flags[@]}" -I"$SRC/iOS/MMKV/MMKV" \
        "$SRC/iOS/MMKV/MMKV/libMMKV.mm" -o "$out/obj/libMMKV.o"
    clang++ -c -x objective-c++ -std=gnu++17 -fobjc-arc "${flags[@]}" -iquote "$SRC/iOS/MMKV/MMKV" \
        "$HERE/tstatus_race.mm" -o "$out/obj/tstatus_race.o"
    clang++ -arch arm64 -mmacosx-version-min=13.0 "$out"/obj/*.o -framework Foundation -lz -o "$out/tstatus_race"

    echo
    echo "== $name"
    echo "  t_status in an iPhone build: $section"
    printf "  "
    "$out/tstatus_race" "$REPLAYS" || true
}

build_and_run "as checked out" "$SRC/Core"

# The fix: test the macros' values instead of whether they're defined.
cp -R "$SRC/Core" "$WORK/Core-fixed"
sed -i '' 's/^#    if defined(TARGET_OS_SIMULATOR) && defined(TARGET_CPU_X86)$/#    if TARGET_OS_SIMULATOR \&\& TARGET_CPU_X86/' \
    "$WORK/Core-fixed/MMKV_IO.cpp"
grep -q '^#    if TARGET_OS_SIMULATOR && TARGET_CPU_X86$' "$WORK/Core-fixed/MMKV_IO.cpp" || { echo "fix did not apply" >&2; exit 2; }
build_and_run "with the fix" "$WORK/Core-fixed"

Activity

  1. github-actions commented on Oct 21, 2026

    @github-actions

    👋 Hello! Thanks for opening an issue.

    Note

    New features, bugfixes, updates and other improvements are all handled mostly by @lingol in his free time.
    To support @lingol , please consider 💖 sponsoring him on GitHub 💖.
    Sponsored issues will be prioritized.

    (If you are already a sponsor, thank you! We will get to this shortly.)

  2. added a commit that references this issue on Oct 22, 2026
    bf50b8a
  3. maction3664-cmyk commented on Oct 31, 2026

    @maction3664-cmyk

    چگونه تلفن اندروید هک شده خود توسط اپل را دوباره برنامه نویسی کنم تا از سرقت اطلاعاتم جلوگیری کنم.
    اندروید گوشی ۹ هست ما مانند باقی مردم پول خریدن تلفن همراه را نداریم

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