What changed, and why it matters
This commit hardens how Sparrow Wallet opens its encrypted wallet files. It blocks several H2 database features that could be abused to run code or read files when a malicious wallet file is opened, and it prevents Java object deserialization. It also adds tests showing that dangerous constructs like linked tables, triggers, and file-read functions are now rejected before they can execute.
Treat this as a security hardening patch and include it in the next release. Users should upgrade to a version containing this commit and avoid opening untrusted wallet files on older releases. No immediate incident response is indicated by the commit alone.
Security signals we found
H2 database feature hardening
Java deserialization disabled
DDL blacklist expansion
Storage engine validation
Constant and JAVA_OBJECT column checks
Tightened error handling on wallet load
New defensive unit tests for malicious wallet files
Evidence from the diff
The patch is a follow-up hardening of the H2-backed wallet persistence layer. It expands the DDL blacklist to include OR REPLACE, AGGREGATE, ENGINE clauses, and dangerous H2 functions such as FILE_READ, FILE_WRITE, CSVWRITE, CSVREAD, RUNSCRIPT, and LINK_SCHEMA. It sets h2.allowedClasses to a non-existent package and installs a NoDeserializationSerializer to disable Java object serialization/deserialization. Validation now checks for non-MVTable storage engines, user-defined constants, and JAVA_OBJECT columns. Error handling is tightened so that validation failures throw StorageException and the data source is closed on failure. A new test suite demonstrates rejection of linked tables, triggers, generated columns, function-based defaults, constants, OTHER columns, check constraints with FILE_READ, and domains.
Changed components
src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.javasrc/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.javaInspect captured patch +166 / −21
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 6b362fc..3c842b8 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,7 @@ 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.api.JavaObjectSerializer;
import org.h2.mvstore.Cursor;
import org.h2.mvstore.MVMap;
import org.h2.mvstore.MVStore;
@@ -28,6 +29,7 @@ import org.h2.mvstore.WriteBuffer;
import org.h2.mvstore.type.DataType;
import org.h2.mvstore.type.LongDataType;
import org.h2.tools.ChangeFileEncryption;
+import org.h2.util.JdbcUtils;
import org.jdbi.v3.core.Jdbi;
import org.jdbi.v3.core.h2.H2DatabasePlugin;
import org.jdbi.v3.sqlobject.SqlObjectPlugin;
@@ -64,9 +66,18 @@ public class DbPersistence implements Persistence {
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("LINKED\\s+TABLE|CREATE\\s+(?:FORCE\\s+)?(?:TRIGGER|ALIAS)", Pattern.CASE_INSENSITIVE);
+ private static final Pattern INVALID_SCHEMA_DDL_PATTERN = Pattern.compile("LINKED\\s+TABLE|CREATE\\s+(?:FORCE\\s+|OR\\s+REPLACE\\s+)*(?:TRIGGER|ALIAS|AGGREGATE)"
+ + "|\\bENGINE\\s+[\"'\\w]|\\b(?:FILE_READ|FILE_WRITE|CSVWRITE|CSVREAD|RUNSCRIPT|LINK_SCHEMA)\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern WALLET_SCHEMA_IDENTIFIER_PATTERN = Pattern.compile("\"wallet_[^\"\\x00-\\x1f]*\"");
private static final Map<String, String> VALID_COLUMN_DEFAULTS = Map.of("UTXOMIXDATA.MIXESDONE", "0", "FLYWAY_SCHEMA_HISTORY.INSTALLED_ON", "CURRENT_TIMESTAMP");
+ private static final String H2_BASE_TABLE_CLASS = "org.h2.mvstore.db.MVTable";
+ private static final String H2_ALLOWED_CLASSES_PROPERTY = "h2.allowedClasses";
+ private static final String H2_NO_ALLOWED_CLASSES = "com.sparrowwallet.sparrow.NONE";
+
+ static {
+ System.setProperty(H2_ALLOWED_CLASSES_PROPERTY, H2_NO_ALLOWED_CLASSES);
+ JdbcUtils.serializer = new NoDeserializationSerializer();
+ }
private HikariDataSource dataSource;
private AsymmetricKeyDeriver keyDeriver;
@@ -93,27 +104,32 @@ 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);
+ try {
+ validateStore(storage, encryptionKey);
+ validateSchema(storage, MASTER_SCHEMA, encryptionKey);
+ migrate(storage, MASTER_SCHEMA, encryptionKey);
+ validateSchema(storage, MASTER_SCHEMA, encryptionKey);
- Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
- masterWallet = jdbi.withHandle(handle -> {
- WalletDao walletDao = handle.attach(WalletDao.class);
- return walletDao.getMainWallet(MASTER_SCHEMA, getWalletName(storage.getWalletFile(), null));
- });
+ Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
+ masterWallet = jdbi.withHandle(handle -> {
+ WalletDao walletDao = handle.attach(WalletDao.class);
+ return walletDao.getMainWallet(MASTER_SCHEMA, getWalletName(storage.getWalletFile(), null));
+ });
- if(masterWallet == null) {
- throw new StorageException("The wallet file was corrupted. Check the backups folder for previous copies.");
- }
+ if(masterWallet == null) {
+ throw new StorageException("The wallet file was corrupted. Check the backups folder for previous copies.");
+ }
- Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, masterWallet, encryptionKey);
- masterWallet.setChildWallets(childWallets.keySet().stream().map(WalletAndKey::getWallet).collect(Collectors.toList()));
+ Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, masterWallet, encryptionKey);
+ masterWallet.setChildWallets(childWallets.keySet().stream().map(WalletAndKey::getWallet).collect(Collectors.toList()));
- createUpdateExecutor(masterWallet);
+ createUpdateExecutor(masterWallet);
- return new WalletAndKey(masterWallet, encryptionKey, keyDeriver, childWallets);
+ return new WalletAndKey(masterWallet, encryptionKey, keyDeriver, childWallets);
+ } catch(StorageException | RuntimeException e) {
+ closeDataSource();
+ throw e;
+ }
}
private Map<WalletAndKey, Storage> loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws StorageException {
@@ -435,8 +451,10 @@ public class DbPersistence implements Persistence {
builder.encryptionKey(filePassword.toCharArray());
}
+ boolean storeOpened = false;
byte[] metaPayload = null;
try(MVStore store = builder.open()) {
+ storeOpened = true;
if(store.getMapNames().contains(H2_META_TABLE_MAP)) {
ByteArrayOutputStream payload = new ByteArrayOutputStream();
PageCapture capture = new PageCapture(payload);
@@ -449,15 +467,18 @@ public class DbPersistence implements Persistence {
metaPayload = payload.toByteArray();
}
} catch(MVStoreException e) {
- if(metaPayload == null) {
- log.debug("Could not read wallet file store for validation, deferring to standard open", e);
+ if(metaPayload != null) {
+ log.warn("Error closing wallet file store after validation", e);
+ } else if(!storeOpened) {
+ log.debug("Could not open wallet file store for validation, deferring to standard open", e);
return;
+ } else {
+ throw new StorageException("This is not a valid wallet file.\n\nWallet file could not be validated.");
}
- log.warn("Error closing wallet file store after validation", e);
}
if(metaPayload == null) {
- return;
+ throw new StorageException("This is not a valid wallet file.\n\nWallet file could not be validated.");
}
String rawDdl = new String(metaPayload, StandardCharsets.ISO_8859_1);
@@ -516,6 +537,12 @@ public class DbPersistence implements Persistence {
throw new RuntimeException(new StorageException("Wallet file contains unexpected linked tables: " + String.join(", ", linkedTables) + "."));
}
+ List<String> engineTables = handle.createQuery("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) "
+ + "AND TABLE_TYPE = 'BASE TABLE' AND (TABLE_CLASS IS NULL OR TABLE_CLASS <> :tableClass)").bind("schema", schema).bind("tableClass", H2_BASE_TABLE_CLASS).mapTo(String.class).list();
+ if(!engineTables.isEmpty()) {
+ throw new RuntimeException(new StorageException("Wallet file contains unexpected table storage engines: " + String.join(", ", engineTables) + "."));
+ }
+
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()) {
@@ -546,6 +573,18 @@ public class DbPersistence implements Persistence {
if(!domains.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected database domains: " + String.join(", ", domains) + "."));
}
+
+ List<String> constants = handle.createQuery("SELECT CONSTANT_NAME FROM INFORMATION_SCHEMA.CONSTANTS WHERE CONSTANT_SCHEMA <> 'INFORMATION_SCHEMA'").mapTo(String.class).list();
+ if(!constants.isEmpty()) {
+ throw new RuntimeException(new StorageException("Wallet file contains unexpected database constants: " + String.join(", ", constants) + "."));
+ }
+
+ List<String> serializedColumns = handle.createQuery("SELECT TABLE_NAME || '.' || COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND DATA_TYPE = 'JAVA_OBJECT' "
+ + "UNION SELECT OBJECT_NAME FROM INFORMATION_SCHEMA.ELEMENT_TYPES WHERE UPPER(OBJECT_SCHEMA) = UPPER(:schema) AND OBJECT_TYPE = 'TABLE' AND DATA_TYPE = 'JAVA_OBJECT'")
+ .bind("schema", schema).mapTo(String.class).list();
+ if(!serializedColumns.isEmpty()) {
+ throw new RuntimeException(new StorageException("Wallet file contains unexpected serialized object columns: " + String.join(", ", serializedColumns) + "."));
+ }
});
} catch(RuntimeException e) {
if(e.getCause() instanceof StorageException) {
@@ -1029,6 +1068,18 @@ public class DbPersistence implements Persistence {
}
}
+ private static class NoDeserializationSerializer implements JavaObjectSerializer {
+ @Override
+ public byte[] serialize(Object obj) {
+ throw new UnsupportedOperationException("Serialization of Java objects is not supported in wallet files.");
+ }
+
+ @Override
+ public Object deserialize(byte[] bytes) {
+ throw new UnsupportedOperationException("Deserialization of Java objects is not supported in wallet files.");
+ }
+ }
+
private static class PageCapture implements DataType<Object> {
private final ByteArrayOutputStream payload;
diff --git a/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java b/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java
new file mode 100644
index 0000000..fcf4647
--- /dev/null
+++ b/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java
@@ -0,0 +1,94 @@
+package com.sparrowwallet.sparrow.io;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.Statement;
+import java.util.Comparator;
+
+public class DbPersistenceTest {
+ private Path tempDir;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ tempDir = Files.createTempDirectory("sprw-persistence");
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ if(tempDir != null) {
+ Files.walk(tempDir).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+ }
+ }
+
+ private File buildWalletFile(String... schemaObjects) throws Exception {
+ String base = tempDir.resolve("wallet").toString();
+ try(Connection connection = DriverManager.getConnection("jdbc:h2:" + base + ";DATABASE_TO_UPPER=false;DB_CLOSE_ON_EXIT=FALSE", "sa", ""); Statement statement = connection.createStatement()) {
+ statement.execute("create schema wallet_master");
+ statement.execute("create table wallet_master.wallet(id identity not null, name varchar(255) not null)");
+ for(String ddl : schemaObjects) {
+ statement.execute(ddl);
+ }
+ }
+ return new File(base + ".mv.db");
+ }
+
+ private void assertRejected(File walletFile) {
+ Storage storage = new Storage(PersistenceType.DB, walletFile);
+ Assertions.assertThrows(StorageException.class, storage::loadUnencryptedWallet);
+ }
+
+ @Test
+ public void linkedTableRejectedWithoutExecuting() throws Exception {
+ File marker = tempDir.resolve("output.csv").toFile();
+ String target = "jdbc:h2:" + tempDir.resolve("external") + ";INIT=CREATE TABLE IF NOT EXISTS PUB(ID INT)\\;CALL CSVWRITE('" + marker.getAbsolutePath() + "','SELECT 1')";
+ File walletFile = buildWalletFile("create force linked table wallet_master.remote('','" + target.replace("'", "''") + "','sa','','PUB')");
+ marker.delete();
+
+ assertRejected(walletFile);
+ Assertions.assertFalse(marker.exists(), "linked table connected during load");
+ }
+
+ @Test
+ public void triggerRejected() throws Exception {
+ assertRejected(buildWalletFile("create table wallet_master.t(id int)",
+ "create force trigger wallet_master.tg before insert on wallet_master.t for each row call \"com.sparrowwallet.Missing\""));
+ }
+
+ @Test
+ public void generatedColumnRejected() throws Exception {
+ assertRejected(buildWalletFile("create table wallet_master.g(id int, computed int generated always as (id + 1))"));
+ }
+
+ @Test
+ public void columnDefaultFunctionRejected() throws Exception {
+ assertRejected(buildWalletFile("create table wallet_master.d(id int, x int default LENGTH(FILE_READ('/etc/hostname')))"));
+ }
+
+ @Test
+ public void constantRejected() throws Exception {
+ assertRejected(buildWalletFile("create constant wallet_master.k value 1"));
+ }
+
+ @Test
+ public void javaObjectColumnRejected() throws Exception {
+ assertRejected(buildWalletFile("create table wallet_master.j(id int, obj OTHER)"));
+ }
+
+ @Test
+ public void checkConstraintRejected() throws Exception {
+ assertRejected(buildWalletFile("create table wallet_master.c(id int check (LENGTH(FILE_READ('/etc/hostname')) > 0))"));
+ }
+
+ @Test
+ public void domainRejected() throws Exception {
+ assertRejected(buildWalletFile("create domain wallet_master.dm as int default 0"));
+ }
+}
Why this scored 79/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.