org.zaproxy.zap.extension.ascanrulesAlpha.LDAPInjection.java Source code

Java tutorial

Introduction

Here is the source code for org.zaproxy.zap.extension.ascanrulesAlpha.LDAPInjection.java

Source

/*
 * Zed Attack Proxy (ZAP) and its related class files.
 *
 * ZAP is an HTTP/HTTPS proxy for assessing web application security.
 *
 * Copyright 2012 The ZAP Development Team
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.zaproxy.zap.extension.ascanrulesAlpha;

import java.net.UnknownHostException;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.httpclient.InvalidRedirectLocationException;
import org.apache.commons.httpclient.URIException;
import org.apache.commons.lang.RandomStringUtils;
import org.apache.log4j.Logger;
import org.parosproxy.paros.Constant;
import org.parosproxy.paros.core.scanner.AbstractAppParamPlugin;
import org.parosproxy.paros.core.scanner.Alert;
import org.parosproxy.paros.core.scanner.Category;
import org.parosproxy.paros.core.scanner.NameValuePair;
import org.parosproxy.paros.network.HttpMessage;
import org.zaproxy.zap.utils.HirshbergMatcher;

/**
 * The LDAPInjection plugin identifies LDAP injection vulnerabilities with LDAP based login pages,
 * and LDAP searches
 *
 * @author 70pointer
 */
public class LDAPInjection extends AbstractAppParamPlugin {

    /** plugin dependencies */
    private static final String[] dependency = {};
    /** for logging. */
    private static Logger log = Logger.getLogger(LDAPInjection.class);

    private static final String I18N_PREFIX = "ascanalpha.";
    /** determines if we should output Debug level logging */
    private boolean debugEnabled = log.isDebugEnabled();
    // TODO: append "&;" to this string, once they do not incorrectly cause the Sites tab to grow an
    // extra limb!
    private static final String errorAttack = "|!<>=~=>=<=*(),+-\"'\\/";
    // Note the ampersand at the end.. causes problems if earlier in the string..
    // and the semicolon after that..
    // ZAP: Added a static error bundle to speed up the implementation
    // LDAP errors for Injection testing
    // Use an inverse map to avoid multimap use
    // ----------------------------------------
    private static final Map<Pattern, String> LDAP_ERRORS = new HashMap<Pattern, String>();
    private int matchThreshold = 0;
    private int andRequests = 0;

    // characters used in the generation of random parameters
    private static final char[] RANDOM_PARAMETER_CHARS = "abcdefghijklmnopqrstuvwyxz0123456789".toCharArray();

    static {
        ResourceBundle resourceBundle = ResourceBundle.getBundle(
                LDAPInjection.class.getPackage().getName() + ".resources.LdapErrors", Constant.getLocale(),
                ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES));
        String ldapImplementationsFlat = resourceBundle
                .getString(I18N_PREFIX + "ldapinjection.knownimplementations");
        String[] ldapImplementations = ldapImplementationsFlat.split(":");
        String errorMessageFlat;
        String[] errorMessages;
        Pattern errorPattern;

        for (String ldapImplementation : ldapImplementations) { // for each LDAP implementation
            // for each known LDAP implementation
            errorMessageFlat = resourceBundle
                    .getString(I18N_PREFIX + "ldapinjection." + ldapImplementation + ".errormessages");
            errorMessages = errorMessageFlat.split(":");

            for (String errorMessage : errorMessages) { // for each error message for the given LDAP implemention
                // compile it into a pattern
                errorPattern = Pattern.compile(errorMessage);

                // add it to the errors list together with the ldap implementation
                LDAP_ERRORS.put(errorPattern, ldapImplementation);
            }
        }
    }

    // use Hirshberg to calculate longest common substring between two strings.
    private static final HirshbergMatcher hirshberg = new HirshbergMatcher();

    @Override
    public int getId() {
        return 40015;
    }

    @Override
    public String getName() {
        return Constant.messages.getString(I18N_PREFIX + "ldapinjection.name");
    }

    @Override
    public String[] getDependency() {
        return dependency;
    }

    @Override
    public String getDescription() {
        return Constant.messages.getString(I18N_PREFIX + "ldapinjection.desc");
    }

    @Override
    public int getCategory() {
        return Category.INJECTION;
    }

    @Override
    public String getSolution() {
        return Constant.messages.getString(I18N_PREFIX + "ldapinjection.soln");
    }

    @Override
    public String getReference() {
        return Constant.messages.getString(I18N_PREFIX + "ldapinjection.refs");
    }

    @Override
    public void init() {
        // DEBUG: turn on for debugging
        // log.setLevel(org.apache.log4j.Level.DEBUG);
        // this.debugEnabled = true;

        if (this.debugEnabled) {
            log.debug("Initialising");
        }
        // set up the match threshold percentages based on the alert threshold.
        // allow for the use of common libraries (etc) in both pass/fail cases by skewing towards
        // the upper end of the range.
        switch (this.getAlertThreshold()) {
        case HIGH:
            this.matchThreshold = 95;
            break;
        case MEDIUM:
            this.matchThreshold = 65;
            break;
        case LOW:
            this.matchThreshold = 40;
            break;
        default:
            break;
        }
        // how hard should we try to find an LDAP injection point (primarily by looking at how
        // deeply embedded it might be in paremtheses)
        // this is important in complex LDAP expressions which are deeply nested, i.e., where there
        // are various AND, OR, or NOT expressions
        // (&, |, ! respectively in LDAP)
        switch (this.getAttackStrength()) {
        case INSANE:
            this.andRequests = 16;
            break;
        case HIGH:
            this.andRequests = 8;
            break;
        case MEDIUM:
            this.andRequests = 4;
            break;
        case LOW:
            this.andRequests = 2;
            break;
        default:
        }
    }

    @Override
    public void scan(HttpMessage msg, NameValuePair originalParam) {
        /*
         * Scan everything _except_ URL path parameters.
         * URL Path parameters are problematic for the matching based scanners, because changing the URL path
         * "parameter" generates output that is wildly different from the unmodified URL path "parameter"
         */
        if (originalParam.getType() != NameValuePair.TYPE_URL_PATH) {
            super.scan(msg, originalParam);
        }
    }

    /**
     * scans the user specified parameter for LDAP injection vulnerabilities. Requires one extra
     * request for each parameter checked
     */
    @Override
    public void scan(HttpMessage originalmsg, String paramname, String paramvalue) {

        // for the purposes of our logic, we can handle a NULL parameter as an empty string. Saves
        // on NPEs.
        if (paramvalue == null)
            paramvalue = "";

        try {
            if (this.debugEnabled) {
                log.debug("Scanning URL [" + originalmsg.getRequestHeader().getMethod() + "] ["
                        + originalmsg.getRequestHeader().getURI() + "],  [" + paramname + "] with value ["
                        + paramvalue + "] for LDAP Injection");
            }

            // get the response for the "original" unmodified request
            // this fixes what seems to be a bug in the Zap core, where the request response is not
            // actually available at this point via "originalmsg"
            sendAndReceive(originalmsg);

            // 1: try error based LDAP injection, for one of the LDAP implementations that we know
            // about
            HttpMessage attackMsg = getNewMsg();
            // set a new parameter.. with a value designed to cause an LDAP error to occur
            this.setParameter(attackMsg, paramname, errorAttack);
            // send it, and see what happens :)
            sendAndReceive(attackMsg);

            // Now send another request for the same parameter, except use this request as a control
            // to see if a placebo attack has the same effect
            // the parameter will be the same length as the actual attack, but will contain purely
            // alphanumeric characters
            String placeboAttack = RandomStringUtils.random(errorAttack.length(),
                    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
            HttpMessage placeboAttackMsg = getNewMsg();
            this.setParameter(placeboAttackMsg, paramname, placeboAttack);
            sendAndReceive(placeboAttackMsg);

            if (checkResultsForLDAPAlert(attackMsg, placeboAttackMsg, paramname)) {
                return;
            }

            // bale out if we were asked nicely
            if (isStop()) {
                if (log.isDebugEnabled())
                    log.debug("Stopping the scan due to a user request");
                return;
            }

            // otherwise continue to check for non error-based LDAP injection, using boolean based
            // logic.
            // first check stability of the output for the original parameter.
            // if its not stable (enough), there is not much point in continuing
            HttpMessage repeatMsg = getNewMsg();
            sendAndReceive(repeatMsg);
            int repeatMatch = this.calcMatchPercentage(originalmsg.getResponseBody().toString(),
                    repeatMsg.getResponseBody().toString());
            log.debug("Got percentage for repeat: " + repeatMatch);
            if (repeatMatch < matchThreshold) {
                // the URL is not stable, based on the threshold level set. bale.
                log.debug("The output is not stable for the original URL. Re-playing it resulted in a match of "
                        + repeatMatch + "%, compared to a threshold of " + matchThreshold + "%");
                return;
            }

            // bale out if we were asked nicely
            if (isStop()) {
                if (log.isDebugEnabled())
                    log.debug("Stopping the scan due to a user request");
                return;
            }

            // now try a random parameter of the same length, to make sure that changing it results
            // in output substantially DIFFERENT to the original
            // get a random parameter value the same length as the original!
            String randomparameterAttack = RandomStringUtils.random(paramvalue.length(), RANDOM_PARAMETER_CHARS);
            if (this.debugEnabled)
                log.debug("The random parameter chosen was [" + randomparameterAttack + "]");

            HttpMessage randomParamMsg1 = getNewMsg();
            this.setParameter(randomParamMsg1, paramname, randomparameterAttack);
            sendAndReceive(randomParamMsg1);

            // bale out if we were asked nicely
            if (isStop()) {
                if (log.isDebugEnabled())
                    log.debug("Stopping the scan due to a user request");
                return;
            }

            HttpMessage randomParamMsg2 = getNewMsg();
            this.setParameter(randomParamMsg2, paramname, randomparameterAttack);
            sendAndReceive(randomParamMsg2);

            int randomVersusRandomMatch = this.calcMatchPercentage(randomParamMsg1.getResponseBody().toString(),
                    randomParamMsg2.getResponseBody().toString());
            log.debug("Got percentage match for a random parameter (against another identical request): "
                    + randomVersusRandomMatch);
            if (!(randomVersusRandomMatch > matchThreshold)) {
                // the output for the random parameter is .
                log.debug("The output for a random parameter is unstable. It resulted in a match of "
                        + randomVersusRandomMatch + "%, compared to a threshold of " + matchThreshold + "%");
                return;
            }

            // now check the random against the original, to make sure the output is different
            int randomVersusOriginalMatch = this.calcMatchPercentage(randomParamMsg1.getResponseBody().toString(),
                    originalmsg.getResponseBody().toString());
            log.debug("Got percentage match for a random parameter against the original parameter: "
                    + randomVersusOriginalMatch + "%, compared to a threshold of " + matchThreshold + "%");
            if (randomVersusOriginalMatch > matchThreshold) {
                // the output for the random parameter is .
                log.debug(
                        "The output for a random parameter is too similar to the output for the original parameter. It resulted in a match of "
                                + randomVersusOriginalMatch + "%, compared to a threshold of " + matchThreshold
                                + "%");
                return;
            }

            // bale out if we were asked nicely
            if (isStop()) {
                if (log.isDebugEnabled())
                    log.debug("Stopping the scan due to a user request");
                return;
            }

            // 1: The following logic is designed for login pages, which is the primary use case of
            // LDAP within web apps
            // try inserting a logically equivalent expression, to see if we get a match for the
            // original output
            // because of the syntax we need to build up, start with 1, not 0.
            for (int andAttackNumber = 1; andAttackNumber <= this.andRequests; andAttackNumber++) {
                // build up something like one of the following, depending on the iteration we're
                // in:
                // Note there the first case has 1 ')' and 1 '('
                // )(objectClass=*
                // ))((objectClass=*
                // )))(((objectClass=*
                StringBuffer temp = new StringBuffer().append(paramvalue);
                for (int i = 0; i < andAttackNumber; i++)
                    temp.append(')');
                for (int i = 0; i < andAttackNumber; i++)
                    temp.append('(');
                temp.append("objectClass=*");

                String appendTrueAttack = new String(temp);

                HttpMessage appendTrueMsg = getNewMsg();
                this.setParameter(appendTrueMsg, paramname, appendTrueAttack);
                sendAndReceive(appendTrueMsg);

                int appendTrueVersusOriginalMatch = this.calcMatchPercentage(
                        appendTrueMsg.getResponseBody().toString(), originalmsg.getResponseBody().toString());
                log.debug("Got percentage for append TRUE expression [" + appendTrueAttack + "] versus original: "
                        + appendTrueVersusOriginalMatch);
                if (appendTrueVersusOriginalMatch > this.matchThreshold) {

                    log.debug(
                            appendTrueAttack + " seems to produce sufficiently equivalent results to the original");
                    log.debug("We found an LDAP injection");

                    String extraInfo = Constant.messages.getString(
                            I18N_PREFIX + "ldapinjection.booleanbased.alert.extrainfo", paramname,
                            getBaseMsg().getRequestHeader().getMethod(),
                            getBaseMsg().getRequestHeader().getURI().getURI(), appendTrueAttack,
                            randomparameterAttack);

                    String vulnevidence = ""; // there is no String to search for in the original output.  all
                    // extra info is in extra info field. ahem!
                    String attack = Constant.messages.getString(
                            I18N_PREFIX + "ldapinjection.booleanbased.alert.attack", appendTrueAttack,
                            randomparameterAttack);
                    String vulnname = Constant.messages.getString(I18N_PREFIX + "ldapinjection.name");
                    String vulndesc = Constant.messages.getString(I18N_PREFIX + "ldapinjection.desc");
                    String vulnsoln = Constant.messages.getString(I18N_PREFIX + "ldapinjection.soln");

                    // bingo!
                    bingo(Alert.RISK_HIGH, Alert.CONFIDENCE_MEDIUM, vulnname, vulndesc,
                            getBaseMsg().getRequestHeader().getURI().getURI(), paramname, attack, extraInfo,
                            vulnsoln, vulnevidence, getBaseMsg());

                    logBoolenInjection(getBaseMsg(), paramname, appendTrueAttack, randomparameterAttack);

                    // all done for this parameter. return.
                    return;
                }
                // bale out if we were asked nicely
                if (isStop()) {
                    if (log.isDebugEnabled())
                        log.debug("Stopping the scan due to a user request");
                    return;
                }
            }

            // 2: try a separate case for where the LDAP injection point is *not* wrapped in
            // parentheses, like the following complete filter expression:
            // "sn=Joe Bloggs"
            // this is very likely to be found in LDAP-based search pages and the like.
            // so what we do here is to use wildcards inserted into the middle of the original
            // value, to create a (hopefully) logically equivalent filter expression
            // like the following:
            // "sn=Joe *loggs"
            // but only do this if the param length is > 1, to eliminate false positives to some
            // degree.
            int paramLength = paramvalue.length();
            if (paramLength > 1) {

                StringBuffer temp = new StringBuffer().append(paramvalue.substring(0, (paramLength / 2) - 1)); // if len == 10 => gets chars at 0-3
                // (ie, 4 chars)
                temp.append('*');
                temp.append(paramvalue.substring(paramLength / 2)); // if len == 10 => gets chars at 5- (ie, 5 chars)
                String hopefullyTrueAttack = new String(temp);

                log.debug("Trying for LDAP injection with the following '*' based attack: [" + temp
                        + "], compared to the original value [" + paramvalue + "]");

                HttpMessage hopefullyTrueMsg = getNewMsg();
                this.setParameter(hopefullyTrueMsg, paramname, hopefullyTrueAttack);
                sendAndReceive(hopefullyTrueMsg);

                int hopefullyTrueVersusOriginalMatch = this.calcMatchPercentage(
                        hopefullyTrueMsg.getResponseBody().toString(), originalmsg.getResponseBody().toString());
                log.debug("Got percentage for hopefully TRUE expression [" + hopefullyTrueAttack
                        + "] versus original: " + hopefullyTrueVersusOriginalMatch);
                if (hopefullyTrueVersusOriginalMatch > this.matchThreshold) {

                    log.debug(hopefullyTrueAttack
                            + " seems to produce sufficiently equivalent results to the original");
                    log.debug("We found an LDAP injection");

                    String extraInfo = Constant.messages.getString(
                            I18N_PREFIX + "ldapinjection.booleanbased.alert.extrainfo", paramname,
                            getBaseMsg().getRequestHeader().getMethod(),
                            getBaseMsg().getRequestHeader().getURI().getURI(), hopefullyTrueAttack,
                            randomparameterAttack);

                    String vulnevidence = ""; // there is no String to search for in the original output.  all
                    // extra info is in extra info field. ahem!
                    String attack = Constant.messages.getString(
                            I18N_PREFIX + "ldapinjection.booleanbased.alert.attack", hopefullyTrueAttack,
                            randomparameterAttack);
                    String vulnname = Constant.messages.getString(I18N_PREFIX + "ldapinjection.name");
                    String vulndesc = Constant.messages.getString(I18N_PREFIX + "ldapinjection.desc");
                    String vulnsoln = Constant.messages.getString(I18N_PREFIX + "ldapinjection.soln");

                    // bingo!
                    bingo(Alert.RISK_HIGH, Alert.CONFIDENCE_MEDIUM, vulnname, vulndesc,
                            getBaseMsg().getRequestHeader().getURI().getURI(), paramname, attack, extraInfo,
                            vulnsoln, vulnevidence, getBaseMsg());

                    logBoolenInjection(getBaseMsg(), paramname, hopefullyTrueAttack, randomparameterAttack);

                    // all done for this parameter. return.
                    return;
                }
            } else {
                log.debug("The parameter value [" + paramvalue
                        + " is too short to try inserting wildcards, to find logically equivalent expressions");
            }

            // TODO: add additional logic here to handle LDAP based searches (as opposed to LDAP
            // based login pages),
            // by using the "*" LDAP expression, to eek out more data from the LDAP directory into
            // the response.
            // but that's a task for another day.

        } catch (InvalidRedirectLocationException | UnknownHostException | URIException e) {
            if (log.isDebugEnabled()) {
                log.debug("Failed to send HTTP message, cause: " + e.getMessage());
            }
        } catch (Exception e) {
            // Do not try to internationalise this.. we need an error message in any event..
            // if it's in English, it's still better than not having it at all.
            log.error("An error occurred checking a url for LDAP Injection issues", e);
        }
    }

    private static void logBoolenInjection(HttpMessage msg, String parameterName, String attack,
            String falseAttack) {
        if (!log.isDebugEnabled()) {
            return;
        }

        String logMessage = MessageFormat.format(
                "A likely LDAP injection vulnerability has been found with [{0}] URL [{1}] "
                        + "on parameter [{2}], using [{3}] to simulate a logically "
                        + "equivalent condition, and using [{4}] to simulate a FALSE condition.",
                msg.getRequestHeader().getMethod(), msg.getRequestHeader().getURI().toString(), parameterName,
                attack, falseAttack);
        log.debug(logMessage);
    }

    /**
     * calculate the percentage length of similarity between 2 strings.
     *
     * @param a
     * @param b
     * @return
     */
    private int calcMatchPercentage(String a, String b) {
        // log.debug("About to get LCS for [" + a +"] and [ "+ b + "]");
        if (a == null && b == null)
            return 100;
        if (a == null || b == null)
            return 0;
        if (a.length() == 0 && b.length() == 0)
            return 100;
        if (a.length() == 0 || b.length() == 0)
            return 0;
        String lcs = hirshberg.getLCS(a, b);
        // log.debug("Got LCS: "+ lcs);
        // get the percentage match against the longer of the 2 strings
        return (int) ((((double) lcs.length()) / Math.max(a.length(), b.length())) * 100);
    }

    /**
     * returns does the Message Response match the pattern provided?
     *
     * @param msg the Message whose response we will examine
     * @param pattern the pattern which we will look for in the Message Body
     * @return true/false. D'uh! (It being a boolean, and all that)
     */
    protected boolean responseMatches(HttpMessage msg, Pattern pattern) {
        Matcher matcher = pattern.matcher(msg.getResponseBody().toString());
        return matcher.find();
    }

    /**
     * @param attackMessage the message used to send the LDAP attack
     * @param placeboMessage the message used to send the placebo attack
     * @param parameterName the name of the parameter which was attacked
     * @return
     * @throws Exception
     */
    private boolean checkResultsForLDAPAlert(HttpMessage attackMessage, HttpMessage placeboMessage,
            String parameterName) throws Exception {
        // compare the request response with each of the known error messages,
        // for each of the known LDAP implementations.
        // in order to minimise false positives, only consider a match
        // for the error message in the response if the string also
        // did NOT occur in the original (unmodified) response
        for (Pattern errorPattern : LDAP_ERRORS.keySet()) {

            // if the pattern was found in the new response,
            // but not in the original response (for the unmodified request)
            // then we have a match.. LDAP injection!
            if (responseMatches(attackMessage, errorPattern) && !responseMatches(getBaseMsg(), errorPattern)
                    && !responseMatches(placeboMessage, errorPattern)) {

                // the response of the attack matches one of the known LDAP errors, but the placebo
                // does not trigger the same effect.
                // so raise the error, and move on to the next parameter

                String extraInfo = Constant.messages.getString(I18N_PREFIX + "ldapinjection.alert.extrainfo",
                        parameterName, getBaseMsg().getRequestHeader().getMethod(),
                        getBaseMsg().getRequestHeader().getURI().getURI(), errorAttack,
                        LDAP_ERRORS.get(errorPattern), errorPattern);

                String attack = Constant.messages.getString(I18N_PREFIX + "ldapinjection.alert.attack",
                        parameterName, errorAttack);
                String vulnname = Constant.messages.getString(I18N_PREFIX + "ldapinjection.name");
                String vulndesc = Constant.messages.getString(I18N_PREFIX + "ldapinjection.desc");
                String vulnsoln = Constant.messages.getString(I18N_PREFIX + "ldapinjection.soln");

                // we know the LDAP implementation, so put it in the title, where it will be
                // obvious.
                bingo(Alert.RISK_HIGH, Alert.CONFIDENCE_MEDIUM, vulnname + " - " + LDAP_ERRORS.get(errorPattern),
                        vulndesc, getBaseMsg().getRequestHeader().getURI().getURI(), parameterName, attack,
                        extraInfo, vulnsoln, errorPattern.toString(), attackMessage); // use the attack message, rather than the original message.

                if (log.isDebugEnabled()) {
                    String logMessage = MessageFormat.format(
                            "A likely LDAP injection vulnerability has been found with [{0}] URL [{1}] "
                                    + "on parameter [{2}], using an attack with LDAP meta-characters [{3}], "
                                    + "yielding known [{4}] error message [{5}], which was not present in the original response.",
                            getBaseMsg().getRequestHeader().getMethod(),
                            getBaseMsg().getRequestHeader().getURI().getURI(), parameterName, errorAttack,
                            LDAP_ERRORS.get(errorPattern), errorPattern);
                    log.debug(logMessage);
                }

                return true; // threw an alert
            }
        } // for each error message for the given LDAP implemention

        return false; // did not throw an alert
    }

    /** @return */
    @Override
    public int getRisk() {
        return Alert.RISK_HIGH;
    }

    /** @return */
    @Override
    public int getCweId() {
        return 90;
    }

    /** @return */
    @Override
    public int getWascId() {
        return 29;
    }
}