Blog Post

Tech Talk
1 MIN READ

Finding Cisco IOS XE CVE-2023-20198 With ConfigSources

LMPatrickA's avatar
LMPatrickA
Icon for Employee rankEmployee
2 years ago

On October 16, 2023, Cisco published a vulnerability that affects IOS XE machines running the built-in web server: https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-iosxe-webui-privesc-j22SaA4z

This is tracked as https://nvd.nist.gov/vuln/detail/CVE-2023-20198

By adding a simple Config Check to an existing Cisco IOS ConfigSource, LogicMonitor can help people quickly identify which resources have the web server enabled.  Here is an example:

  1. Name: Cisco-CSCwh87343-Check
  2. Check type: "Use Groovy Script"
  3. Groovy script: 
    /*
    The built-in string variable 'config' contains the entire contents of the configuration file.
    The following example will trigger an alert when the configuration file contains the string "blue".
    if (config.contains("blue")) {
    return 1;
    } else {
    return 0;
    }
    */

    if (config.contains("ip http")) {
    return 1;
    } else {
    return 0;
    }
  4. Then trigger this type of alert: Warning
  5. Description: "Search for presence of Cisco
    CSCwh87343 vulnerability"

Caveats: 

-This will apply to all devices where the ConfigSource is used, even though all devices may not be affected by the vulnerability

-This assumes usage of ConfigSources and specifically the Cisco_iOS ConfigSource

Thanks to Todd Ritter for finding this CVE and Creating the ConfigSource

Published 2 years ago
Version 1.0
  • Anonymous's avatar
    Anonymous

    I built a standalone property source for this. Checking if “ip http” server is in the config is not enough (what if “no ip http server” is present?). Also, it’s a good idea to highlight the ones that have public ip addresses. Also, there’s a mitigation for it that you can check for.

    Here’s my PS code. I apply it to “system.sysinfo =~ "IOSXE" && ssh.user && ssh.pass && isCisco()”:

    import com.santaba.agent.groovyapi.expect.Expect
    import com.santaba.common.groovyapi.expect.expectj.ExpectJ
    import java.util.regex.Pattern
    import com.jcraft.jsch.JSch

    deviceType = hostProps.get("system.categories")

    if (deviceType.contains("Cisco")){
    def device = null
    try {
    device = new CiscoIOSDevice(hostProps)
    } catch (all) {
    println("http.server.error=Fail to initiate device object: ${all.getMessage()}")
    throw new RuntimeException("Failure to initiate the device.\nMessage: ${all.getMessage()}")
    return 1
    }
    try {
    runningConfig = device.LM_getCollectionOutput("running-config | include ip http se")
    }
    catch (all) {
    println("http.server.error=Fail to fetch running config: ${all.getMessage()}")
    throw new RuntimeException("Failure to fetch the running config.\nMessage: ${all.getMessage()}")
    return 2
    }
    ipaddresses = hostProps.get("system.ips", "")
    device.Cisco_LogoutDevice()
    runningConfig.eachLine{
    if (it.contains("ip http server")){
    if (it.tokenize(" ")[0] == "no"){println("http.server=false")}
    else {println("http.server=true")}
    }
    if (it.contains("ip http secure-server")){
    if (it.tokenize(" ")[0] == "no"){println("http.server.secure=false")}
    else {println("http.server.secure=true")}
    }
    if (it.contains("ip http active-session-modules none")){
    if (it.tokenize(" ")[0] == "no"){println("http.server.active_session_modules=false")}
    else {println("http.server.active_session_modules=true")}
    }
    if (it.contains("ip http secure-active-session-modules none")){
    if (it.tokenize(" ")[0] == "no"){println("http.server.secure_active_session_modules=false")}
    else {println("http.server.secure_active_session_modules=true")}
    }
    }
    privateips = []
    publicips = []
    ipaddresses.tokenize(",").each{
    try{
    publicips.add((it =~ /\b(?!(10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2[0-9]|3[0-1])(\.\d{1,3}){2}))(\d{1,3}(\.\d{1,3}){3})\b/)[0][0])
    } catch (x){
    try {
    privateips.add((it =~ /\b(([0-9]{1,3}\.){3}[0-9]{1,3}\b)/)[0][0])
    } catch (y){}
    }
    }
    println("http.server.private.ips=${privateips.join(',')}")
    println("http.server.public.ips=${publicips.join(',')}")
    return 0
    } else {
    println("http.server.error=Not a supported device type")
    }

    /**
    * Cisco IOS command execution
    */
    class CiscoIOSDevice {
    private hostProps = null
    private host = null
    private user = [:]
    private pass = [:]
    private creds = [:]
    private enable_pass = []// If the user has specified an enable password, this will get set further down.
    private enable_level = null
    private priv_exec_mode = false // Notes what EXEC mode we are in. Initially we assume USER EXEC.
    private escalated_privs = false // If we need to escalate our prives from $ to #, we should know.
    private parser_view = false

    private enum Cisco_ENUMUserPrivilegeState {
    USER, PRIV
    }
    private user_privilege_mode = Cisco_ENUMUserPrivilegeState.USER

    private Expect cli

    private prompt = ""
    private mode = ">"
    private full_prompt = ""

    // The following variables are set to show what type of IOS device we have, based off the sysinfo property.
    private sysinfo = null
    private isCisco_ASA = false
    private isCisco_WAAS = false
    private isCisco_c6800 = false
    private isCisco_Cat_3750 = false
    private isCisco_Cat = false
    private isCisco_PIX = false
    private isCisco_Standard = false

    private enum CiscoModel {
    STANDARD, ASA, WAAS, CATALYST, CATALYST_3750, PIX, C6800
    }
    private CiscoModel cisco_model

    private raw_show_output = null
    private show_entries = null

    private use_ssh = true // We'll assume ssh unless otherwise specified.
    private telnet_port = "23"

    private connected = false // Describes the current device ssh connectivity state.
    private script_timeout = 60 // seconds
    private max_conn_attempts = 3 // Attempt the ssh connection this many times after failure.
    private conn_attempt_pause = 5 // Seconds to pause between ssh connection attempts.

    private error_message = null
    // Get's populated with the last critical error. Used by the init method when throwing an exception


    CiscoIOSDevice(hostProps) {
    if (!init(hostProps)) {
    if (error_message) {
    throw new RuntimeException("Initialization process failed! Reason: \"${error_message}\".\n\n")
    }
    else {
    throw new RuntimeException("Initialization process failed!\n\n")
    }
    }
    }

    /**
    * Called by the Class Constructor, this is the main 'loop' that calls the necessary methods to make a successful connection
    * to the device. It is also responsible for multi-attempt logic.
    * @param hostProps The hostProps object retrieved from the CollectorDB, and used to extract necessary device properties
    * @return TRUE if successful. FALSE otherwise.
    */
    private boolean init(hostProps) {
    this.hostProps = hostProps
    get_lm_props()
    Cisco_DetermineIOSDeviceType()

    def conn_attempt_num = 1
    connected = false

    while (!connected && conn_attempt_num < (max_conn_attempts + 1)) {
    // Attempt to connect to the device. If we fail, and the failure occurs after connectivity is established,
    // we want to disconnect from the device. This is typically means we received a "Permission denied" error.
    if (!Cisco_ConnectToDevice()) {
    if (connected) {
    Cisco_LogoutDevice()
    }
    error_message = "Could not establish ssh session with host"
    }

    // Check if we are within a parser view.
    if (connected) {
    Cisco_ShowParserView()
    }

    //if ( connected && !escalate_device_privs() ) {
    if (connected && !Cisco_EscalateDevicePrivileges()) {
    if (connected) {
    Cisco_LogoutDevice()
    }
    error_message = "Could not properly escalate privileges on the device. Ensure that 'ssh.enable.pass' property has been set for the device if necessary"
    }

    if (connected && !Cisco_SetTerminal()) {
    if (connected) {
    Cisco_LogoutDevice()
    }
    }

    if (!connected) {
    conn_attempt_num++
    sleep(conn_attempt_pause * 1000)
    }
    }

    return connected && escalated_privs
    }

    private get_lm_props() {
    // Grab all the hostProp entries
    def propKeys = this.hostProps.keySet()

    // Iterate over the hostProp entries, determining what values we have set
    propKeys.each { key ->
    switch (key.toLowerCase()) {
    case ~/^config\.forceview$/:
    if(this.hostProps.get(key).toLowerCase() == "true") {
    this.parser_view = true;
    }
    break
    case ~/^system\.hostname$/:
    this.host = this.hostProps.get(key)
    break
    case ~/ssh\.user$/:
    this.creds.put this.hostProps.get(key).toString(), this.hostProps.get("ssh.pass")
    if (!this.enable_pass.contains(this.hostProps.get("ssh.pass"))) {
    this.enable_pass.add(this.hostProps.get("ssh.pass"))
    }
    break
    case ~/config\.user$/:
    this.creds.put this.hostProps.get(key).toString(), this.hostProps.get("config.pass")
    if (!this.enable_pass.contains((this.hostProps.get("config.pass")))) {
    this.enable_pass.add(this.hostProps.get("config.pass"))
    }
    break
    case ~/ssh\.enable\.pass$/:
    if (!this.enable_pass.contains((this.hostProps.get("ssh.enable.pass")))) {
    this.enable_pass = ["${this.hostProps.get("ssh.enable.pass")}", *this.enable_pass]
    }
    break
    case ~/config\.enable\.pass$/:
    if (!this.enable_pass.contains((this.hostProps.get("config.enable.pass")))) {
    this.enable_pass = ["${this.hostProps.get("config.enable.pass")}", *enable_pass]
    }
    break
    case ~/^system\.sysinfo$/:
    this.sysinfo = this.hostProps.get(key)
    break
    case ~/^(config|ssh)\.enable\.level$/:
    this.enable_level = this.hostProps.get(key)
    break
    case ~/^configsource\.use\.telnet$/:
    if (this.hostProps.get(key) == "1" || this.hostProps.get(key) == "true" || this.hostProps.get(key) == "yes") {
    this.use_ssh = false
    }
    break
    case ~/^configsource\.telnet\.port/:
    this.telnet_port = this.hostProps.get(key)
    break
    case ~/^configsource\.script\.timeout/:
    this.script_timeout = (this.hostProps.get(key).toInteger() > 0 && this.hostProps.get(key).toInteger() <= 300) ? this.hostProps.get(key).toInteger() : 60
    break
    }
    }

    /*
    * In this section of code, we test the values of host, user, and pass -- ensuring
    * they did indeed get set. If not, the script will certainly fail, so catch it
    * now and provide a useful error message.
    */
    if (!this.host) {
    System.err << "(debug::fatal) Could not retrieve the system.hostname value. Exiting."
    return false
    }

    if (this.creds.isEmpty()) {
    System.err << "(debug::fatal) Could not retrieve the ssh.user/ssh.pass device property. This is required for authentication. Exiting."

    return false
    }

    return true
    }

    /**
    * Takes the SYSINFO value from hostProps and attempts to determine what sort of underlying IOS platform we are working with.
    * Some devices behave differently and thus require different logic.
    * @return
    */
    private Cisco_DetermineIOSDeviceType() {
    switch (this.sysinfo.trim()) {
    case { it.startsWith("Cisco Adaptive Security Appliance") }:
    this.isCisco_ASA = true
    this.cisco_model = CiscoModel.ASA
    break
    case { it.startsWith("Cisco Wide Area Application Services") }:
    this.isCisco_WAAS = true
    this.cisco_model = CiscoModel.WAAS
    break
    case { it.startsWith("Cisco IOS Software, c68") }:
    this.isCisco_c6800 = true
    this.cisco_model = CiscoModel.C6800
    break
    case { it.contains("C3750") }:
    this.isCisco_Cat_3750 = true
    this.cisco_model = CiscoModel.CATALYST_3750
    break
    case ~/^(?:Cisco )?IOS Software(?:\s\[.*\])?,?\s*Catalyst.*, .*Version\s+(.*)/:
    this.isCisco_Cat = true
    this.isCisco_Standard = true
    break
    case { it.startsWith("Cisco IOS") }:
    this.isCisco_Standard = true
    break
    case { it.contains("Cisco PIX") }:
    this.isCisco_PIX = true
    break
    default:
    this.isCisco_Standard = true
    break
    }
    }

    /**
    * This method is responsible for establishing the connection to a device.
    * @return TRUE if successful. FALSE otherwise
    */
    private boolean Cisco_ConnectToDevice() {
    def err_state = true
    if (this.use_ssh) {
    // open an ssh connection and wait for the prompt
    this.creds.each { lm_username, lm_password ->
    try {
    if (err_state) {
    this.cli = Expect.open(this.host, lm_username, lm_password, this.script_timeout)
    err_state = false
    }
    }
    catch (Exception all) {
    return false
    }
    }

    if (this.cli == null) {
    return false
    }
    }
    else {
    this.creds.each { lm_username, lm_password ->
    // telnet session
    try {
    if (err_state) {
    this.cli = Expect.open(this.host, this.telnet_port.toInteger(), this.script_timeout)
    err_state = false
    this.cli.expect("Username:")
    this.cli.send(lm_username + "\n")
    this.cli.expect("Password:")
    this.cli.send(lm_password + "\n")
    }
    }
    catch (Exception all) {
    return false
    }
    }
    }

    // Let's ensure that the cli object was created OR that we have performed the previous methods without exiting an error state.
    // This ensures that the script doesn't continue on if connectivity was not established, returning control back to the retry loop.
    if (this.cli == null || err_state) {
    return false
    }

    // Connection successfully established
    connected = true

    /* INITIAL MATCH ----------------------------------------------------------------------------------------------------------------------------
    In order to accommodate ascii art banners and such, we need essentially look for the last line sent to us after connection.
    Everything before that tends to be noise.
    */

    def raw_prompt_line = ""

    try {
    // The following expect method should match on most any cisco ios device prompt. Later we can take this value and parse it for the prompt
    this.cli.expect('[a-zA-Z0-9\\-\\_\\.\\/]+[>#][\\s+]?\$')
    this.cli.send("\n")
    this.cli.expect('[a-zA-Z0-9\\-\\_\\.\\/]+[>#][\\s+]?\$')

    // TODO: move this over to precompiled regex matchers
    raw_prompt_line = ("${this.cli.matched()}" =~ /([a-zA-Z0-9\-_\.\/]+[>#])[\s+]?$/)[0][1]

    }
    catch (Exception all) {
    return false
    }

    /* -- PROMPT DETECTION ---------------------------------------------------------------------------------------------------------------------
    We have progressed this far, we should be able to deconstruct the prompt line into 'prompt' and 'mode'
    */

    try {
    this.prompt = raw_prompt_line[0..-2]
    this.mode = raw_prompt_line[-1]
    this.full_prompt = this.prompt + this.mode
    }
    catch (Exception all) {
    return false
    }

    // Check to see what the previous expect command matched. This will us which user mode we have been dropped into.
    if (this.mode == "#") {
    this.priv_exec_mode = true
    }

    return true

    }

    /**
    * This method will attempt to escalate our user privileges to PRIV
    * @return boolean Will return TRUE if successful; FALSE otherwise.
    */
    private boolean Cisco_EscalateDevicePrivileges() {
    if (this.user_privilege_mode == Cisco_ENUMUserPrivilegeState.PRIV) {
    this.escalated_privs = true; return true
    }
    if (this.priv_exec_mode) {
    escalated_privs = true; return true
    }

    def exit_loop = false
    def password_position = 0

    while (!exit_loop) {
    def response
    if (password_position == 0) {
    this.cli.send("enable\n")
    }

    try {
    this.cli.expect("[Pp]assword:", "${prompt}\\s*#", full_prompt)
    response = this.cli.matched()
    }
    catch (Exception e) { }

    if (response =~ /${this.prompt}\s*#/) {
    this.mode = "#"
    this.full_prompt = Pattern.quote(response)
    this.priv_exec_mode = true
    this.user_privilege_mode = Cisco_ENUMUserPrivilegeState.PRIV
    this.escalated_privs = true
    exit_loop = true
    }
    else {
    this.cli.send(enable_pass[password_position] + "\n")
    }

    if (password_position == enable_pass.size()) {
    exit_loop = true
    }
    else {
    password_position++
    }
    }

    return (this.user_privilege_mode == Cisco_ENUMUserPrivilegeState.PRIV)
    }

    private Cisco_SetTerminal() {
    try {
    if (this.isCisco_WAAS || this.isCisco_Standard || this.isCisco_c6800 || this.isCisco_Cat_3750) {
    this.cli.send("terminal length 0\n")
    this.cli.expect(this.full_prompt)
    this.cli.send("terminal width 0\n")
    this.cli.expect("${this.full_prompt}\\s*\$")
    }
    else {
    this.cli.send("terminal pager 0\n")
    this.cli.expect("${this.full_prompt}\\s*\$")
    }
    }
    catch (Exception all) {
    return false
    }

    return true
    }

    private Cisco_ShowParserView() {
    try {
    this.cli.send("show parser view\n")
    this.cli.expect(this.full_prompt, "No view is active")
    this.cli.before()
    }
    catch (Exception all) {
    return false
    }

    if (this.cli.before().toString().contains("Current view is")) {
    this.parser_view = true
    }
    }

    def Cisco_GetShowCommands() {
    this.cli.send("show ?")

    try {
    if (this.isCisco_ASA || this.isCisco_PIX) {
    this.cli.expect("${this.full_prompt} show")
    }
    else if (this.isCisco_WAAS || this.isCisco_c6800) {
    this.cli.expect(this.full_prompt)
    }
    else {
    this.cli.expect("${this.full_prompt}show")
    }
    }
    catch (Exception all) {
    return false
    }

    try {
    this.raw_show_output = this.cli.before()
    }
    catch (Exception all) {
    return false
    }

    return this.raw_show_output
    }

    def Cisco_ParseShowCommands() {
    this.raw_show_output.eachLine() { line ->
    if (!show_entries) {
    show_entries = [:]
    }

    switch (line.trim()) {
    case { it.isEmpty() }:
    break
    case { it.startsWith("show ?") }:
    break
    case { it.startsWith(this.full_prompt) }:
    break
    default:
    try {
    def cmd = line.trim().tokenize()[0]
    def desc = line.trim().tokenize()[1..-1].join(" ")
    this.show_entries << ["${cmd}": "${desc.trim()}"]
    }
    catch (all) {
    // some sort of odd output. ignore.
    break
    }
    }
    }
    }

    def LM_getActiveDiscoveryOutput(desired_show_commands) {
    // Need to ensure that we are actually connected to the device before proceeding
    if (!connected) {
    return false
    }

    def output = ""

    if (!this.raw_show_output) {
    this.Cisco_GetShowCommands()
    }
    if (!this.show_entries) {
    this.Cisco_ParseShowCommands()
    }

    this.show_entries.each { key, value ->
    if (desired_show_commands.contains(key.toString())) {
    output += "${key}##${key}##${value}\n"
    }
    }

    return output
    }

    def LM_getCollectionOutput(show_operator) {
    // Need to ensure that we are actually connected to the device before proceeding
    if (!connected) {
    return false
    }

    def output = ""
    def response = null

    // In the event that we are within a parser view, we need to add 'view full' to a 'running-config' request.
    if (parser_view && show_operator == "running-config") {
    show_operator += " view full"
    }

    def conn_attempt_num = 1

    while ((conn_attempt_num < (this.max_conn_attempts + 1)) && response == null) {
    conn_attempt_num++

    // Send the requested show command to the device
    if (this.isCisco_c6800 || this.isCisco_Cat_3750 || this.isCisco_PIX || this.isCisco_ASA || this.isCisco_Cat) {
    if (this.escalated_privs) {
    this.cli.send("show ${show_operator}\nexit\nexit\n")
    }
    else {
    this.cli.send("show ${show_operator}\nexit\n")
    }
    }
    else {
    this.cli.send("show ${show_operator}\n")
    }

    // Attempt to match
    try {
    if (isCisco_c6800 || isCisco_Cat_3750 || this.isCisco_PIX || this.isCisco_ASA) {
    this.cli.expectClose()

    response = this.cli.stdout()
    }
    else {
    // Match prompt on a line by itself with or without surrounding whitespace.
    // Obviate case where config returns with embedded prompt and terminates
    // the config prematurely.
    this.cli.expect(/^\s*${this.full_prompt}\s*$/)

    response = this.cli.before()
    }
    }
    catch (Exception all) { }
    }

    response = response.trim()

    try {
    def found_current_config_entry = true

    if (this.isCisco_c6800 || this.isCisco_Cat_3750 || this.isCisco_PIX || this.isCisco_ASA) {
    found_current_config_entry = false
    }

    response.eachLine() { line ->
    switch (line) {
    case ~/^(.show|show) ${show_operator}/:
    if (!found_current_config_entry) {
    found_current_config_entry = true
    }
    break
    case ~/^: Saved/:
    if (!found_current_config_entry) {
    found_current_config_entry = true
    }
    output += "${line}\n"
    break
    case ~/^${full_prompt}\s?show ${show_operator}.*/:
    if (!found_current_config_entry) {
    found_current_config_entry = true
    }
    break
    case ~/^Building configuration.*/:
    break
    case ~/.+\d+ (day|days|hour|hours|year|years|week|weeks|minute|minutes).*/:
    break
    case ~/(^[Ss]witch\s|^)[Uu]ptime.*/:
    break
    case ~/^${full_prompt}.*/:
    break
    case ~/^(Current configuration|Using \d+ out of \d+ bytes).*/:
    found_current_config_entry = true
    break
    case ~/^ntp clock-period.*/:
    break
    case ~/^Logoff/:
    break
    case ~/^Load for five secs.*/:
    break
    case ~/^Time source is.*/:
    break
    case ~/^\w+\s+\w+\s+\d+\s\d+:\d+:\d+\.\d+\s+\w+/:
    break
    default:
    if (found_current_config_entry) {
    output += "${line}\n"
    }
    break
    }
    }
    }
    catch (Exception all) { }

    return output
    }

    def Cisco_LogoutDevice() {
    // If we were never connected, just return true. Most likely poor logic that allowed us to call this method even though we never established a connection.
    if (!connected) {
    return true
    }

    if (this.isCisco_c6800 || this.isCisco_Cat_3750 || this.isCisco_PIX || this.isCisco_ASA || this.isCisco_Cat) {
    return true
    } // The c6800 and Catalyst 3750 series routines will have already exited the device.

    // logout from the device
    if (this.escalated_privs) {
    this.cli.send("\nexit\nexit\n")
    this.cli.expect("\$")
    }
    else {
    this.cli.send("\nexit\n");
    this.cli.expect("#", "${this.full_prompt}exit")
    }

    connected = false

    // close the ssh connection handle then print the config
    try {
    this.cli.expectClose()
    }
    catch (Exception all) {
    return false
    }
    }
    }