ZF-12379: Captcha / Session issues - isValid() returns false even when input is correct after a certain number of attemps (expiration hops)

Description

This reports a combination of issues with Zend_Captcha which may also involve Zend_Session. Basically, after Zend generates a CAPTCHA, the first attempt goes well. However, if the first attempts due to incorrect input, further attempts fail regardless of the input. I identified some problems in Zend's code, but I can't point exactly to where the problem is or suggest a fix. My debugger (XDebug with Eclipse) is showing some really strange stuff (but then, I know the debugger is half-broken). Zend maintainers are advised to take a deep breath and have appropriate beverage nearby before going further.

I discovered this issue while testing Tiki Wiki CMS Groupware, which uses Zend_Captcha since version 6. A demo of version 9, on which I found the problem, is available on http://demo.tiki.org/9x/tiki-index.php However, reproducing the scenario will require some configuration. In Tiki's case, a user can make 2 attempts to solve the CAPTCHA. If the input is wrong in both cases, further attempts will fail regardless of the input. Zend reports the verification code was mis-typed even if the code was typed correctly. By default, only 1 attempt works fine (Tiki allows 2 because it uses setExpirationHops(2)).

The relevant part of isValid() is:

    if ($input !== $this->getWord()) {
        $this->_error(self::BAD_CAPTCHA);
        return false;
    }

getWord() reads:

/**
 * Get captcha word
 *
 * @return string
 */
public function getWord()
{
    if (empty($this->_word)) {
        $session     = $this->getSession();
        $this->_word = $session->word;
    }
    return $this->_word;
}

Some unclear issue with sessions causes getWord() to return null or the empty string in some cases, rather than the verification code. isValid() does not expect that and reports the input as invalid, even though the real problem was in fetching the actual verification code.

Whatever number of expiration hops is initially set, that number is set to 1 in getSession(), so any number above 2 won't work correctly. The session namespace for some foo CAPTCHA is stored in session array $_SESSION['foo'], which is managed by a second session array, $_SESSION['__ZF']['Zend_Form_Captcha_foo']. The latter array's ENNH element defines the number of hops to expiration, and decreases each hop. Session expiration is managed by Zend_Session_Namespace's constructor. Therefore, what should happen is that a first request generates the CAPTCHA and creates these session arrays, which should be set to expire the next hop. Then, regardless of the input submitted, the namespace should expire and both arrays should be unset. But that's not what happens. Since getSession() sets expiration to 1 hop after the namespace is constructed, $_SESSION['__ZF']['Zend_Form_Captcha_foo'] remains (so only $_SESSION['foo'] actually disappears). To make things worst, on the next request, $_SESSION['foo'] is also recreated. I failed to figure out why. In the end, both arrays are defined, when they should both be gone. And $_SESSION['foo'] does not contain the original data, but rather maps the word key to the empty string, hence isValid() rejects any non-empty string. It's worth noting that if all CAPTCHAs become the empty string, this would constitute a security threat allowing to trivially defeat Zend_Captcha, although I haven't verified this.

I'm providing a quick testcase for this. Before running zend_captcha.php, the Zend component needs to be made a symbolic link to a Zend installation. I also suggest to destroy any pre-existing session. To test, open the file, then add 2 GET parameters for the id and the input. For example, if the CAPTCHA generated has id 15517fa872b888710cefe41d0bff0c4a and code f5sac2, then hit zend_captcha.php?input=f5sac2&id=15517fa872b888710cefe41d0bff0c4a The verification will work the first time. But if you simply refresh the page, the test will claim the input is invalid.

Again, the affected code is too large for me to recommend a patch, but I imagine isValid() and getSession() will need changes.

Comments

Sorry, it seems I cannot attach files to this ticket. Ask me for the testcase.

@~chealer] You must fill out the [CLA and then you can add files.

We need a complete and short example to reproduce the problem but without any code from a third party!

Well, I don't intend to send any "contribution" to Zend, so I have no desire to send the CLA.

The testcase is based on Tiki's code, but trivial (ignoring the font included).

[~chealer] Have I missed something or where is the testcase? We need some code.

Frank, the testcase is on my PC. If I understand correctly, I cannot attach files to this ticket without having sent the Individual Contributor License Agreement. Since I have no intention to become a Contributor, I am not going to send the CLA, and therefore am not going to attach the testcase here. As I said, ask me if you want it.

Simple solution: Use Gist or any other similar service! :)

My testcase is not just text. However, I pasted the code to http://paste.debian.net/185493/