Summary

Product Calibre
Vendor Calibre
Severity Medium
Affected Versions <= 7.15.0 (latest version as of writing)
Tested Versions 7.15.0
CVE Identifier CVE-2024-7009
CWE Classification(s) CWE-89 Improper Neutralization of Special Elements used in an SQL Command (‘SQL Injection’)
CAPEC Classification(s) CAPEC-66 SQL Injection

CVSS3.1 Scoring System

Base Score: 4.2 (Medium) Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N

Metric Value
Attack Vector (AV) Network
Attack Complexity (AC) High
Privileges Required (PR) Low
User Interaction (UI) None
Scope (S) Unchanged
Confidentiality (C) Low
Integrity (I) Low
Availability (A) None

Product Overview

Calibre is a cross-platform free and open-source suite of e-book software. Calibre supports organizing existing e-books into virtual libraries, displaying, editing, creating and converting e-books, as well as syncing e-books with a variety of e-readers. Editing books is supported for EPUB and AZW3 formats. Books in other formats like MOBI must first be converted to those formats, if they are to be edited. Calibre also has a large collection of community contributed plugins.

Calibre also offers a powerful content server feature. This allows users to share their Calibre libraries over the internet, making it easy to access your e-book collection from anywhere, at any time.

Vulnerability Summary

A user with privileges to perform full-text searches on any Calibre library on the content server can inject arbitrary SQL code into the search query. This can be used to extract sensitive information from any SQLite databases on the server’s filesystem, as well as the ability to perform limited file writes to the filesystem.

Vulnerability Details

In src/calibre/srv/fts.py, the /fts/snippets/{book_ids} endpoint is defined.

@endpoint('/fts/snippets/{book_ids}', postprocess=json)
def fts_snippets(ctx, rd, book_ids):
    '''
    Perform the specified full text query and return the results with snippets restricted to the specified book ids.

    Optional: ?query=<search query>&library_id=<default library>&use_stemming=<y or n>
    &query_id=arbitrary&snippet_size=32&highlight_start=\x1c&highlight_end=\x1e
    '''
    db = get_library_data(ctx, rd)[0]
    if not db.is_fts_enabled():
        raise HTTPPreconditionRequired('Full text searching is not enabled on this library')
    # ...
    from calibre.db import FTSQueryError
    sanitize_pat = re.compile(r'\s+')
    try:
        for x in db.fts_search(
            query, use_stemming=use_stemming, return_text=True,
            highlight_start=rd.query.get('highlight_start', '\x1c'), highlight_end=rd.query.get('highlight_end', '\x1e'),
            restrict_to_book_ids=bids, snippet_size=ssz,
        ):
            r = snippets[x['book_id']]
            q = sanitize_pat.sub('', x['text'])
            r.setdefault(q, {'formats': [], 'text': x['text'],})['formats'].append(x['format'])
    except FTSQueryError as e:
        raise HTTPUnprocessableEntity(str(e))
    ans['snippets'] = {bid: tuple(v.values()) for bid, v in snippets.items()}
    return ans

Tracing the call to db.fts_search, we eventually land in src/calibre/db/fts/connect.py:

def search(self,
    fts_engine_query, use_stemming, highlight_start, highlight_end, snippet_size, restrict_to_book_ids,
    return_text=True, process_each_result=None
):
    if restrict_to_book_ids is not None and not restrict_to_book_ids:
        return
    fts_engine_query = unicode_normalize(fts_engine_query)
    fts_table = 'books_fts' + ('_stemmed' if use_stemming else '')
    if return_text:
        text = 'books_text.searchable_text'
        if highlight_start is not None and highlight_end is not None:
            if snippet_size is not None:
                text = f'''snippet("{fts_table}", 0, '{highlight_start}', '{highlight_end}', '…', {max(1, min(snippet_size, 64))})''' # [1]
            else:
                text = f'''highlight("{fts_table}", 0, '{highlight_start}', '{highlight_end}')'''
        text = ', ' + text
    else:
        text = ''
    query = 'SELECT {0}.id, {0}.book, {0}.format {1} FROM {0} '.format('books_text', text)
    query += f' JOIN {fts_table} ON fts_db.books_text.id = {fts_table}.rowid'
    query += ' WHERE '
    data = []
    conn = self.get_connection()
    temp_table_name = ''
    if restrict_to_book_ids:
        temp_table_name = f'fts_restrict_search_{next(self.temp_table_counter)}'
        conn.execute(f'CREATE TABLE temp.{temp_table_name}(x INTEGER)')
        conn.executemany(f'INSERT INTO temp.{temp_table_name} VALUES (?)', tuple((x,) for x in restrict_to_book_ids))
        query += f' fts_db.books_text.book IN temp.{temp_table_name} AND '
    query += f' "{fts_table}" MATCH ?'
    data.append(fts_engine_query)
    query += f' ORDER BY {fts_table}.rank '
    if temp_table_name:
        query += f'; DROP TABLE temp.{temp_table_name}'
    try:
        for record in conn.execute(query, tuple(data)):
            result = {
                'id': record[0],
                'book_id': record[1],
                'format': record[2],
                'text': record[3] if return_text else '',
            }
            if process_each_result is not None:
                result = process_each_result(result)
            ret = yield result
            if ret is True:
                break
    except apsw.SQLError as e:
        raise FTSQueryError(fts_engine_query, query, e) from e

At no point are the highlight_start and highlight_end parameters sanitized. This allows an attacker to inject arbitrary SQL code into the highlight_start and highlight_end parameters at [1], which are then used in the query string. This can be seen by attempting to use a single quote in the highlight_end parameter:

Injecting into this database is of little worth to an attacker, as the database is not used for anything other than full-text search. However, the SQLite3 engine allows for data to be read from other databases on the filesystem, such as the server-users.sqlite file which contains the username and password information used for authentication to the content server. This can be done by using the ATTACH command to attach the database to the current connection, and then querying the table. For instance, consider a server setup where there is a privileged user testacc with write access and a non-privileged user nonprivacc with read-only access, including full-text search access to the “Calibre Library” library. If the non-privileged user knows the location of the server-users.sqlite file (on Windows, this is typically in %AppData%, requiring the attacker to know the user profile name), they can access the username and password data through the following URL:

http://CALIBRE_SERVER/fts/snippets/1?library_id=Calibre_Library&query=C&query_id=1&highlight_end=','',32) FROM books_text JOIN books_fts_stemmed ON fts_db.books_text.id = books_fts_stemmed.rowid WHERE "books_fts_stemmed" MATCH ?; attach 'C:\Users\Devesh\AppData\Roaming\calibre\server-users.sqlite' as suwu; select 1,1,name,pw from suwu.users;-- -

It is similarly possible to use the ATTACH DATABASE command on a non-existing filename to write data to the filesystem, albeit in a limited fashion. This could be used to, for instance, write a batch file to the user’s startup folder that will be executed on the next operating system login.

Suggested Mitigations

The highlight_start and highlight_end parameters should be sanitized before being used in the query string. This can be done by escaping any single quotes in the parameters. Parameterised queries, if possible, should be used for these values.

Credits

Devesh Logendran of STAR Labs SG Pte. Ltd. (@starlabs_sg)