Repository navigation
Description
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
-
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:1035and:1114); - writes the record;
- copies
t_statusinto the key's holder (memcpy(&kvHolder.cryptStatus, &t_status, …),:810and:841).
It holds only that instance's
m_lockwhile doing so. - saves the crypter's state in
-
A large write to another encrypted instance on another thread can overwrite
t_statusbetween 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.
- The holder then keeps the other instance's state, and
-
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. -
The next full load (
clearMemoryCache(), a memory warning, a relaunch) stops at that record withtruncatedMessage, 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:
- creates encrypted store A with three keys, and reopens it so it loads from the file;
- starts three threads that write values of 300–3300 B to three other encrypted stores;
- overwrites a 769 B value in A and reads it back;
- 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 supportthread_localthere, thestaticbranch could be removed entirely. - Why per-thread is enough.
t_statusonly carries a value fromgetCurStatusto thememcpyinside 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
github-actions commentedon Oct 21, 2026
👋 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.)
Merge pull request #1699 from zoeyhhh-prog/fix/t_status-thread-local
maction3664-cmyk commentedon Oct 31, 2026
چگونه تلفن اندروید هک شده خود توسط اپل را دوباره برنامه نویسی کنم تا از سرقت اطلاعاتم جلوگیری کنم.
اندروید گوشی ۹ هست ما مانند باقی مردم پول خریدن تلفن همراه را نداریم