session_regenerate_id会导致返回两个PHPSESSID cookies。

3
我开发了一款API,最初仅通过浏览器使用,从未发现任何问题。然而,现在我正在尝试通过第三方Android库(OkHttpClient)连接到它,并使用REST API测试客户端(Insomnia.rest)测试了我所看到的内容。
我的问题在于,当我执行API的登录操作并启动会话时,我调用session_regenerate_id(true)来避免粘性会话攻击(我不确定这是否是正式名称)。
然而,当我这样做时,如下所示的标头中返回了两个PHPSESSID cookie.
< HTTP/1.1 200 OK
< Date: Thu, 18 Apr 2019 22:51:43 GMT
< Server: Apache/2.4.27 (Win64) PHP/7.1.9
< X-Powered-By: PHP/7.1.9
* cookie size: name/val 8 + 6 bytes
* cookie size: name/val 4 + 1 bytes
< Set-Cookie: ClientID=413059; path=/
* cookie size: name/val 9 + 26 bytes
* cookie size: name/val 4 + 1 bytes
< Set-Cookie: PHPSESSID=15u9j1p2oinfl5a8slh518ee9r; path=/
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
* cookie size: name/val 9 + 26 bytes
* cookie size: name/val 4 + 1 bytes
* Replaced cookie PHPSESSID="hkkffpj8ta9onsn92pp70r257v" for domain localhost, path /, expire 0
< Set-Cookie: PHPSESSID=hkkffpj8ta9onsn92pp70r257v; path=/
* cookie size: name/val 17 + 1 bytes
* cookie size: name/val 4 + 1 bytes
< Set-Cookie: UsingGoogleSignIn=0; path=/
* cookie size: name/val 6 + 1 bytes
* cookie size: name/val 4 + 1 bytes
< Set-Cookie: UserID=7; path=/
< Access-Control-Allow-Credentials: true
< Content-Length: 47
< Content-Type: application/json

从上面的输出可以看到有两个带有PHPSESSID的Set-Cookie。如果我删除session_regenerate_id,那么只会得到一个PHPSESSID cookie,然后Android客户端就可以正常工作了。
我在Windows 10上的Wamp中展示了这个问题,并在CentOS 7构建的生产环境中使用了Apache。
所以问题是,如何生成新的PHP会话ID而不返回两个不同的PHPSESSID cookie?
以下是与登录过程相关的一些代码。我无法包括所有的代码,但它应该显示正在发生的概念。
调用API请求登录函数。
$email = mysqli_escape_string($this->getDBConn(), $encryption->encrypt($postArray["email"]));
        $password = mysqli_escape_string($this->getDBConn(), $encryption->encrypt($postArray["password"]));
        $externalDevice = isset($postArray["external_device"]) ? abs($postArray["external_device"]) : 0;

        $query = "SELECT * FROM users WHERE Email='$email'";
        $result = $this->getDBConn()->query($query);
        if ($result)
        {
            if (mysqli_num_rows($result) > 0 )
            {
                $myrow = $result->fetch_array();
                if ($myrow["UsingGoogleSignIn"] === '1')
                {
                    //We're trying to login as a normal user, but the account was registered using Google Sign In
                    //so tell the user to login via google instead
                    return new APIResponse(API_RESULT::SUCCESS, "AccountSigninViaGoogle");
                }
                else
                {
                    //Check the password matches
                    if ($myrow["Password"] === $password)
                    {
                        $this->getLogger()->writeToLog("Organisation ID: " . $myrow["Organisation"]);
                        $organisationDetails = $this->getOrganisationDetails(abs($myrow["Organisation"]), false);
                        $this->getLogger()->writeToLog(print_r($organisationDetails, true));

                        $this->createLoginSession($myrow, $organisationDetails, false, $paymentRequired, $passwordChangeRequired);
                        $data = null;
                        if ($externalDevice === 1)
                        {
                            $data = new stdClass();
                            $data->AuthToken = $_SESSION["AuthToken"];
                            $data->ClientID = $_SESSION["ClientID"];
                            $data->UserID = abs($_SESSION["UserID"]);
                        }

                        $this->getLogger()->writeToLog("Login Response Headers");
                        $headers = apache_response_headers();
                        $this->getLogger()->writeToLog(print_r($headers, true));

此时会返回一个API响应,其中包含JSON对象。

在上面的代码中,如果电子邮件和密码匹配(这里没有使用Google登录),它会调用createLoginSession,如下所示:

private function createLoginSession($myrow, $organisationDetails, $usingGoogleSignIn, &$paymentRequired, &$passwordChangeRequired)
    {
        require_once 'CommonTasks.php';
        require_once 'IPLookup.php';
        require_once 'Encryption.php';
        try
        {
            $this->getLogger()->writeToLog("Creating login session");
            $paymentRequired = false;
            if ($organisationDetails === null)
            {
                $organisationDetails = $this->getOrganisationDetails($myrow["Organisation"]);
            }
            $encryption = new Encryption();
            $userID = mysqli_escape_string($this->getDBConn(), $myrow["UserID"]);
            $organisationID = intval(abs($myrow["Organisation"]));

            $commonTasks = new CommonTasks();
            $browserDetails = $commonTasks->getBrowserName();
            $this->getLogger()->writeToLog("Browser Details");
            $this->getLogger()->writeToLog(print_r($browserDetails, true));
            $clientName = $browserDetails["name"];

            $iplookup = new IPLookup(null, $this->getLogger());
            $ipDetails = json_decode($iplookup->getAllIPDetails($commonTasks->getIP()));

            if ($ipDetails !== null)
            {
                $ip = $ipDetails->ip;
                $country = $ipDetails->country_name;
                $city = $ipDetails->city;
            }
            else
            {
                $ip = "";
                $country = "";
                $city = "";
            }

            //Create a random client ID and store this as a cookie
            if (isset($_COOKIE["ClientID"]))
            {
                $clientID = $_COOKIE["ClientID"];
            }
            else
            {
                $clientID = $commonTasks->generateRandomString(6, "0123456789");
                setcookie("ClientID", $clientID, 0, "/");
            }

            //Create an auth token
            $authToken = $commonTasks->generateRandomString(25);
            $encryptedAuthToken = $encryption->encrypt($authToken);

            $query = "REPLACE INTO client (ClientID, UserID, AuthToken, ClientName, Country, City, IPAddress) " .
                "VALUES ('$clientID', '$userID', '$encryptedAuthToken', '$clientName', '$country', '$city', '$ip')";
            $result = $this->getDBConn()->query($query);
            if ($result)
            {

                session_start();
                $this->getLogger()->writeToLog("Logging in and regnerating session id");
                session_regenerate_id(true);

                $_SESSION["AuthToken"] = $authToken;
                $_SESSION["ClientID"] = $clientID;
                $_SESSION["UserID"] = $userID;
                $_SESSION["FirstName"] = $this->getEncryption()->decrypt($myrow["FirstName"]);
                $_SESSION["LastName"] = $this->getEncryption()->decrypt($myrow["LastName"]);

                $passwordChangeRequired = $myrow["PasswordChangeRequired"] === "1" ? true : false;
                //Check if the last payment failure reason is set, if so, set a cookie with the message but only
                //if the organisation is not on the free plan
                //Logger::log("Current Plan: " . $this->getOrganisationDetails(->getPlan()));
                if ($organisationDetails->getPlan() !== "Free")
                {
                    if (!empty($organisationDetails->getLastPaymentFailureReason()))
                    {
                        $this->getLogger()->writeToLog("Detected last payment as a failure. Setting cookies for organisation id: " . $organisationDetails->getId());
                        setcookie("HavePaymentFailure", true, 0, "/");
                        setcookie("PaymentFailureReason", $organisationDetails->getLastPaymentFailureReason(), 0, "/");

                    }
                    //Check if the current SubscriptionPeriodEnd is in the past
                    $subscriptionPeriodEnd = $organisationDetails->getSubscriptionOfPeriod();
                    $currentTime = DateTimeManager::getEpochFromCurrentTime();
                    if ($currentTime > $subscriptionPeriodEnd)
                    {
                        $this->getLogger()->writeToLog("Detected payment overdue for organisation: " . $organisationDetails->getId());
                        //The payment was overdue, determine the number of days grace period (there's a 7 day grace period) that's left
                        $subscriptionPeriodEndGracePeriod = $subscriptionPeriodEnd + (86400 * 7);
                        $numberOfDaysRemaining = floor((($subscriptionPeriodEndGracePeriod - $currentTime) / 86400));

                        setcookie("PaymentOverdue", true, 0, "/");
                        setcookie("DaysGraceRemaining", $numberOfDaysRemaining, 0, "/");
                        if ($numberOfDaysRemaining <= 0)
                        {
                            $paymentRequired = true;
                        }
                    }
                }
                setcookie("UsingGoogleSignIn", $usingGoogleSignIn ? "1" : "0", 0, "/");
                if ($organisationDetails->getId() !== 0)
                {

                    $_SESSION["OrganisationDetails"] = array();
                    $_SESSION["OrganisationDetails"]["id"] = $organisationDetails->getId();
                    $_SESSION["OrganisationDetails"]["Name"] = $organisationDetails->getName();

                }
                setcookie("UserID", $userID, 0, "/");
                $this->getLogger()->writeToLog("Successfully created login session. User ID '$userID' and Organisation ID '$organisationID'");
                return true;
            }
            else
            {
                $error = mysqli_error($this->getDBConn());
                $this->getLogger()->writeToLog("Failed to create login session. DB Error: $error");
                $this->getAlarms()->setAlarm(AlarmLevel::CRITICAL, "AccountManagement", "Failed to create login session. DB Error");
                throw new DBException($error);
            }
        }
        catch (DBException $ex)
        {
            throw $ex;
        }
    }

在上面的函数中,我调用了session_start(),然后调用regenerate_session_id(),然后在响应中获得了两个PHPSESSID cookie,尽管日志输出行只输出了一次,所以它肯定不会被多次调用。
如果我删除regenerate_session_id,问题就消失了。为了安全起见,我尝试交换session_start()的位置,使其在regenerate_session_id之后调用,但看起来会话ID没有像预期的那样重新创建。 更新2 根据@waterloomatt的评论,我创建了一个仅包含以下内容的PHP脚本:
<?php
session_start();
session_regenerate_id(true);
phpinfo();

从phpinfo输出的HTTP头信息如下:

**HTTP Request Headers**
GET /api/session_test.php HTTP/1.1
Host    localhost
Connection  keep-alive Upgrade-Insecure-Requests    1
User-Agent  Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36
Accept  text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding gzip, deflate, br
Accept-Language en-GB,en-US;q=0.9,en;q=0.8
Cookie  _ga=GA1.1.1568991346.1553017442

**HTTP Response Headers**
X-Powered-By    PHP/7.2.10
Set-Cookie  PHPSESSID=i19irid70cqbvpkrh0ufffi0jk; path=/
Expires Thu, 19 Nov 1981 08:52:00 GMT 
Cache-Control   no-store, no-cache,> must-revalidate Pragma no-cache
Set-Cookie  PHPSESSID=48qvia5e6bpmmk251qfrqs8urd; path=/

请问您能否验证在单个请求中是否可能多次调用session_regenerate_id函数。每次调用该函数都会添加一个Set-Cookie头信息。 - waterloomatt
@waterloomatt 我已经在 session_regenerate_id 执行的地方添加了日志行(只有一个地方),并确认日志条目仅出现一次,因此我不会多次重新生成ID。 - Boardy
你是如何调用 session_start() 的?在哪里调用的? - Woodrow
如果可能的话,请发布您相关的会话代码,我们将尝试重现此问题。 - waterloomatt
@waterloomatt 我已经添加了一个代码片段,希望能够展示正在发生的事情。 - Boardy
显示剩余5条评论
3个回答

2

这个标头实际上将删除cookie。这是删除HTTP-only cookie的唯一方法:使其过期并回溯。

最初的回答:

该头部实际上将删除Cookie。这是删除HttpOnly Cookie的唯一方式:将其设置为过期时间,并且向后回溯。

Set-Cookie: PHPSESSID=15u9j1p2oinfl5a8slh518ee9r; path=/
< Expires: Thu, 19 Nov 1981 08:52:00 GMT

1
谢谢,但我不确定它如何有所帮助。问题不在于无法删除或过期cookie,而是为什么session_regenerate_id会导致返回两个Set-Cookie: PHPSESSID=... cookie。 - Boardy

1
我想我已经弄清楚了发生了什么。我以为当cookie过期时,所有内容都会在同一行上,但看起来 - 至少是我传递cookie字符串的方式 - cookie的到期时间在另一行。因此,我已更改了解析cookie字符串的方式,因此如果下一行有过期时间,我就不包括那个cookie,这似乎有效。以下是我传递cookie并在其过期时不包括cookie的方式:
for (int i = 0; i < headers.length; i++)
                {
                    Log.d("BaseAPI", "Header: " + headers[i]);
                    if (headers[i].trim().toLowerCase().startsWith("set-cookie:"))
                    {
                        if (headers[i+1].toLowerCase().startsWith("expires"))
                        {
                            Log.d("BaseAPI", "Found expired header. The cookie is: " + headers[i+1]);
                            //Thu, 19 Nov 1981 08:52:00 GMT
                            long epoch = new SimpleDateFormat("EEE, dd MMM YYYY HH:mm:ss").parse(headers[i+1].replace("Expires: ", "").replace("GMT", "").trim()).getTime();
                            Log.d("BaseAPI", "Cookie Epoch: " + epoch);
                            long currentEpoch = new Date().getTime();
                            Log.d("BaseAPI", "Current Epoch: " + currentEpoch);
                            if (epoch < currentEpoch)
                            {
                                continue;
                            }
                        }
                        cookieBuilder.append(headers[i].trim().replace("Set-Cookie:", "").replace("path=/", ""));
                        cookieBuilder.append(" ");

                    }
                }

0

这是一个已知且有文档记录的问题。 只需调用session_regenerate_id()而不传递true参数即可。 手册明确指出,如果要避免竞争条件并且并发访问可能导致不一致状态,则不应删除旧会话数据。 更多信息请参见https://www.php.net/manual/en/function.session-regenerate-id.php


1
我实际上被告知,安全公司建议传递true(不是为了这个项目,而是为了我的实际工作中的另一个项目)。但是,作为测试,我已经从regenerate_session_id中移除了true,但它仍然返回两个PHPSESSID cookie。 - Boardy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接