What changed, and why it matters
This commit adds stronger safety checks when Sparrow Wallet opens its encrypted wallet database files. It tries to detect and block tampered wallet files that contain unusual database objects such as linked tables, triggers, aliases, synonyms, generated columns, custom domains, or unexpected default values. These checks reduce the risk that a maliciously crafted wallet file could trick the application into running harmful database commands or accessing remote data when the wallet is loaded.
Treat this as a security hardening fix and include it in the next release. Users should upgrade to a version containing this commit and avoid opening untrusted or externally supplied Sparrow wallet files until patched. Developers should review whether additional H2 features (e.g., user-defined functions, Java functions, or external data sources) are also reachable through wallet files and consider further restrictions.
Security signals we found
Adds pre-migration validation of H2 MVStore metadata payload for dangerous DDL patterns
Blocks CREATE FORCE LINKED TABLE / TRIGGER / ALIAS in wallet file metadata
Rejects linked tables, synonyms, generated columns, custom domains, and unexpected column defaults
Moves validateSchema() before migrate() to prevent malicious schema objects from being executed during migration
Uses read-only MVStore opening and a custom DataType to inspect raw storage without full SQL parsing
Evidence from the diff
The patch hardens DbPersistence.loadWallet and related schema loading by adding two new validation steps before migration. validateStore() opens the underlying H2 MVStore in read-only mode, scans the table.0 meta map payload, and rejects files whose DDL contains CREATE FORCE LINKED TABLE, TRIGGER, or ALIAS. validateSchema() is now also invoked before migration for both the master and child schemas, and additional INFORMATION_SCHEMA queries reject non-base tables, linked tables, synonyms, generated columns, unexpected column defaults/ON UPDATE expressions, and custom domains. A PageCapture DataType helper is added to extract raw meta map bytes from the MVStore.
Changed components
src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.javaWallet loading / persistence layerH2 database migration and schema validationInspect captured patch +146 / −4
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
index 11f238a..9c7d78d 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
@@ -20,6 +20,13 @@ import org.apache.commons.lang3.concurrent.BasicThreadFactory;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.exception.FlywayValidateException;
+import org.h2.mvstore.Cursor;
+import org.h2.mvstore.MVMap;
+import org.h2.mvstore.MVStore;
+import org.h2.mvstore.MVStoreException;
+import org.h2.mvstore.WriteBuffer;
+import org.h2.mvstore.type.DataType;
+import org.h2.mvstore.type.LongDataType;
import org.h2.tools.ChangeFileEncryption;
import org.jdbi.v3.core.Jdbi;
import org.jdbi.v3.core.h2.H2DatabasePlugin;
@@ -56,6 +63,9 @@ public class DbPersistence implements Persistence {
private static final String H2_PASSWORD = "";
public static final String MIGRATION_RESOURCES_DIR = "com/sparrowwallet/sparrow/sql/";
private static final Pattern JDBC_URL_INJECTION_PATTERN = Pattern.compile(";\\w+=");
+ private static final String H2_META_TABLE_MAP = "table.0";
+ private static final Pattern INVALID_SCHEMA_DDL_PATTERN = Pattern.compile("CREATE\\s+FORCE\\s+(?:LINKED\\s+TABLE|TRIGGER|ALIAS)", Pattern.CASE_INSENSITIVE);
+ private static final Map<String, String> VALID_COLUMN_DEFAULTS = Map.of("UTXOMIXDATA.MIXESDONE", "0", "FLYWAY_SCHEMA_HISTORY.INSTALLED_ON", "CURRENT_TIMESTAMP");
private HikariDataSource dataSource;
private AsymmetricKeyDeriver keyDeriver;
@@ -82,6 +92,8 @@ public class DbPersistence implements Persistence {
public WalletAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException {
ECKey encryptionKey = getEncryptionKey(password, storage.getWalletFile(), alreadyDerivedKey);
+ validateStore(storage, encryptionKey);
+ validateSchema(storage, MASTER_SCHEMA, encryptionKey);
migrate(storage, MASTER_SCHEMA, encryptionKey);
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
@@ -112,6 +124,7 @@ public class DbPersistence implements Persistence {
List<String> childSchemas = schemas.stream().filter(schema -> schema.startsWith(WALLET_SCHEMA_PREFIX) && !schema.equals(MASTER_SCHEMA)).collect(Collectors.toList());
Map<WalletAndKey, Storage> childWallets = new TreeMap<>();
for(String schema : childSchemas) {
+ validateSchema(storage, schema, encryptionKey);
migrate(storage, schema, encryptionKey);
validateSchema(storage, schema, encryptionKey);
@@ -409,6 +422,48 @@ public class DbPersistence implements Persistence {
}
}
+ private void validateStore(Storage storage, ECKey encryptionKey) throws StorageException {
+ File walletFile = storage.getWalletFile();
+ if(!walletFile.exists()) {
+ return;
+ }
+
+ String filePassword = getFilePassword(encryptionKey);
+ MVStore.Builder builder = new MVStore.Builder().fileName(walletFile.getAbsolutePath()).readOnly();
+ if(filePassword != null) {
+ builder.encryptionKey(filePassword.toCharArray());
+ }
+
+ byte[] metaPayload = null;
+ try(MVStore store = builder.open()) {
+ if(store.getMapNames().contains(H2_META_TABLE_MAP)) {
+ ByteArrayOutputStream payload = new ByteArrayOutputStream();
+ PageCapture capture = new PageCapture(payload);
+ MVMap<Long, Object> metaTable = store.openMap(H2_META_TABLE_MAP, new MVMap.Builder<Long, Object>().keyType(LongDataType.INSTANCE).valueType(capture));
+ Cursor<Long, Object> cursor = metaTable.cursor(null);
+ while(cursor.hasNext()) {
+ cursor.next();
+ }
+
+ metaPayload = payload.toByteArray();
+ }
+ } catch(MVStoreException e) {
+ if(metaPayload == null) {
+ log.debug("Could not read wallet file store for validation, deferring to standard open", e);
+ return;
+ }
+ log.warn("Error closing wallet file store after validation", e);
+ }
+
+ if(metaPayload == null) {
+ return;
+ }
+
+ if(INVALID_SCHEMA_DDL_PATTERN.matcher(new String(metaPayload, StandardCharsets.ISO_8859_1)).find()) {
+ throw new StorageException("This is not a valid wallet file.\n\nWallet file contains unexpected database objects.");
+ }
+ }
+
private void migrate(Storage storage, String schema, ECKey encryptionKey) throws StorageException {
File migrationDir = getMigrationDir();
try {
@@ -445,11 +500,23 @@ public class DbPersistence implements Persistence {
throw new RuntimeException(new StorageException("Wallet file contains unexpected check constraints: " + String.join(", ", checkConstraints) + "."));
}
- List<Map<String, Object>> nonBaseTables = handle.createQuery("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) "
- + "AND TABLE_TYPE <> 'BASE TABLE' AND UPPER(TABLE_NAME) <> 'FLYWAY_SCHEMA_HISTORY'").bind("schema", schema).mapToMap().list();
+ List<String> nonBaseTables = handle.createQuery("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) "
+ + "AND TABLE_TYPE <> 'BASE TABLE'").bind("schema", schema)
+ .map((rs, ctx) -> rs.getString("TABLE_NAME") + " (" + rs.getString("TABLE_TYPE") + ")").list();
if(!nonBaseTables.isEmpty()) {
- String detail = nonBaseTables.stream().map(m -> m.get("TABLE_NAME") + " (" + m.get("TABLE_TYPE") + ")").collect(Collectors.joining(", "));
- throw new RuntimeException(new StorageException("Wallet file contains unexpected database object types: " + detail + "."));
+ throw new RuntimeException(new StorageException("Wallet file contains unexpected database object types: " + String.join(", ", nonBaseTables) + "."));
+ }
+
+ List<String> linkedTables = handle.createQuery("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND STORAGE_TYPE = 'TABLE LINK'")
+ .bind("schema", schema).mapTo(String.class).list();
+ if(!linkedTables.isEmpty()) {
+ throw new RuntimeException(new StorageException("Wallet file contains unexpected linked tables: " + String.join(", ", linkedTables) + "."));
+ }
+
+ List<String> synonyms = handle.createQuery("SELECT SYNONYM_NAME FROM INFORMATION_SCHEMA.SYNONYMS WHERE UPPER(SYNONYM_SCHEMA) = UPPER(:schema)")
+ .bind("schema", schema).mapTo(String.class).list();
+ if(!synonyms.isEmpty()) {
+ throw new RuntimeException(new StorageException("Wallet file contains unexpected synonyms: " + String.join(", ", synonyms) + "."));
}
List<String> generatedColumns = handle.createQuery("SELECT TABLE_NAME || '.' || COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND GENERATION_EXPRESSION IS NOT NULL")
@@ -458,6 +525,20 @@ public class DbPersistence implements Persistence {
throw new RuntimeException(new StorageException("Wallet file contains unexpected generated columns: " + String.join(", ", generatedColumns) + "."));
}
+ List<String[]> defaultColumns = handle.createQuery("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_DEFAULT, COLUMN_ON_UPDATE FROM INFORMATION_SCHEMA.COLUMNS "
+ + "WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND (COLUMN_DEFAULT IS NOT NULL OR COLUMN_ON_UPDATE IS NOT NULL)").bind("schema", schema)
+ .map((rs, ctx) -> new String[] {rs.getString("TABLE_NAME"), rs.getString("COLUMN_NAME"), rs.getString("COLUMN_DEFAULT"), rs.getString("COLUMN_ON_UPDATE")}).list();
+ List<String> unexpectedDefaults = new ArrayList<>();
+ for(String[] column : defaultColumns) {
+ String qualifiedName = column[0] + "." + column[1];
+ if(column[3] != null || !Objects.equals(VALID_COLUMN_DEFAULTS.get(qualifiedName.toUpperCase(Locale.ROOT)), column[2])) {
+ unexpectedDefaults.add(qualifiedName);
+ }
+ }
+ if(!unexpectedDefaults.isEmpty()) {
+ throw new RuntimeException(new StorageException("Wallet file contains unexpected column default or update expressions: " + String.join(", ", unexpectedDefaults) + "."));
+ }
+
List<String> domains = handle.createQuery("SELECT DOMAIN_NAME FROM INFORMATION_SCHEMA.DOMAINS WHERE DOMAIN_SCHEMA <> 'INFORMATION_SCHEMA'").mapTo(String.class).list();
if(!domains.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected database domains: " + String.join(", ", domains) + "."));
@@ -944,4 +1025,65 @@ public class DbPersistence implements Persistence {
"\nSilent payment addresses:" + silentPaymentAddresses;
}
}
+
+ private static class PageCapture implements DataType<Object> {
+ private final ByteArrayOutputStream payload;
+
+ public PageCapture(ByteArrayOutputStream payload) {
+ this.payload = payload;
+ }
+
+ private void capture(ByteBuffer buffer) {
+ int remaining = buffer.remaining();
+ if(remaining > 0) {
+ byte[] bytes = new byte[remaining];
+ buffer.get(bytes);
+ payload.write(bytes, 0, bytes.length);
+ }
+ }
+
+ @Override
+ public Object read(ByteBuffer buffer) {
+ capture(buffer);
+ return null;
+ }
+
+ @Override
+ public void read(ByteBuffer buffer, Object storage, int len) {
+ capture(buffer);
+ }
+
+ @Override
+ public void write(WriteBuffer buffer, Object obj) {
+ }
+
+ @Override
+ public void write(WriteBuffer buffer, Object storage, int len) {
+ }
+
+ @Override
+ public int compare(Object a, Object b) {
+ return 0;
+ }
+
+ @Override
+ public int binarySearch(Object key, Object storage, int size, int initialGuess) {
+ return 0;
+ }
+
+ @Override
+ public int getMemory(Object obj) {
+ return 0;
+ }
+
+ @Override
+ public boolean isMemoryEstimationAllowed() {
+ return false;
+ }
+
+ @Override
+ public Object[] createStorage(int size) {
+ return new Object[size];
+ }
+ }
}
Why this scored 70/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.