Skip to content

Commit f3475e8

Browse files
Fix crash related to invalid RowSlot or RowSlotChunk pointers
Details: Fix two issues where a RowSlot* or RowSlotChunk* could point to invalid addresses following a CursorWindow resize which moves the entire allocation block, invalidating the previous address. These scenarios could occur when a query required expanding the default CursorWindow size and the allocation occured on an edge boundary. This adjustment also allows us to remove the CURSOR_SIZE_EXTRA which caused the test `testManyRowsLong` to identify the RowSlotChunk issue. The test `shouldNotCauseRowSlotAllocationCrash` verified the fix for the RowSlot error.
1 parent 007a3f7 commit f3475e8

File tree

4 files changed

+335
-6
lines changed

4 files changed

+335
-6
lines changed

sqlcipher/src/androidTest/java/net/zetetic/database/sqlcipher_cts/AndroidSQLCipherTestCase.java

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package net.zetetic.database.sqlcipher_cts;
22

33
import android.content.Context;
4+
import android.icu.text.NumberFormat;
45
import android.util.Log;
56

67
import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -19,6 +20,9 @@
1920
import java.io.InputStream;
2021
import java.io.OutputStream;
2122
import java.security.SecureRandom;
23+
import java.util.ArrayList;
24+
import java.util.Arrays;
25+
import java.util.List;
2226
import java.util.Locale;
2327

2428
@RunWith(AndroidJUnit4.class)
@@ -105,4 +109,205 @@ protected void log(String message, Object... args) {
105109
protected void loge(Exception ex, String message, Object... args) {
106110
Log.e(TAG, String.format(Locale.getDefault(), message, args), ex);
107111
}
112+
113+
public interface RowColumnValueBuilder {
114+
Object buildRowColumnValue(String[] columns, int row, int column);
115+
}
116+
117+
protected void buildDatabase(
118+
SQLiteDatabase database,
119+
int rows,
120+
int columns,
121+
RowColumnValueBuilder builder) {
122+
var columnNames = new ArrayList<String>();
123+
Log.i(TAG, String.format("Building database with %s rows, %d columns",
124+
NumberFormat.getInstance().format(rows), columns));
125+
var createTemplate = "CREATE TABLE t1(%s);";
126+
var insertTemplate = "INSERT INTO t1 VALUES(%s);";
127+
var createBuilder = new StringBuilder();
128+
var insertBuilder = new StringBuilder();
129+
for (int column = 0; column < columns; column++) {
130+
var columnName = generateColumnName(columnNames, column);
131+
createBuilder.append(String.format("%s BLOB%s",
132+
columnName,
133+
column != columns - 1 ? "," : ""));
134+
insertBuilder.append(String.format("?%s", column != columns - 1 ? "," : ""));
135+
}
136+
var create = String.format(createTemplate, createBuilder.toString());
137+
var insert = String.format(insertTemplate, insertBuilder.toString());
138+
database.execSQL("DROP TABLE IF EXISTS t1;");
139+
database.execSQL(create);
140+
database.execSQL("BEGIN;");
141+
var names = columnNames.toArray(new String[0]);
142+
for (int row = 0; row < rows; row++) {
143+
var insertArgs = new Object[columns];
144+
for (var column = 0; column < columns; column++) {
145+
insertArgs[column] = builder.buildRowColumnValue(names, row, column);
146+
}
147+
database.execSQL(insert, insertArgs);
148+
}
149+
database.execSQL("COMMIT;");
150+
Log.i(TAG, String.format("Database built with %d columns, %d rows", columns, rows));
151+
}
152+
153+
protected Integer[] generateRandomNumbers(
154+
int max,
155+
int times){
156+
var random = new SecureRandom();
157+
var numbers = new ArrayList<>();
158+
for(var index = 0; index < times; index++){
159+
boolean alreadyExists;
160+
do {
161+
var value = random.nextInt(max);
162+
alreadyExists = numbers.contains(value);
163+
if(!alreadyExists){
164+
numbers.add(value);
165+
}
166+
} while(alreadyExists);
167+
}
168+
return numbers.toArray(new Integer[0]);
169+
}
170+
171+
protected List<String> ReservedWords = Arrays.asList(
172+
"ABORT",
173+
"ACTION",
174+
"ADD",
175+
"AFTER",
176+
"ALL",
177+
"ALTER",
178+
"ANALYZE",
179+
"AND",
180+
"AS",
181+
"ASC",
182+
"ATTACH",
183+
"AUTOINCREMENT",
184+
"BEFORE",
185+
"BEGIN",
186+
"BETWEEN",
187+
"BY",
188+
"CASCADE",
189+
"CASE",
190+
"CAST",
191+
"CHECK",
192+
"COLLATE",
193+
"COLUMN",
194+
"COMMIT",
195+
"CONFLICT",
196+
"CONSTRAINT",
197+
"CREATE",
198+
"CROSS",
199+
"CURRENT_DATE",
200+
"CURRENT_TIME",
201+
"CURRENT_TIMESTAMP",
202+
"DATABASE",
203+
"DEFAULT",
204+
"DEFERRABLE",
205+
"DEFERRED",
206+
"DELETE",
207+
"DESC",
208+
"DETACH",
209+
"DISTINCT",
210+
"DROP",
211+
"EACH",
212+
"ELSE",
213+
"END",
214+
"ESCAPE",
215+
"EXCEPT",
216+
"EXCLUSIVE",
217+
"EXISTS",
218+
"EXPLAIN",
219+
"FAIL",
220+
"FOR",
221+
"FOREIGN",
222+
"FROM",
223+
"FULL",
224+
"GLOB",
225+
"GROUP",
226+
"HAVING",
227+
"IF",
228+
"IGNORE",
229+
"IMMEDIATE",
230+
"IN",
231+
"INDEX",
232+
"INDEXED",
233+
"INITIALLY",
234+
"INNER",
235+
"INSERT",
236+
"INSTEAD",
237+
"INTERSECT",
238+
"INTO",
239+
"IS",
240+
"ISNULL",
241+
"JOIN",
242+
"KEY",
243+
"LEFT",
244+
"LIKE",
245+
"LIMIT",
246+
"MATCH",
247+
"NATURAL",
248+
"NO",
249+
"NOT",
250+
"NOTNULL",
251+
"NULL",
252+
"OF",
253+
"OFFSET",
254+
"ON",
255+
"OR",
256+
"ORDER",
257+
"OUTER",
258+
"PLAN",
259+
"PRAGMA",
260+
"PRIMARY",
261+
"QUERY",
262+
"RAISE",
263+
"RECURSIVE",
264+
"REFERENCES",
265+
"REGEXP",
266+
"REINDEX",
267+
"RELEASE",
268+
"RENAME",
269+
"REPLACE",
270+
"RESTRICT",
271+
"RIGHT",
272+
"ROLLBACK",
273+
"ROW",
274+
"SAVEPOINT",
275+
"SELECT",
276+
"SET",
277+
"TABLE",
278+
"TEMP",
279+
"TEMPORARY",
280+
"THEN",
281+
"TO",
282+
"TRANSACTION",
283+
"TRIGGER",
284+
"UNION",
285+
"UNIQUE",
286+
"UPDATE",
287+
"USING",
288+
"VACUUM",
289+
"VALUES",
290+
"VIEW",
291+
"VIRTUAL",
292+
"WHEN",
293+
"WHERE",
294+
"WITH",
295+
"WITHOUT");
296+
297+
private String generateColumnName(
298+
List<String> columnNames,
299+
int columnIndex){
300+
var random = new SecureRandom();
301+
var labels = "abcdefghijklmnopqrstuvwxyz";
302+
var element = columnIndex < labels.length()
303+
? String.valueOf(labels.charAt(columnIndex))
304+
: String.valueOf(labels.charAt(random.nextInt(labels.length() - 1)));
305+
while(columnNames.contains(element) || ReservedWords.contains(element.toUpperCase())){
306+
element += labels.charAt(random.nextInt(labels.length() - 1));
307+
}
308+
columnNames.add(element);
309+
Log.i(TAG, String.format("Generated column name:%s for index:%d", element, columnIndex));
310+
return element;
311+
}
312+
108313
}

sqlcipher/src/androidTest/java/net/zetetic/database/sqlcipher_cts/SQLCipherDatabaseTest.java

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818
import org.junit.Test;
1919

2020
import java.io.File;
21+
import java.io.UnsupportedEncodingException;
2122
import java.nio.charset.StandardCharsets;
23+
import java.security.MessageDigest;
24+
import java.security.NoSuchAlgorithmException;
25+
import java.security.SecureRandom;
2226
import java.util.Arrays;
2327
import java.util.HashMap;
28+
import java.util.Random;
2429
import java.util.UUID;
2530

2631
public class SQLCipherDatabaseTest extends AndroidSQLCipherTestCase {
@@ -571,4 +576,118 @@ public void shouldSupportDeleteWithNullWhereArgs(){
571576
}
572577
assertThat(rowsFound, is(0L));
573578
}
579+
580+
581+
// This test recreated a scenario where the CursorWindow::allocRow
582+
// would alloc a RowSlot*, then the alloc call to allocate the
583+
// fieldDirOffset (based on fieldDirSize) would cause mData
584+
// to move (when there was just enough space in mData for a RowSlot*,
585+
// but not enough for the corresponding FieldSlot*), invalidating the
586+
// previous rowSlot address. This has been addressed by reassigning the
587+
// rowSlot pointer after alloc, prior to binding the fieldDirOffset
588+
// and should not fail now.
589+
/** @noinspection StatementWithEmptyBody*/
590+
@Test
591+
public void shouldNotCauseRowSlotAllocationCrash(){
592+
SQLiteCursor.resetCursorWindowSize();
593+
database.execSQL("create table t1(a INTEGER, b INTEGER, c TEXT, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z, "+
594+
"aa, bb, cc, dd, ee, ff, gg, hh, ii, jj, kk, ll, mm, nn, oo, pp, qq, rr, ss, tt, uu, vv);");
595+
database.beginTransaction();
596+
Random random = new Random();
597+
for(int i = 0; i < 20; i++) {
598+
int size = 1024*3 + 450;
599+
database.execSQL("insert into t1(a, b, c) values(?, ?, randomblob(?));", new Object[]{i, size, size});
600+
}
601+
database.setTransactionSuccessful();
602+
var value = false;
603+
var cursor = database.rawQuery("select * from t1;");
604+
if(cursor != null){
605+
while(cursor.moveToNext()){}
606+
value = true;
607+
}
608+
assertThat(value, is(true));
609+
}
610+
611+
@Test
612+
public void shouldBuildLargeDatabaseWithCustomCursorSizeAndNavigateValuesWithDigest() throws UnsupportedEncodingException, NoSuchAlgorithmException {
613+
int rowCount = 1000;
614+
int windowAllocationSize = 1024 * 1024 / 20;
615+
buildDatabase(database, rowCount, 30, (columns, row, column) -> {
616+
try {
617+
var digest = MessageDigest.getInstance("SHA-1");
618+
var columnName = columns[column];
619+
var value = String.format("%s%d", columnName, row);
620+
return digest.digest(value.getBytes(StandardCharsets.UTF_8));
621+
} catch (Exception e) {
622+
Log.e(TAG, e.toString());
623+
return null;
624+
}
625+
});
626+
627+
var randomRows = generateRandomNumbers(rowCount, rowCount);
628+
SQLiteCursor.setCursorWindowSize(windowAllocationSize);
629+
var cursor = database.rawQuery("SELECT * FROM t1;", null);
630+
var digest = MessageDigest.getInstance("SHA-1");
631+
int row = 0;
632+
Log.i(TAG, "Walking cursor forward");
633+
while(cursor.moveToNext()){
634+
var compare = compareDigestForAllColumns(cursor, digest, row);
635+
assertThat(compare, is(true));
636+
row++;
637+
}
638+
Log.i(TAG, "Walking cursor backward");
639+
while(cursor.moveToPrevious()){
640+
row--;
641+
var compare = compareDigestForAllColumns(cursor, digest, row);
642+
assertThat(compare, is(true));
643+
}
644+
Log.i(TAG, "Walking cursor randomly");
645+
for(int randomRow : randomRows){
646+
cursor.moveToPosition(randomRow);
647+
var compare = compareDigestForAllColumns(cursor, digest, randomRow);
648+
assertThat(compare, is(true));
649+
}
650+
}
651+
652+
@Test
653+
public void shouldCheckAllTypesFromCursor(){
654+
database.execSQL("drop table if exists t1;");
655+
database.execSQL("create table t1(a text, b integer, c text, d real, e blob)");
656+
byte[] data = new byte[10];
657+
new SecureRandom().nextBytes(data);
658+
database.execSQL("insert into t1(a, b, c, d, e) values(?, ?, ?, ?, ?)", new Object[]{"test1", 100, null, 3.25, data});
659+
Cursor results = database.rawQuery("select * from t1", new String[]{});
660+
results.moveToFirst();
661+
int type_a = results.getType(0);
662+
int type_b = results.getType(1);
663+
int type_c = results.getType(2);
664+
int type_d = results.getType(3);
665+
int type_e = results.getType(4);
666+
results.close();
667+
assertThat(type_a, is(Cursor.FIELD_TYPE_STRING));
668+
assertThat(type_b, is(Cursor.FIELD_TYPE_INTEGER));
669+
assertThat(type_c, is(Cursor.FIELD_TYPE_NULL));
670+
assertThat(type_d, is(Cursor.FIELD_TYPE_FLOAT));
671+
assertThat(type_e, is(Cursor.FIELD_TYPE_BLOB));
672+
}
673+
674+
private boolean compareDigestForAllColumns(
675+
Cursor cursor,
676+
MessageDigest digest,
677+
int row) throws UnsupportedEncodingException {
678+
var columnCount = cursor.getColumnCount();
679+
for(var column = 0; column < columnCount; column++){
680+
Log.i(TAG, String.format("Comparing SHA-1 digest for row:%d", row));
681+
var columnName = cursor.getColumnName(column);
682+
var actual = cursor.getBlob(column);
683+
var value = String.format("%s%d", columnName, row);
684+
var expected = digest.digest(value.getBytes(StandardCharsets.UTF_8));
685+
if(!Arrays.equals(actual, expected)){
686+
Log.e(TAG, String.format("SHA-1 digest mismatch for row:%d column:%d", row, column));
687+
return false;
688+
}
689+
}
690+
return true;
691+
}
692+
574693
}

0 commit comments

Comments
 (0)