Fix app crash when attempting to delete wallet
What changed, and why it matters
This update fixes a crash that could happen when a user tries to delete or switch their cryptocurrency wallet in the Skylight Wallet app. The crash occurred because the app might free the wallet's underlying native memory while a background task (like checking the network connection or refreshing balances) was still using it. The patch adds a simple 'busy' flag and a wait step so the app finishes any in-flight task before closing the wallet. There is no direct evidence this crash can be exploited by an attacker to steal funds or data, but any use-after-free bug in native code is a security concern worth treating carefully.
Treat this as a stability and potential memory-safety fix. Users should update to the patched version. Developers should verify that _walletIdle is never completed more than once and that no other code paths free _w2Wallet outside _closeOpenWallet. A targeted security review of the native wallet binding and any other teardown paths is advisable, but no emergency response is warranted absent evidence of exploitation.
Security signals we found
Use-after-free / race condition in native wallet pointer teardown
Background timer tasks touching freed wallet pointer
Crash on wallet delete / wallet-type switch
Memory-safety fix in Dart/Flutter binding to native Monero wallet library
Evidence from the diff
The commit addresses a likely use-after-free / race condition in WalletModel. Previously, delete() and wallet-type switching could call closeWallet() on the underlying w2wallet pointer while periodic Timer tasks (connection check, refresh) were still running and touching that same pointer. The patch introduces _walletBusy, _disposing, and _walletIdle to serialize periodic tasks and make teardown wait for the in-flight task to finish before freeing the wallet. It also wraps the timer tasks in _runGuarded and awaits _retryConnectIfDue so it stays inside the guard. The crash is a reliability bug; whether it is exploitable for code execution is not established by the diff or supplied references.
Changed components
lib/models/wallet_model.dartWalletModel._closeOpenWalletWalletModel._runGuardedWalletModel._startTimersWalletModel.deleteWalletModel._walletManagerInspect captured patch +53 / −15
diff --git a/lib/models/wallet_model.dart b/lib/models/wallet_model.dart
index 30c9132..87caeab 100644
--- a/lib/models/wallet_model.dart
+++ b/lib/models/wallet_model.dart
@@ -171,6 +171,12 @@ class WalletModel with ChangeNotifier {
// fallback that leaks the view key and the user's IP to the server.
bool _torRequirementBroken = false;
+ // Serialize periodic tasks + teardown: the raw pointer must not be freed
+ // while an isolate read is in flight. Skip-if-busy, not a queue.
+ bool _walletBusy = false;
+ bool _disposing = false;
+ Completer<void>? _walletIdle;
+
final _sessionStartedAt = DateTime.now().secondsSinceEpoch;
var _hasAttemptedConnection = false;
var _isConnected = false;
@@ -235,11 +241,8 @@ class WalletModel with ChangeNotifier {
if (_w2WalletManager != null && _managerType == type) return _w2WalletManager!;
if (_w2WalletManager != null && _w2Wallet != null) {
- _w2WalletManager!.closeWallet(_w2Wallet!, false);
- _w2Wallet = null;
- _w2TxHistory = null;
- _daemonInitialized = false;
- _loadedType = null;
+ // Switching factories frees the old wallet; quiesce the timer tasks first.
+ await _closeOpenWallet();
}
final managerFactory = Monero().walletManagerFactory();
@@ -250,6 +253,29 @@ class WalletModel with ChangeNotifier {
return _w2WalletManager!;
}
+ /// Frees the open native wallet safely: blocks new periodic tasks, waits for
+ /// any in-flight one (they hold the raw pointer), then closes. Only called
+ /// from user flows, never a guarded task, so it can't self-deadlock.
+ Future<void> _closeOpenWallet() async {
+ _disposing = true;
+ try {
+ if (_walletBusy) {
+ _walletIdle = Completer<void>();
+ await _walletIdle!.future;
+ }
+ if (_w2Wallet != null) {
+ _w2Wallet!.pauseRefresh();
+ _w2WalletManager?.closeWallet(_w2Wallet!, false);
+ _w2Wallet = null;
+ _w2TxHistory = null;
+ _daemonInitialized = false;
+ _loadedType = null;
+ }
+ } finally {
+ _disposing = false;
+ }
+ }
+
/// Path of the wallet file for the current mode. LWS keeps the original
/// `mywallet` path; the node gets a `_node` suffix so toggling modes doesn't
/// force a rescan.
@@ -263,13 +289,29 @@ class WalletModel with ChangeNotifier {
return connectionType == 'node' ? '${basePath}_node' : basePath;
}
+ /// Runs a periodic task, at most one at a time; teardown waits on the
+ /// in-flight one before freeing the wallet.
+ Future<void> _runGuarded(Future<void> Function() task) async {
+ if (_walletBusy || _disposing || _w2Wallet == null) return;
+ _walletBusy = true;
+ try {
+ await task();
+ } catch (e) {
+ log(LogLevel.warn, 'Periodic wallet task failed: $e');
+ } finally {
+ _walletBusy = false;
+ _walletIdle?.complete();
+ _walletIdle = null;
+ }
+ }
+
void _startTimers() {
Timer.periodic(Duration(seconds: 1), (timer) {
- _runCheckConnectionTimerTask();
+ _runGuarded(_runCheckConnectionTimerTask);
});
Timer.periodic(Duration(seconds: 20), (timer) {
- _runRefreshTimerTask();
+ _runGuarded(_runRefreshTimerTask);
});
}
@@ -286,10 +328,8 @@ class WalletModel with ChangeNotifier {
notifyListeners();
}
- // Not awaited: a connect can take seconds over Tor and must not hold up the
- // sync poll below. Re-entry from the next tick is guarded by the in-flight
- // attempt inside connectToDaemon.
- unawaited(_retryConnectIfDue());
+ // Awaited so the connect stays inside the guard, not detached past teardown.
+ await _retryConnectIfDue();
// Node sync state flips off the native background thread; poll it here so
// "blocks remaining" advances between the slower refresh cycles.
@@ -1447,10 +1487,8 @@ class WalletModel with ChangeNotifier {
}
Future delete() async {
- (await _walletManager()).closeWallet(_w2Wallet!, false);
- _w2Wallet = null;
- _daemonInitialized = false;
- _loadedType = null;
+ await _closeOpenWallet();
+
_hasAttemptedConnection = false;
_isConnected = false;
_isSynced = false;
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.