TL;DR — Rcherz Scoring is an Android app for running archery competition scoring; ~50k Play Store installs, backend at rcherz.com. A routine APK review turned up ordinary client-side hardening items. The backend review turned up eleven findings, anchored by one composition bug: the production Apache vhost served its own .git/ directory over HTTPS. With Apache directory listing enabled on /.git/objects/pack/, the 77 MB pack file was a public download. Inside the repo: live Authorize.Net production credentials for a partner retailer's payment processing, two FCM legacy server keys, an Apple Push Notification production certificate, a Facebook App Secret, an Amazon LWA OAuth client secret, a reCAPTCHA secret, the same keyboard-walked password reused across four services, passwords hashed with one round of SHA-1 and a static salt, a mobile API that whitelists every scoring mutation to run unauthenticated, and an authenticateById() admin backdoor the comment describes as "should be removed in production." End to end: public HTTPS → source code → user table crack → full payment processor access. The client-side review that started it took an hour. The rest took an afternoon.

background

Rcherz Scoring is the Android application that competition organizers install on tablets to run archery scoring at tournaments. Archers pair their devices via a short PIN, the kiosk records and submits scores, and competition admins view the live results. It integrates with Firebase Cloud Messaging for pushes, Amazon Login With Amazon for identity on the Amazon build, and the app's vendor — Bowbook s.r.o., a Slovak company — operates a REST backend at rcherz.com for all mutations.

I went in because I wanted to practice black-box Android review on a target with a modest real-world deployment footprint and a self-hosted backend. Competition scoring is also one of those "invisible to users when it works, catastrophic when it doesn't" surfaces that rewards careful review. Archers don't check whether the leaderboard was CSRF'd.

Everything below was done with jadx, apktool, curl, and eventually a laptop browser. No credentialed authentication to rcherz.com, no exploitation against user accounts, no access to customer data. I stopped at the "this is trivially exploitable" threshold on each finding and did not proceed to exploit.

starting with the APK

Pulled cs.rcherz.scoring v1.9.20s from APKPure (10 MB, targetSdk 36). Three dex files, 276 first-party Java classes under cs.rcherz.*, the rest is Firebase + OkHttp + AndroidX.

Client side turned up the normal Android hardening backlog:

  • android:allowBackup="true" — every SharedPreference and the 10 MB OkHttp response cache adb backup out.
  • READ_CONTACTS and READ_HISTORY_BOOKMARKS in the manifest, neither referenced in code — dead permissions inflating the install-time warning.
  • BrowserController loads server-provided URLs in a WebView with setJavaScriptEnabled(true) and a spoofed 2010-era Nexus One user-agent. No addJavascriptInterface() so no direct JS→Java bridge, but CookieManager.getInstance().setAcceptCookie(true) is global — any rcherz.com reflected-XSS would have the session cookie.
  • FormFields.addURLButton prepends http:// to scheme-less URLs instead of https://.
  • The HTTP client's _isPostLogged defaults to true — POST bodies land in an in-memory logger that a DeveloperActivity displays to the user and that error reports embed.
  • strings.xml ships a Google API key used for Firebase + Maps, and I tested it externally: it works from plain curl against the Google Maps Platform APIs (Geocoding, Places, Directions, Distance Matrix, Time Zone, Elevation all returned HTTP 200), meaning the GCP Console has no Android-app restriction on the key. Anyone who unzips the APK has a free Google Maps quota at Bowbook's expense.

None of those would make a post. The post started when I pointed the recon commands at rcherz.com.

the backend

The mobile app talks to https://rcherz.com/en/mobile/* with session-cookie authentication. I ran the standard discovery hits while reading the Manifest:

$ curl -I https://rcherz.com/.git/HEAD
HTTP/1.1 200 OK

That shouldn't exist in production. I fetched the config:

$ curl https://rcherz.com/.git/config
[core]
    repositoryformatversion = 0
[remote "origin"]
    url = https://kubernetes:[REDACTED]@inf.rcherz.com:3000/matus/rcherzv1.git
    fetch = +refs/heads/docker:refs/remotes/origin/docker
[branch "docker"]
    remote = origin
    merge = refs/heads/docker

Two things at once. A plaintext password for the project's private Gitea in the remote URL — and Apache serving .git/ at all. The first is the headline; the second is what makes it retrievable at scale.

The rest of the .git/ directory was walkable:

/.git/HEAD         200   ref: refs/heads/docker
/.git/index        200   1,700,439 bytes
/.git/packed-refs  200   points to 93fe336f...
/.git/logs/HEAD    200   207 bytes

Apache mod_autoindex was enabled on /.git/objects/pack/:

<h1>Index of /.git/objects/pack</h1>
<li><a href="pack-e2d116...idx">  357,904 bytes</a></li>
<li><a href="pack-e2d116...pack"> 80,984,982 bytes</a></li>
<li><a href="pack-e2d116...rev">   51,028 bytes</a></li>

A HEAD request and a 512-byte range request to confirm the pack file was real:

Content-Length: 80,984,982
First bytes:     50 41 43 4B 00 00 00 02 00 00 31 c8
                 "PACK" magic  version 2   12,744 objects

77 MB containing 12,744 git objects. Every commit on the docker branch. A public HTTPS GET is enough; the Gitea credentials in .git/config are a second redundant path but not a necessary one.

.git/logs/HEAD had the best single line in the investigation:

root <root@rcherz-httpd-6648587c64-sf5qk.(none)> 1776669512 +0000
    clone: from https://inf.rcherz.com:3000/matus/rcherzv1.git

That hostname format is a Kubernetes pod. The pattern <deployment>-<rs-hash>-<5char> is a ReplicaSet-owned pod from a Deployment named rcherz-httpd. So the production Apache container runs git clone as root into its own docroot at startup. Apache's vhost is rooted at /app/v1. Anything under .git/ gets served unless explicitly blocked — and it wasn't. This is a composition bug: the Dockerfile and the Apache config are each defensible in isolation, catastrophic together.

what it shipped

The Authorize.Net credential is the one I reported out-of-band to the payment processor before doing anything else. The full inventory of what was inside:

Authorize.Net production transaction credentials

Site /protected/config/main.php, lines 297–302. The sandbox equivalents (test.authorize.net) were in a commented block immediately above them — someone had copied the test creds into prod, swapped in the real ones, and left the sandbox values in a // comment for the next developer to find. git blame would have dated the lines.

'lancasterAuthorizePaymentProcessingUrl'
    => 'https://secure.authorize.net/gateway/transact.dll',
'lancasterAuthorizeNetTransactionKey'
    => '[REDACTED — 16 chars]',
'lancasterAuthorizeNetLoginId'
    => '[REDACTED]',
'livePayments' => true,

Authorize.Net's AIM API supports AUTH_CAPTURE, CREDIT, VOID, and transaction history on a login-id + transaction-key pair. Most relevant for a leak like this one: CREDIT lets the holder of these values refund a transaction, or credit an arbitrary card up to a year after an original transaction, if the merchant account permits it. Whether Lancaster Archery's account permits unreferenced credits is a merchant-setting question I wasn't going to test. But the baseline abuse — burning transaction fees with repeated test-value authorizations, enumerating BIN ranges via AVS responses — works against any valid merchant key.

Two FCM legacy server keys

One in /protected/models/MobileDevices.php:

'Authorization: key=AIza[REDACTED — 35 chars]',

A different one (distinct value) in /protected/modules/mobile/controllers/PushController.php. Two keys, two flows, both baked in.

FCM legacy server keys POST to https://fcm.googleapis.com/fcm/send. The holder can send to any device token harvested from an FCM registration, or to /topics/* to reach every user subscribed to a broadcast topic. Notification title, body, deep link, and click action are all attacker-controllable. The legacy API is formally deprecated but Google hasn't turned off the endpoint and existing keys keep working. Rotation requires re-issuing keys in the Firebase console and bumping the value everywhere; there is no "revoke" button for the key-as-a-string once it's leaked.

Apple Push Notification production certificate

/protected/config/components/apns-prod.pem      (4,237 bytes)

PEM-encoded cert + RSA private key. The subject:

UID=bowbook.rcherzios
CN=Apple Push Services: bowbook.rcherzios
O=bowbook, s.r.o.

Same attack surface as the FCM keys but for iOS. Push to any device token, subject line and body of your choosing. Mitigation requires Apple-side revocation + reissue.

Facebook App Secret

'facebookAppId' => '[REDACTED]',
'facebookAppSecret' => '[REDACTED — 32 hex]',

Facebook App Secrets sign the server-side legs of OAuth, compute appsecret_proof headers for Graph API calls, and exchange short-lived user tokens for long-lived ones. For a product that does Facebook login, a leaked App Secret means an attacker can operate the Graph API as the app itself and, depending on what scopes the app requested, read the connected users' basic profiles and app-specific permissions.

Amazon LWA OAuth client secret

'amazon' => [
    'clientId' => 'amzn1.application-oa2-client.[REDACTED]',
    'clientSecret' => '[REDACTED — 64 hex]'
],

Distinct from the Amazon API key in the APK's assets/api_key.txt — that one is signed by Amazon and bound to the package name, harmless. The server-side client secret authenticates token-exchange calls during Login With Amazon. A leaked client secret lets you run the server half of OAuth as the app.

Google reCAPTCHA secret key

In common/config/params-sample.php (v2 branch):

'google.recaptcha.secret.key' => '6Ld-hyUTAAAAA[REDACTED]',

reCAPTCHA's siteverify endpoint validates user-submitted tokens against the site key + secret key pair. With the secret, the attacker can either harvest reCAPTCHA challenges at the site's quota or, if the consuming code doesn't re-check the hostname field on the siteverify response, forge verifications against unrelated site keys on the same quota. Every anti-bot gate in the product that leans on this pair was functionally open.

An AES key

'aesKey' => 'QSD8974A2F2DF45adfq46Qrpfyhgakeq',

32 ASCII characters, used for symmetric encryption of something in the tree. Whatever that something is, it's now decryptable.

SMTP credentials

'mail' => array(
    'transportOptions' => array(
        'host' => 'mail.rcherz.com',
        'username' => 'rcherz+rcherz.com',
        'password' => '5tgb6yhn',
        ...

With these, send mail as the vendor from their own mail server. Users who trust email from [email protected] get phished convincingly.

The Yii Gii admin password

Same main.php:

'modules' => array(
    'gii' => array(
        'class' => 'system.gii.GiiModule',
        'password' => '5tgb6yhn',
        'ipFilters' => array('127.0.0.1', '::1'),
    ),

Gii is Yii's code generator. It writes PHP files to disk. The IP filter is the only thing keeping it from being a public RCE primitive; the password is a keyboard walk and would not be the control stopping an attacker who ended up inside the cluster. And 5tgb6yhn — the middle column of a QWERTY keyboard from top to bottom — appears three times in main.php alone. Gii admin, SMTP, and a DB connection a few lines above.

For the completionist: a fourth hardcoded DB password elsewhere ('asdH21341MA,dasDqh' in rcherzv2/console/config/main-local.php, which is meant to be gitignored; the file was committed anyway in the docker branch).

trustedIps

Same file:

'trustedIps' => array('127.0.0.1', '::1', '192.168.*', '10.*'),

Used by the backend's auth middleware to skip authentication on "service calls" originating from private IPs. The comment above the block says // TODO : remove. From inside the Kubernetes cluster, a pod that can reach the Apache service at its internal IP bypasses auth. The devutils/index.php file is a one-page HTML form that lets any visitor $.ajax a POST to any URL — from inside the cluster network, that's SSRF-to-auth-bypass on a silver tray.

no auth on the scoring API

The config file alone would have been the whole post. The mobile controllers pushed it over.

Every action in the mobile module inherits from MobileController, whose accessRules() reads:

public function accessRules(){
    return array(
        //allows just authenticated users to access all methods
        array(
            'allow',
            'users' => array('*'),
        ),
    );
}

The comment describes the intent. The code implements the opposite. Yii 1's access rule shorthand uses @ for "authenticated users only" and * for "everyone including guests." This rule is *.

The real enforcement is in beforeAction:

public function beforeAction($action) {
    $controllerId = trim($this->id . ' ');
    $actionId = trim($action->id . ' ');

    if (!array_key_exists($controllerId, $this->allowedActions)
        || !in_array($actionId, $this->allowedActions[$controllerId])) {
        if (Yii::app()->user->isGuest || Yii::app()->user->getId() == '') {
            echo CJSON::encode(array('error' => 'User is not authentified'));
            Yii::app()->end();
        }
    }
    return true;
}

Read the condition carefully. If the action is NOT in $allowedActions, the guest check runs. If the action IS in $allowedActions, the guest check is skipped.

The name is inverted from what you'd assume. $allowedActions is not a whitelist of authenticated endpoints. It is a whitelist of endpoints allowed to run with no login at all.

Here is what sits in the whitelist for scoring:

'scoring' => array(
    'loadScoringUsers',
    'startScoring',
    'saveUserDetail',
    'stopScoring',
    'clearUsers',
    'setWinner',
    'removeUsers',
    'saveUsersResultDetails',
    'getUsersPositions',
    'loadCompetition',
    'getLastScoringAppInfo',
    'ping',
    'reportDeviceStatus'
),
'v2scoring' => array(
    ... same ...
    'validatePin',
    ...
),

Every scoring endpoint. Including mutations. actionSetWinner:

public function actionSetWinner() {
    $json = CJSON::encode([
        'resultId' => $this->actionParams['resultId'],
        'winner' => (bool) $this->actionParams['isWinner']
    ]);
    // ... appends to runtime/winner.log ...
    Results::setIsWinner($this->actionParams['resultId'],
                         (bool) $this->actionParams['isWinner']);
    echo CJSON::encode(array('success' => true));
}

No ownership check on resultId. No PIN check. No session. Any HTTP client with an integer and a parameter name flips anyone's winner flag. The only incidental control is the append to runtime/winner.log, which an attacker has no reason to look at.

actionSaveUsersResultDetails follows the same pattern: log the $_POST to runtime/scoring.log for "temp debug," then write scores to whatever resultId the request specifies.

The architectural assumption these handlers seem to make is that installationId is an authentication token. It isn't. It's a client-generated UUID sent in plaintext as a URL parameter on every request. It's logged by Apache access logs, logged by Varnish, and never rotates. It's an identifier. To confirm the point on a live endpoint without touching live data, I hit validatePin externally:

GET /en/mobile/v2/scoring/validatePin?competitionId=1&pin=0000
→ {"success":true,"isValid":false}

GET /en/mobile/v2/scoring/validatePin?competitionId=1&pin=1234
→ {"success":true,"isValid":false}

# ten consecutive requests, no session cookie:
200 200 200 200 200 200 200 200 200 200

No session required, no rate limit, no lockout. If PINs are four digits, a few thousand requests per minute with no backoff unlocks any competition in under two minutes. And unlocking the PIN isn't strictly necessary for setWinner or saveUsersResultDetails, which don't consult it.

password hashing

Users::hashPassword:

private static $salt = '8qikM4LhRX3pla3hKEi6';

public static function hashPassword($plainPassword) {
    return sha1(self::$salt . $plainPassword);
}

Every mistake in a tight package.

  • SHA-1 is collision-broken since 2017 and, more relevantly, fast. A single RTX 4090 does 10–20 GH/s in hashcat -m 100 mode.
  • Static salt. Every user shares the same salt, so precomputation against the user table is a one-shot cost for the whole database instead of per-user.
  • No iterations. PBKDF2, bcrypt, argon2id all exist to make this calculation intentionally slow. This one runs as fast as the hash hardware allows.

The only saving grace is that the salt exists at all, so off-the-shelf rainbow tables don't hit directly. You have to build a targeted table. That takes minutes.

The password reset flow generates new passwords as:

$random = md5(rand(0, 10000000));

rand() is not random_bytes(). Ten million seeds means ten million possible outputs. Precompute all 10 million md5(i) values into a hash table and every "random" password the reset flow has ever produced is a dictionary lookup. The precomputation runs in seconds on a laptop.

User IDs generated through the model also lean on uniqid():

$id = substr(md5(uniqid()), 0, 8);

uniqid() is a microtime-timestamp wrapper. Eight hex chars is 32 bits of nominal space, most of which is strongly timestamp-correlated. If you know roughly when an ID was generated, the effective entropy is closer to 16 bits.

the admin backdoor

UserIdentity::authenticateById:

/**
 * Authenticates user is admin request, where no password is needed
 * (superusers can log in under every account)
 * This is because of testing purposes, should be removed in production version
 */
public function authenticateById($userId) {
    $user = Users::model()->findUserById($userId);
    if ($user) {
        $this->_userInfo = $user;
        return true;
    }
    else return false;
}

The comment acknowledges it should be removed. It wasn't. Any code path that lets attacker-controlled input reach this method is an account takeover primitive. Alongside it, tokenAuthenticate() logs in by LoginTokens.id primary key (the corresponding model is an empty ActiveRecord subclass — token values look like plain auto-incrementing integers), and bypassAuthenticateAndPrepareUsingModel() does what it says in the name.

the chain

Written out end-to-end, assuming any attacker notices the open .git/:

  1. curl https://rcherz.com/.git/objects/pack/pack-e2d116...pack — downloads 77 MB of repository, no authentication.
  2. git init; cp *.pack *.idx .git/objects/pack/; git fsck; git reset --hard 93fe336f... — fully reconstructed source tree.
  3. grep -rn "'password'" protected/config/main.php — 20 lines, three distinct credential categories, every API key in the tree.
  4. Use the Authorize.Net login-id + transaction key to refund transactions on Lancaster Archery's merchant account to attacker-controlled cards within the refund window. Or burn merchant fees with repeated authorizations. Or enumerate valid card numbers via AVS responses.
  5. Independently: dump the users table (path depends on DB creds in the same file) and crack every password in the database in under an hour.
  6. Independently: walk the $allowedActions list, pick any v2scoring/* endpoint, iterate resultId values, tamper with scores or flip winner flags on live competitions. No account needed.
  7. Independently: with the FCM keys and the APNs cert, push to every installed device in the ecosystem with an attacker-chosen notification body and deep link.

Each of steps 4–7 is independently sufficient to classify the incident as a full compromise of the product. They share no prerequisites beyond step 1.

what goes back in the .git

The underlying cause of step 1 is narrow and fixable. Three lines of Docker plus one line of Apache:

# before
RUN git clone https://.../rcherzv1.git /app/v1

# after: multi-stage build that drops .git
FROM git-builder AS src
RUN git clone --depth 1 https://.../rcherzv1.git /app/v1 && rm -rf /app/v1/.git

FROM php-apache
COPY --from=src /app/v1 /app/v1
USER 1000
# Apache vhost, catch-all
<DirectoryMatch "(^|/)\.git(/|$)">
    Require all denied
</DirectoryMatch>
Options -Indexes

The Dockerfile fix removes .git/ from the image entirely. The Apache block is defense in depth so a future regression doesn't re-open the hole. Turning off mod_autoindex is the third move — it's what made the pack file name enumerable in the first place.

The secrets side is a bigger but boring job: every value in this post rotated, every 'foo' => '...' line rewritten to 'foo' => $_ENV['FOO'], and a gitleaks detect --log-opts="--all" run over the full history with every surfaced value added to the rotation list. A CI job that fails the build on any new gitleaks match keeps it from growing back.

The $allowedActions logic should be replaced entirely. A mobile action is either fully public (reference data like target-face geometry), session-authenticated (user profile, competition membership), or device-paired (scoring actions). The current model conflates all three and implements the most permissive version. For device-paired actions specifically, the pairing should issue a server-side token bound to (installationId, competitionId), revocable, with a short TTL. Not a client-picked UUID.

Password hashing migrates to bcrypt (password_hash / password_verify), with re-hashing on next successful login during a transition window, then a forced reset for accounts dormant past some cutoff. random_bytes(16) replaces md5(rand()) in password resets. authenticateById, tokenAuthenticate, and bypassAuthenticateAndPrepareUsingModel are deleted.

disclosure timeline

Date Event
2026-04-22 Initial triage; .git/ exposure discovered during recon
2026-04-22 Out-of-band notice sent to [email protected] re: compromised merchant transaction key
2026-04-22 Initial disclosure sent to Bowbook s.r.o. via [REDACTED]
[TBD] Vendor acknowledged
[TBD] Authorize.Net key rotated
[TBD] FCM keys rotated, APNs cert reissued
[TBD] .git/ removed from docroot, Apache block deployed
[TBD] Remaining secrets rotated
[TBD] $allowedActions refactored; scoring endpoints require device-paired session
[TBD] Password hashing migrated to bcrypt
[TBD] Full fix deployed, 90-day embargo elapses, post published

CVE IDs (pending): CVE-[TBD] through CVE-[TBD]. Bowbook indicated a [TBD] response; timeline updates will be appended.

lessons

Two things will stick with me.

The boring list always finds the boring things. .git/, .env, backup.sql, phpinfo.php, composer.json. I almost skipped that pass because I was three hours into the APK review and feeling fancy. The one that hit was the first item on every scanner's checklist since 2012. The lesson isn't "stop feeling fancy." It's "the boring checks cost a curl -I each. Run them even when you're feeling fancy."

Inversions hide in comments. Two of the worst findings in this codebase were lines whose natural-language comment described the opposite of the code.

//allows just authenticated users to access all methods
array('allow', 'users' => array('*')),
// This is because of testing purposes, should be removed in production version
public function authenticateById($userId) { ... }

A comment that contradicts the line below it is a red flag every code reviewer knows to recognize, and a red flag every grep-based audit tool doesn't. For adversarial review specifically, an explicit pass that diffs the comment against the code is disproportionately productive for the time invested. Every sufficiently old codebase has at least one.

The composition bug in the Dockerfile is the story; it's also the least important finding. The actual risk in the product was in the 'users' => array('*') line and the $allowedActions whitelist. Those had been waiting for someone to look.

Coordinated disclosure with Bowbook s.r.o. was underway at the time of writing; the timeline above will be updated when each milestone is confirmed. The Authorize.Net credential was reported out-of-band to the payment processor on the day of discovery, in parallel with initial vendor contact, and rotation should precede any other fix. Credentials in this post are redacted to character counts only; the character counts disclose nothing that isn't immediately derivable from a 77 MB pack file anyone could fetch during the exposure window.