From 26cd480978c9402fce38be8dc2d36b28a9ead91f Mon Sep 17 00:00:00 2001 From: woffluon Date: Tue, 6 Oct 2026 21:57:02 +0300 Subject: [PATCH 01/11] fix(runtime): harden apt transport, sha256 keyring verification, and elf patcher --- .../terminal/runtime/AptConfigurator.kt | 320 +++++++--- .../terminal/runtime/BootstrapManager.kt | 93 +-- .../terminal/runtime/ElfLinkerPatcher.kt | 552 ++++++++++++++---- scripts/setup-extra-rootfs.sh | 10 +- 4 files changed, 700 insertions(+), 275 deletions(-) diff --git a/app/src/main/java/org/cortex/terminal/runtime/AptConfigurator.kt b/app/src/main/java/org/cortex/terminal/runtime/AptConfigurator.kt index 4e08bd5..196df09 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/AptConfigurator.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/AptConfigurator.kt @@ -1,9 +1,13 @@ package org.cortex.terminal.runtime +import android.annotation.SuppressLint import android.content.Context +import android.os.Build +import android.system.Os import android.util.Base64 import android.util.Log import java.io.File +import java.security.MessageDigest object AptConfigurator { private const val TAG = "AptConfigurator" @@ -11,18 +15,139 @@ object AptConfigurator { private const val UBUNTU_ARCHIVE_KEYRING_BASE64 = "mQINBE+tgXgBEADfiL1KNFHT4H4Dw0OR9LemR8ebsFl+b9E44IpGhgWYDufj0gaM/UJ1Ti3bHfRT39VVZ6cv1P4mQy0bnAKFbYz/wo+GhzjBWtn6dThYv7n+KL8bptSCXgg1a6en8dCCIA/pwtS2Ut/g4Eu6Z467dvYNlMgCqvg+prKIrXf5ibio48j3AFvd1dDJl2cHfyuON35/83vXKXz0FPohQ7N7kPfI+qrlGBYGWFzC/QEGje360Q2Yo+rfMoyDEXmPsoZVqf7EE8gjfnXiRqmz/Bg5YQb5bgnGbLGiHWtjS+ACIdLUq/h+jlSp57jw8oQktMh2xVMX4utDM0UENeZnPllVJSlR0b+ZmZz7paeSar8Yxn4wsNlL7GZbpW5A/WmcmWfuMYoPhBo5Fq1V2/siKNU3UKuf1KH+X0p1oZ4oOcZ2bS0Zh3YEG8IQce9Bferq4QMKsekcG9IKS6WBIU7BwaElI2ILD0gSwu8KzvNSEeIJhYSsBIEzrWxIBXoN2AC9PCqqXkWlI5Xr/86RWllB3CsoPwEfO8CLJW2LlXTen/Fkq4wT+apdhHeiWiSsq/J5OEff0rKHBQ3fK7fyVuVNrJFb2CopaBLyCxTupvxs162jjUNopt0c7OqNBoPoUoVFAxUSpeEwAw6xrM5vROyLMSeh/YnTuRy8WviRapZCYo6naTCY5wARAQABsAwAAGdwZwEAAAAAAAC0QlVidW50dSBBcmNoaXZlIEF1dG9tYXRpYyBTaWduaW5nIEtleSAoMjAxMikgPGZ0cG1hc3RlckB1YnVudHUuY29tPrAMAABncGcCAAAAAAAAiQI4BBMBAgAiBQJPrYF4AhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRA7T+aswLIfMl1+EACR1HSunmDMiXKxT98il7VGEDKWh0TP35aKmbThYZZnC1TIATTq9Hi7wVNCXGcmaRzL2XIkwwTFl/CLQmFY0Xo39CtJT7xx0RmhO7eiR1VAns5zWwzJzj2FcJVSXWSzmuj5hOVl1V6ZPLkwPL5ukTtq0tt7xO1NKUJVftRlVzFh+GS42kLP05u8Hb0cXqk27XzhHhxi45rKIdHqx38zFeMAP/WavOls7iUtR8V0ejmAwt/2kF+wsWE9TEMRMPzzm5x7ZJdz0TFnU1u30kLbpRF86a9vyQnr+jH3PFMtGg9454PW8lZPRqXTRRIxoGlKo6smaLL8AGeP3ZkY5jBIm13jVBgvB3lgt1jlVfC/w4gPpoiZcD78D4gNWbigSOQPFRdKzR1u0FbBvJEPjwx4EXbJoac0kYMpDdT4CulMUnCl/C6jSgrSqbhDwKZGuxUNbuAaGSo46QYWNUeE6XxZDCHu6lvF36qGj/faRA98V3IdsxUTR4rTSa/skCR+M/6PtlL50wNp4lEx5RUggaFNTL0qtTdid6lOqEdnDmCeGcalsgqHkEdcfGj5y5XJ+JXuh1O06HGGx2iJnCLe6pxuDYtDlj+IIhIYzqYMba1oJd+pnbn764sMmvhB1859+hL0PTvm5t38mq7J4T3tNa5bEcagYitSTsP4OBp6V/IixhF9VbAGAANncGcAmQINBE+tjmgBEAC7pKK78t89DW7mvMoSgiScLfPNF8/TSF380is0hFRL3dOmcXEfNsX26jtv8bdvvtkElB1fPwOntmqSAsrLOuURVQ6GSxH7IDU5QFfaTIsudtLR5YTlC3ZuOTOb1HWEK26fDRXuIWjhFDXJH3KLv+rSrq0+x7ZtH++CHq5XJWk7VUh/wWcGxZefs7+1HTivymhjXCOwQvqblzZ5MAec9i4QIXxkqX1HY7ryxGVdjj9lApOnoU5EcSYr08cm7xQEgrdDLAZFQxDYBLDuV6E6jKEfAfwZINSEe4Ocm82vtCF5K0HiwhFU09ky2yogbMuTTi2f8ibN8SbbhZDJlDPd2ZkkpsKNfIALmOiPhHGvXGmtg6FdzRUOSGirSm8tcakpS+d0/IElbD453sksxg6s3cTs7Q+PudaccyQ0BqatMnzmfxCVOotT65kVnmz2P+4Q0gRSQ/Zi9Inz+OrzWxtn6/Tdw+FMUwvBccxW1r88k6uVLz23jW/8jOuwnUp4JKmZta/U2UZKTyPyrvTYhp/zK332BEnxiRY4ZfQjA4Iwlw00l4pYBDLLc6TFJtLbDv859UCisXa8MtWYWrlM3YfGFs9k1WemML8u79g2DK8g3VPkD94Q5anqufEGm74K/keOmss8cQoBX9VPFMpS1mFCT+2UdGP0UvMlADct0aFnAwtb9QARAQABsAwAAGdwZwEAAAAAAAC0QVVidW50dSBDRCBJbWFnZSBBdXRvbWF0aWMgU2lnbmluZyBLZXkgKDIwMTIpIDxjZGltYWdlQHVidW50dS5jb20+sAwAAGdwZwIAAAAAAACJAjcEEwEKACEFAk+tjmgCGwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AACgkQ2Uqj8O/iEJJIQBAAiY2WV7gGmzKwuPWedh8sFWYqSYKFebnzIti0GDJMhilUEPxO+JVI3HDJm0OI9NIoU2Afhf4tvQMX2ryZ5UqVoJsIzzuGGOY76KFIl0JlR19dKDNcN/mPcEnJnlGNyIU7cIhWgSa+k2e0bzk4P6W0NBr88TZZEqG7qhQmdNt5nJdmOzpGNT2YMYi2nw+kcdjv4HJUD7OGHx6PGykQOKNdO9NpxPGBPnYsSIAEMOu08YauYnTcbFqbnSqvSdXy4JxM+4vQCVDn9drIPV+2b6V2d0LzFeYjrywOA0S7/RyMcs+9F6nmpEvrs3yl7gjM4XVEyG/7TQAjQd+/q3iKnT7MlBd7cVclmi9YzJEbL+te8igImLzzcDA0b62yoieCHJ3eLT85qs+RwRVMlC57NycyTY6YCgryxoVavpVbHaTJaUMRBuf24cyYAdY6yG5HDkn50NctBr/QiLXpftatARzJ9HT1VmjXymBRrM+IoFvro//wtPf4LRjJu/D0H46hKEdo/02pv7ZrnMUit99cn5uWoNgkGBgt27MHyCPuBGp1/XTf0Rt/9nbEsmK7lqUyEBul2u/gGbWAQxFzWKL4HSbV1slLVtF+0eryI4dR2Hq93Ueoryfqv21hmOOcx3jQTVN94ZZ5cRBDYn90Wf4/8N0oxq7UkCuvZmjUeqJ6uPdnvuuwBgADZ3BnAJkCDQRbn8HaARAA7/xscrcfy3El2LjNDMCqI2wcnvNbNBtZxMfpc+lQFKSFGZ25KnVwRwvncKxkvwnni7gIz0S1PAKMRP4472VafMRRhFh2HZJalxmf4CXz+Xd3yFAbWR2RCZfAfJvaTB3/wEEHbAvmM4s0hubeTIZ6LcNOOC17XRBJMdreic9Dhq4fuSKMal+6WYqugr9fQaIWlIqCjHaexEukWHze6Jeh0ixZazF7VX4f4o6TfY92YVRlXkQvJCh0LCeT5CG5r8QYlIe0iZn2VMdCEITTGgx133WQBjbZ4c8zUXm9RajS0lZK0vz57AEMzIRtQQ5tlTkheuI3myl33xajOS10UE3qky7I1G266kerPxgjvFBe431I+iO7Wi8oJrBzvyQ+I6SkQtIG6VAX2oici77nqcd5FqKi97DdC4ZTCPNPnwOxk76DseLaalZc5ROk2o2Lvo31t0KThUuXsBDHS9uoc8bGYP4Hmb02wK3D/jrCSkZob+JDaOgMnch0P92Vf391/Zk9/0jy2yWrppIKd2M3ereT3gbvmUJP5jeVjTbmooTRFe5ZW9WYb2NBcbvQVXfwTZdK87sad6yIpwdk19kgoO8BOcV5MF7kP9nkwxNL9B5Rp7ZLmYxqMA2ZMR2UEsWVTs3WQkVWl/1hBS6SmtgEKcOUSa0OKGfzn4n18icz9u6NN8EAEQEAAbAMAABncGcBAAAAAAAAtEJVYnVudHUgQXJjaGl2ZSBBdXRvbWF0aWMgU2lnbmluZyBLZXkgKDIwMTgpIDxmdHBtYXN0ZXJAdWJ1bnR1LmNvbT6wDAAAZ3BnAgAAAAAAAIkCOAQTAQoAIgUCW5/B2gIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQhxkg0ZkbyTwscxAApLZyfHP/lZqgI5YCt/mDpQdt44KBzkMGbSEK4UNlZa/jbtoZ6LcI+4vDQMYsJdl3Jzl2oTya+MyU6aYAoqWPW4aDdNgJtBaNY94ycE9luQWCRmhcnv/oIHttZGG3WwfOm3UtNn5JgPA7AnrxBGnsNFpmX1jpCJRt66GrYNRxOh9VsHFuGtyQ3hm14u+b7+cb2b9yKilzrovBF2TGp8nfYLKr7VNLlVogkMbsNbOIb4pu7qoIMzhA2WDcsfunXgKtHEBtziW+iFGCxXh5Cqwhx0WS5Vjkc8+PYrxOqljpJN7waHRqmsbVFXxkprLcpIymfJXV8Aqfh8z1vKIvNACi8LQtn0wwyysBL/jkC8LcgQpJKGMsWfVfV1EKI7r/uOZkShm0CnneGR/xIwGyLvyFU2sG6ZnB8h0EDW/bb4tjjFAryrhcKhFwD0b6m/NT1hVbtxGcNlkaXS7A7DvP0+RAEXkoUqNYPPh8KT4rr5i0ami8Yp6QYFvwjsQDpSm8+CoD9B0jS3UgE/Q3TpFByzV9RoBAS3PoMbLnORGFHikZJmf50URPs90CMQrzjLsF1ji35TWNxIi8GPQXYHsvBEvvEalKkgqL96QBcuzXXtu8UdoK+ZRg3slWnUYyZUXGEh3HoIWbd/EbxCM1vm16t79ior646BxefLVSXC0JTOWtJo+wBgADZ3BnAA==" - // Signing key ID: 871920D1991BC93C (Ubuntu Archive Automatic Signing Key 2018) - private val KEY_ID_PATTERN = byteArrayOf( - 0x87.toByte(), 0x19.toByte(), 0x20.toByte(), 0xd1.toByte(), - 0x99.toByte(), 0x1b.toByte(), 0xc9.toByte(), 0x3c.toByte() + // Official SHA-256 of bundled ubuntu-archive-keyring.gpg (2012 Archive, 2012 CD Image, and 2018 Archive keys) + const val EXPECTED_UBUNTU_KEYRING_SHA256_HEX = + "80a36b0a6de2f69f49d2df75ef473ccde121e9e190b9ea01d20a4f63778d5c31" + + // Upstream Ubuntu 24.04 package /usr/share/keyrings/ubuntu-archive-keyring.gpg SHA-256 (without gpg ring trust packets) + private const val UPSTREAM_UBUNTU_KEYRING_SHA256_HEX = + "3622da4fd7bc1efd1ad2eb4ba4ed7dc8ea2efca5dbfdc3cab0ee1ee3dc84d3fc" + + internal val EXPECTED_UBUNTU_KEYRING_SHA256: ByteArray = + hexToBytes(EXPECTED_UBUNTU_KEYRING_SHA256_HEX) + + private val KNOWN_VALID_KEYRING_DIGESTS: List = listOf( + EXPECTED_UBUNTU_KEYRING_SHA256, + hexToBytes(UPSTREAM_UBUNTU_KEYRING_SHA256_HEX), + hexToBytes("5ebbeeb474034b1fa7e50abbe6f136e177fc826219e01f314b3623f7b3097e96"), // ubuntu-keyring-2018-archive.gpg + hexToBytes("192b3782ba2e00e05b6521371fbe67847efad3fdd1cfb87621882d833c8703fa") // ubuntu-keyring-2012-cdimage.gpg + ) + + private val MANAGED_TRUSTED_KEYRINGS = setOf( + "ubuntu-archive-keyring.gpg", + "ubuntu-keyring-2018-archive.gpg", + "ubuntu-keyring-2012-cdimage.gpg" ) + private const val DEFAULT_UBUNTU_SOURCES = + "Types: deb\n" + + "URIs: https://ports.ubuntu.com/ubuntu-ports/\n" + + "Suites: noble noble-updates noble-backports\n" + + "Components: main restricted universe multiverse\n" + + "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\n" + + "Types: deb\n" + + "URIs: https://ports.ubuntu.com/ubuntu-ports/\n" + + "Suites: noble-security\n" + + "Components: main restricted universe multiverse\n" + + "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n" + + private fun hexToBytes(hex: String): ByteArray { + val clean = hex.trim() + return ByteArray(clean.length / 2) { i -> + clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } + + internal fun sha256(bytes: ByteArray): ByteArray { + return MessageDigest.getInstance("SHA-256").digest(bytes) + } + + @SuppressLint("NewApi") + internal fun decodeEmbeddedKeyring(): ByteArray { + if (Build.VERSION.SDK_INT > 0) { + try { + val decoded = Base64.decode(UBUNTU_ARCHIVE_KEYRING_BASE64, Base64.DEFAULT) + if (decoded != null && decoded.isNotEmpty()) { + return decoded + } + } catch (_: Throwable) {} + } + return java.util.Base64.getDecoder().decode(UBUNTU_ARCHIVE_KEYRING_BASE64) + } + + private fun isValidOpenPgpPublicKeyring(bytes: ByteArray): Boolean { + if (bytes.size < 256) return false + val first = bytes[0].toInt() and 0xFF + if ((first and 0x80) == 0) return false + val tag = if ((first and 0x40) == 0) { + (first ushr 2) and 0x0F + } else { + first and 0x3F + } + return tag == 6 + } + + internal fun loadKeyringBytes(context: Context?, root: File? = null): ByteArray { + val embedded = decodeEmbeddedKeyring() + val assetBytes = try { + context?.assets?.open("ubuntu-archive-keyring.gpg")?.use { it.readBytes() } + } catch (_: Exception) { + null + } + if (assetBytes != null && assetBytes.isNotEmpty()) { + val assetDigest = sha256(assetBytes) + if (KNOWN_VALID_KEYRING_DIGESTS.any { MessageDigest.isEqual(assetDigest, it) } || + isValidOpenPgpPublicKeyring(assetBytes) + ) { + return assetBytes + } + } + if (root != null) { + val existingShare = File(root, "usr/share/keyrings/ubuntu-archive-keyring.gpg") + if (hasExpectedKeyringDigest(existingShare)) { + return try { + existingShare.readBytes() + } catch (_: Exception) { + embedded + } + } + } + return embedded + } + + @JvmOverloads + fun hasExpectedKeyringDigest( + f: File, + expectedDigest: ByteArray? = null + ): Boolean { + if (!f.exists() || !f.isFile || f.length() <= 0L) return false + return try { + val actualDigest = sha256(f.readBytes()) + if (expectedDigest != null) { + MessageDigest.isEqual(actualDigest, expectedDigest) + } else { + KNOWN_VALID_KEYRING_DIGESTS.any { MessageDigest.isEqual(actualDigest, it) } + } + } catch (_: Exception) { + false + } + } + fun ensureAptSandbox(root: File) { try { val aptConfDir = File(root, "etc/apt/apt.conf.d") aptConfDir.mkdirs() val sbFile = File(aptConfDir, "01sandbox") - // Strict GPG verification: No insecure repository bypasses + // Android's Zygote already enforces a strict kernel seccomp-bpf filter on all app + // processes, and Cortex intercepts UID/GID/chown syscalls in userspace via LD_PRELOAD + // (libcortex-hook.so). Enabling APT's internal seccomp filter inside the unprivileged + // _apt method helper conflicts with Android's existing seccomp policy and causes + // acquire methods (http, https, gpgv) to crash with SIGSYS. + // Setting APT::Sandbox::Seccomp "false" only disables the redundant child-process + // syscall filter; repository and package authenticity remain strictly enforced via + // GPG signatures (Acquire::AllowInsecureRepositories "false", + // Acquire::AllowDowngradeToInsecureRepositories "false", + // APT::Get::AllowUnauthenticated "false"). sbFile.writeText( "APT::Sandbox::User \"root\";\n" + "APT::Sandbox::Seccomp \"false\";\n" + @@ -46,15 +171,27 @@ object AptConfigurator { " \"--force-unsafe-io\";\n" + "};\n" ) - sbFile.setReadable(true, true) + sbFile.setReadable(true, false) sbFile.setWritable(true, true) - try { android.system.Os.chmod(sbFile.absolutePath, 384) } catch (e: Exception) {} // 0600 + try { Os.chmod(sbFile.absolutePath, 420) } catch (_: Exception) {} // 0644 + + val sb99File = File(aptConfDir, "99sandbox") + sb99File.writeText( + "APT::Sandbox::User \"root\";\n" + + "APT::Sandbox::Seccomp \"false\";\n" + + "Acquire::AllowInsecureRepositories \"false\";\n" + + "Acquire::AllowDowngradeToInsecureRepositories \"false\";\n" + + "APT::Get::AllowUnauthenticated \"false\";\n" + ) + sb99File.setReadable(true, false) + sb99File.setWritable(true, true) + try { Os.chmod(sb99File.absolutePath, 420) } catch (_: Exception) {} // 0644 val dockerClean = File(aptConfDir, "docker-clean") dockerClean.writeText("# Disabled for Cortex\n") - dockerClean.setReadable(true, true) + dockerClean.setReadable(true, false) dockerClean.setWritable(true, true) - try { android.system.Os.chmod(dockerClean.absolutePath, 384) } catch (e: Exception) {} + try { Os.chmod(dockerClean.absolutePath, 420) } catch (_: Exception) {} // 0644 val aptPrefDir = File(root, "etc/apt/preferences.d") aptPrefDir.mkdirs() @@ -74,8 +211,9 @@ object AptConfigurator { "Pin: release *\n" + "Pin-Priority: -1\n" ) - cortexPins.setReadable(true, true) + cortexPins.setReadable(true, false) cortexPins.setWritable(true, true) + try { Os.chmod(cortexPins.absolutePath, 420) } catch (_: Exception) {} val dpkgStatusFile = File(root, "var/lib/dpkg/status") if (dpkgStatusFile.exists() && dpkgStatusFile.isFile) { @@ -112,7 +250,7 @@ object AptConfigurator { } if (updated) { dpkgStatusFile.writeText(newBlocks.joinToString("\n\n") + "\n") - dpkgStatusFile.setReadable(true, true) + dpkgStatusFile.setReadable(true, false) dpkgStatusFile.setWritable(true, true) } } catch (e: Exception) { @@ -129,8 +267,9 @@ object AptConfigurator { "force-unsafe-io\n" + "no-debsig\n" ) - dpkgCortex.setReadable(true, true) + dpkgCortex.setReadable(true, false) dpkgCortex.setWritable(true, true) + try { Os.chmod(dpkgCortex.absolutePath, 420) } catch (_: Exception) {} val profileD = File(root, "etc/profile.d") profileD.mkdirs() @@ -139,9 +278,14 @@ object AptConfigurator { "export DPKG_DEB_THREADS_MAX=1\n" + "export XZ_OPT=-T1\n" + "export XZ_DEFAULTS=-T1\n" + + "export TAR_OPTIONS=\"--no-same-owner\"\n" + "export DEBIAN_FRONTEND=noninteractive\n" + "export DEBCONF_FRONTEND=noninteractive\n" + "export DEBCONF_NONINTERACTIVE_SEEN=true\n" + + "unset PREFIX\n" + + "export GODEBUG=netdns=cgo\n" + + "shopt -s checkwinsize 2>/dev/null\n" + + "export PATH=\"/home/.local/bin:\$HOME/.local/bin:\$PATH\"\n" + "if [ -n \"\$CORTEX_ROOT\" ]; then\n" + " export SSL_CERT_FILE=\"\$CORTEX_ROOT/etc/ssl/certs/ca-certificates.crt\"\n" + " export SSL_CERT_DIR=\"\$CORTEX_ROOT/etc/ssl/certs:/system/etc/security/cacerts\"\n" + @@ -152,10 +296,19 @@ object AptConfigurator { " export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt\n" + " export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt\n" + "fi\n" + - "export TZDIR=/usr/share/zoneinfo\n" + "export TZDIR=/usr/share/zoneinfo\n" + + "if [ -d /etc/cortex/autostart ]; then\n" + + " for s in /etc/cortex/autostart/*; do\n" + + " if [ -f \"\$s\" ]; then\n" + + " sname=\"\$(basename \"\$s\")\"\n" + + " service \"\$sname\" start >/dev/null 2>&1 || true\n" + + " fi\n" + + " done\n" + + "fi\n" ) - profileCortex.setReadable(true, true) + profileCortex.setReadable(true, false) profileCortex.setWritable(true, true) + try { Os.chmod(profileCortex.absolutePath, 420) } catch (_: Exception) {} val usrSbinDir = File(root, "usr/sbin") usrSbinDir.mkdirs() @@ -167,18 +320,18 @@ object AptConfigurator { policyFile.writeText(policyScript) policyFile.setReadable(true, true) policyFile.setExecutable(true, true) - try { android.system.Os.chmod(policyFile.absolutePath, 448) } catch (e: Exception) {} // 0700 + try { Os.chmod(policyFile.absolutePath, 448) } catch (_: Exception) {} // 0700 listOf("ldconfig", "start-stop-daemon").forEach { name -> val f = File(usrSbinDir, name) f.writeText(dummyExitZero) f.setReadable(true, true) f.setExecutable(true, true) - try { android.system.Os.chmod(f.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(f.absolutePath, 448) } catch (_: Exception) {} } val sbinDir = File(root, "sbin") - if (sbinDir.exists() && !java.nio.file.Files.isSymbolicLink(sbinDir.toPath())) { + if (sbinDir.exists() && !ElfLinkerPatcher.isSymlink(sbinDir)) { listOf("policy-rc.d", "ldconfig", "start-stop-daemon").forEach { name -> try { val src = File(usrSbinDir, name) @@ -186,8 +339,8 @@ object AptConfigurator { src.copyTo(dst, overwrite = true) dst.setReadable(true, true) dst.setExecutable(true, true) - android.system.Os.chmod(dst.absolutePath, 448) - } catch (e: Exception) {} + Os.chmod(dst.absolutePath, 448) + } catch (_: Exception) {} } } @@ -204,22 +357,39 @@ object AptConfigurator { val sourcesDir = File(root, "etc/apt/sources.list.d") sourcesDir.mkdirs() val ubuntuSources = File(sourcesDir, "ubuntu.sources") - // Secure repository configuration with Signed-By GPG verification - ubuntuSources.writeText( - "Types: deb\n" + - "URIs: http://ports.ubuntu.com/ubuntu-ports/\n" + - "Suites: noble noble-updates noble-backports\n" + - "Components: main restricted universe multiverse\n" + - "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\n" + - "Types: deb\n" + - "URIs: http://ports.ubuntu.com/ubuntu-ports/\n" + - "Suites: noble-security\n" + - "Components: main restricted universe multiverse\n" + - "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n" - ) - ubuntuSources.setReadable(true, true) + + val existing = if (ubuntuSources.exists() && ubuntuSources.isFile) { + try { ubuntuSources.readText() } catch (_: Exception) { "" } + } else { + "" + } + + val updatedContent = when { + existing.contains("http://ports.ubuntu.com/ubuntu-ports") -> { + val upgraded = existing + .replace("http://ports.ubuntu.com/ubuntu-ports/", "https://ports.ubuntu.com/ubuntu-ports/") + .replace("http://ports.ubuntu.com/ubuntu-ports", "https://ports.ubuntu.com/ubuntu-ports") + .replace("http://archive.ubuntu.com/ubuntu", "https://archive.ubuntu.com/ubuntu") + if (upgraded.contains("Suites:") && upgraded.contains("https://") && !upgraded.contains("http://")) { + upgraded + } else { + DEFAULT_UBUNTU_SOURCES + } + } + existing.contains("Suites:") && existing.contains("https://") && !existing.contains("http://") -> { + existing + } + else -> { + DEFAULT_UBUNTU_SOURCES + } + } + + if (existing != updatedContent) { + ubuntuSources.writeText(updatedContent) + } + ubuntuSources.setReadable(true, false) ubuntuSources.setWritable(true, true) - try { android.system.Os.chmod(ubuntuSources.absolutePath, 384) } catch (e: Exception) {} + try { Os.chmod(ubuntuSources.absolutePath, 420) } catch (_: Exception) {} // 0644 val sourcesList = File(root, "etc/apt/sources.list") if (sourcesList.exists()) { @@ -239,58 +409,24 @@ object AptConfigurator { val aptKeyrings = File(root, "etc/apt/keyrings") aptKeyrings.mkdirs() - // 1. Get official verified keyring bytes - val keyBytes: ByteArray = try { - context?.assets?.open("ubuntu-archive-keyring.gpg")?.use { it.readBytes() } - } catch (e: Exception) { - null - }?.takeIf { it.isNotEmpty() } ?: Base64.decode(UBUNTU_ARCHIVE_KEYRING_BASE64, Base64.DEFAULT) - - fun containsKeyId(f: File): Boolean { - if (!f.exists() || f.length() < KEY_ID_PATTERN.size) return false - return try { - val content = f.readBytes() - var found = false - for (i in 0..(content.size - KEY_ID_PATTERN.size)) { - var match = true - for (j in KEY_ID_PATTERN.indices) { - if (content[i + j] != KEY_ID_PATTERN[j]) { - match = false - break - } - } - if (match) { - found = true - break - } - } - found - } catch (e: Exception) { - false - } - } + val keyBytes = loadKeyringBytes(context, root) + val expectedDigest = sha256(keyBytes) val targetKeyrings = listOf( File(shareKeyrings, "ubuntu-archive-keyring.gpg"), - File(shareKeyrings, "ubuntu-keyring-2018-archive.gpg"), - File(aptKeyrings, "ubuntu-archive-keyring.gpg"), - File(aptKeyrings, "ubuntu-keyring-2018-archive.gpg"), - File(trustedD, "ubuntu-archive-keyring.gpg"), - File(trustedD, "ubuntu-keyring-2018-archive.gpg"), - File(root, "etc/apt/trusted.gpg"), - File(root, "etc/gnupg/trustedkeys.gpg"), - File(root, "home/.gnupg/trustedkeys.gpg"), - File(root, "root/.gnupg/trustedkeys.gpg") + File(trustedD, "ubuntu-archive-keyring.gpg") ) for (target in targetKeyrings) { - if (!containsKeyId(target)) { + val isValid = hasExpectedKeyringDigest(target, expectedDigest) || + hasExpectedKeyringDigest(target, EXPECTED_UBUNTU_KEYRING_SHA256) + if (!isValid) { try { target.parentFile?.mkdirs() target.outputStream().use { it.write(keyBytes) } - target.setReadable(true, true) + target.setReadable(true, false) target.setWritable(true, true) - try { android.system.Os.chmod(target.absolutePath, 384) } catch (e: Exception) {} + try { Os.chmod(target.absolutePath, 420) } catch (_: Exception) {} // 0644 Log.i(TAG, "Wrote verified Ubuntu archive keyring to ${target.absolutePath}") } catch (e: Exception) { Log.e(TAG, "Failed writing keyring to ${target.absolutePath}", e) @@ -298,14 +434,25 @@ object AptConfigurator { } } - // Clean up any obsolete/corrupted keyrings from trusted.gpg.d + // Clean up only managed Ubuntu keyrings in trusted.gpg.d if corrupted; + // never delete third-party or user-managed .gpg / .asc files. try { trustedD.listFiles()?.forEach { file -> - if (!containsKeyId(file)) { - file.delete() + if (file.isFile && file.name in MANAGED_TRUSTED_KEYRINGS) { + val isValid = hasExpectedKeyringDigest(file, expectedDigest) || + hasExpectedKeyringDigest(file, EXPECTED_UBUNTU_KEYRING_SHA256) + if (!isValid) { + file.delete() + if (file.name == "ubuntu-archive-keyring.gpg") { + file.outputStream().use { it.write(keyBytes) } + file.setReadable(true, false) + file.setWritable(true, true) + try { Os.chmod(file.absolutePath, 420) } catch (_: Exception) {} // 0644 + } + } } } - } catch (e: Exception) {} + } catch (_: Exception) {} // Copy secondary keyrings from assets if available to usr/share/keyrings and etc/apt/keyrings if (context != null) { @@ -320,19 +467,20 @@ object AptConfigurator { val targets = listOf(File(shareKeyrings, name), File(aptKeyrings, name)) context.assets.open(name).use { inStream -> val bytes = inStream.readBytes() - if (bytes.isNotEmpty()) { + if (bytes.isNotEmpty() && isValidOpenPgpPublicKeyring(bytes)) { + val assetDigest = sha256(bytes) for (target in targets) { - if (!target.exists() || target.length() != bytes.size.toLong()) { + if (!hasExpectedKeyringDigest(target, assetDigest)) { target.parentFile?.mkdirs() target.outputStream().use { it.write(bytes) } - target.setReadable(true, true) + target.setReadable(true, false) target.setWritable(true, true) - try { android.system.Os.chmod(target.absolutePath, 384) } catch (e: Exception) {} + try { Os.chmod(target.absolutePath, 420) } catch (_: Exception) {} } } } } - } catch (e: Exception) {} + } catch (_: Exception) {} } } @@ -341,7 +489,7 @@ object AptConfigurator { if (partialLists.exists()) { try { partialLists.listFiles()?.forEach { it.delete() } - } catch (e: Exception) {} + } catch (_: Exception) {} } } catch (e: Exception) { Log.e(TAG, "Failed to ensure keyrings", e) diff --git a/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt b/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt index 4a17cc0..8149a6d 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt @@ -284,14 +284,28 @@ object BootstrapManager { val localBin = File(home, ".local/bin") localBin.mkdirs() val localBinBashrc = File(localBin, "bashrc") - localBinBashrc.writeText("#!/bin/bash\n. \"" + d + "HOME/.bashrc\"\n") + localBinBashrc.writeText( + "#!/bin/bash\n" + + "if (return 0 2>/dev/null); then\n" + + " . \"" + d + "HOME/.bashrc\"\n" + + "else\n" + + " exec \"" + d + "{SHELL:-/bin/bash}\" -l\n" + + "fi\n" + ) localBinBashrc.setExecutable(true, true) localBinBashrc.setReadable(true, true) localBinBashrc.setWritable(true, true) try { android.system.Os.chmod(localBinBashrc.absolutePath, 448) } catch (e: Exception) {} // 0700 val localBinProfile = File(localBin, "profile") - localBinProfile.writeText("#!/bin/bash\n. \"" + d + "HOME/.profile\"\n") + localBinProfile.writeText( + "#!/bin/bash\n" + + "if (return 0 2>/dev/null); then\n" + + " . \"" + d + "HOME/.profile\"\n" + + "else\n" + + " exec \"" + d + "{SHELL:-/bin/bash}\" -l\n" + + "fi\n" + ) localBinProfile.setExecutable(true, true) localBinProfile.setReadable(true, true) localBinProfile.setWritable(true, true) @@ -443,7 +457,7 @@ object BootstrapManager { val f = File(root, sub) if (f.exists() && f.isDirectory) { try { - if (!java.nio.file.Files.isSymbolicLink(f.toPath())) { + if (!ElfLinkerPatcher.isSymlink(f)) { f.deleteRecursively() } } catch (e: Exception) { @@ -819,16 +833,14 @@ object BootstrapManager { val targetHook = File(root, "usr/lib/libcortex-hook.so") targetHook.parentFile?.mkdirs() - val targetPath = targetHook.toPath() // Clean up any broken/circular symlinks created by older buggy releases - if (java.nio.file.Files.isSymbolicLink(targetPath)) { - java.nio.file.Files.deleteIfExists(targetPath) + if (ElfLinkerPatcher.isSymlink(targetHook)) { + ElfLinkerPatcher.deleteIfExists(targetHook) } val tmpHook = File(root, "usr/lib/libcortex-hook.so.tmp") - val tmpPath = tmpHook.toPath() - if (java.nio.file.Files.isSymbolicLink(tmpPath)) { - java.nio.file.Files.deleteIfExists(tmpPath) + if (ElfLinkerPatcher.isSymlink(tmpHook)) { + ElfLinkerPatcher.deleteIfExists(tmpHook) } context.assets.open(hookAssetName).use { inStream -> @@ -842,20 +854,7 @@ object BootstrapManager { tmpHook.setWritable(true, true) try { android.system.Os.chmod(tmpHook.absolutePath, 448) } catch (e: Exception) {} - try { - java.nio.file.Files.move( - tmpPath, - targetPath, - java.nio.file.StandardCopyOption.REPLACE_EXISTING, - java.nio.file.StandardCopyOption.ATOMIC_MOVE - ) - } catch (e: Exception) { - java.nio.file.Files.move( - tmpPath, - targetPath, - java.nio.file.StandardCopyOption.REPLACE_EXISTING - ) - } + ElfLinkerPatcher.atomicReplace(tmpHook, targetHook) targetHook.setExecutable(true, true) targetHook.setReadable(true, true) targetHook.setWritable(true, true) @@ -863,17 +862,13 @@ object BootstrapManager { } val libDir = File(root, "lib") - if (libDir.exists() && !java.nio.file.Files.isSymbolicLink(libDir.toPath())) { + if (libDir.exists() && !ElfLinkerPatcher.isSymlink(libDir)) { val libHook = File(libDir, "libcortex-hook.so") - val libHookPath = libHook.toPath() try { - if (java.nio.file.Files.isSymbolicLink(libHookPath)) { - java.nio.file.Files.deleteIfExists(libHookPath) - } - if (libHook.exists()) { - libHook.delete() + if (ElfLinkerPatcher.isSymlink(libHook) || libHook.exists()) { + ElfLinkerPatcher.deleteIfExists(libHook) } - android.system.Os.symlink(targetHook.absolutePath, libHook.absolutePath) + ElfLinkerPatcher.createSymlink(targetHook.absolutePath, libHook) } catch (e: Exception) {} } } catch (e: Exception) { @@ -1010,9 +1005,7 @@ object BootstrapManager { val etcDir = File(root, "etc") etcDir.mkdirs() val resolvFile = File(etcDir, "resolv.conf") - try { - java.nio.file.Files.deleteIfExists(resolvFile.toPath()) - } catch (_: Exception) { + if (!ElfLinkerPatcher.deleteIfExists(resolvFile)) { resolvFile.delete() } val resolvConf = selectedDns.joinToString("\n") { "nameserver $it" } + "\noptions timeout:1 attempts:2 rotate\n" @@ -1199,7 +1192,7 @@ object BootstrapManager { localBin.mkdirs() val binDir = File(root, "bin") - val isBinDirSymlink = binDir.exists() && java.nio.file.Files.isSymbolicLink(binDir.toPath()) + val isBinDirSymlink = binDir.exists() && ElfLinkerPatcher.isSymlink(binDir) // 1. awk guarantee: find mawk or gawk and copy as real ELF executable val mawkCandidates = listOf( @@ -1229,20 +1222,7 @@ object BootstrapManager { tmp.setWritable(true, true) tmp.setExecutable(true, true) try { android.system.Os.chmod(tmp.absolutePath, 448) } catch (e: Exception) {} - try { - java.nio.file.Files.move( - tmp.toPath(), - target.toPath(), - java.nio.file.StandardCopyOption.REPLACE_EXISTING, - java.nio.file.StandardCopyOption.ATOMIC_MOVE - ) - } catch (e: Exception) { - java.nio.file.Files.move( - tmp.toPath(), - target.toPath(), - java.nio.file.StandardCopyOption.REPLACE_EXISTING - ) - } + ElfLinkerPatcher.atomicReplace(tmp, target) target.setReadable(true, true) target.setWritable(true, true) target.setExecutable(true, true) @@ -1282,20 +1262,7 @@ object BootstrapManager { tmp.setWritable(true, true) tmp.setExecutable(true, true) try { android.system.Os.chmod(tmp.absolutePath, 448) } catch (e: Exception) {} - try { - java.nio.file.Files.move( - tmp.toPath(), - target.toPath(), - java.nio.file.StandardCopyOption.REPLACE_EXISTING, - java.nio.file.StandardCopyOption.ATOMIC_MOVE - ) - } catch (e: Exception) { - java.nio.file.Files.move( - tmp.toPath(), - target.toPath(), - java.nio.file.StandardCopyOption.REPLACE_EXISTING - ) - } + ElfLinkerPatcher.atomicReplace(tmp, target) target.setReadable(true, true) target.setWritable(true, true) target.setExecutable(true, true) diff --git a/app/src/main/java/org/cortex/terminal/runtime/ElfLinkerPatcher.kt b/app/src/main/java/org/cortex/terminal/runtime/ElfLinkerPatcher.kt index fb10221..ccd82a0 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/ElfLinkerPatcher.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/ElfLinkerPatcher.kt @@ -1,14 +1,24 @@ package org.cortex.terminal.runtime +import android.annotation.SuppressLint +import android.os.Build +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants import android.util.Log import java.io.File -import java.nio.file.Files -import java.nio.file.Paths -import java.nio.file.StandardCopyOption object ElfLinkerPatcher { private const val TAG = "ElfLinkerPatcher" + private const val ELFCLASS32 = 1 + private const val ELFCLASS64 = 2 + private const val ELFDATA2LSB = 1 + private const val EM_ARM = 40 + private const val EM_AARCH64 = 183 + private const val PT_LOAD = 1L + private const val PF_X = 0x1L + private val LINKER_CANDIDATES = listOf( "usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1", "usr/lib/aarch64-linux-gnu/ld-2.39.so", @@ -24,6 +34,172 @@ object ElfLinkerPatcher { "usr/lib/ld-linux-armhf.so.3" ) + @SuppressLint("NewApi") + internal fun isSymlink(file: File): Boolean { + if (Build.VERSION.SDK_INT > 0) { + return try { + val st = Os.lstat(file.absolutePath) + if (st != null) { + OsConstants.S_ISLNK(st.st_mode) + } else { + java.nio.file.Files.isSymbolicLink(file.toPath()) + } + } catch (_: ErrnoException) { + false + } catch (_: Throwable) { + try { + java.nio.file.Files.isSymbolicLink(file.toPath()) + } catch (_: Throwable) { + false + } + } + } + return try { + java.nio.file.Files.isSymbolicLink(file.toPath()) + } catch (_: Throwable) { + false + } + } + + @SuppressLint("NewApi") + internal fun readSymlink(file: File): String? { + if (Build.VERSION.SDK_INT > 0) { + return try { + val target = Os.readlink(file.absolutePath) + target ?: java.nio.file.Files.readSymbolicLink(file.toPath()).toString() + } catch (_: ErrnoException) { + null + } catch (_: Throwable) { + try { + java.nio.file.Files.readSymbolicLink(file.toPath()).toString() + } catch (_: Throwable) { + null + } + } + } + return try { + java.nio.file.Files.readSymbolicLink(file.toPath()).toString() + } catch (_: Throwable) { + null + } + } + + @SuppressLint("NewApi") + internal fun createSymlink(target: String, linkFile: File): Boolean { + if (Build.VERSION.SDK_INT > 0) { + return try { + Os.symlink(target, linkFile.absolutePath) + true + } catch (_: ErrnoException) { + false + } catch (_: Throwable) { + try { + java.nio.file.Files.createSymbolicLink( + linkFile.toPath(), + java.nio.file.Paths.get(target) + ) + true + } catch (_: Throwable) { + false + } + } + } + return try { + java.nio.file.Files.createSymbolicLink( + linkFile.toPath(), + java.nio.file.Paths.get(target) + ) + true + } catch (_: Throwable) { + false + } + } + + @SuppressLint("NewApi") + internal fun deleteIfExists(file: File): Boolean { + if (Build.VERSION.SDK_INT > 0) { + return try { + Os.remove(file.absolutePath) + true + } catch (_: ErrnoException) { + false + } catch (_: Throwable) { + try { + java.nio.file.Files.deleteIfExists(file.toPath()) + } catch (_: Throwable) { + file.delete() + } + } + } + return try { + java.nio.file.Files.deleteIfExists(file.toPath()) + } catch (_: Throwable) { + file.delete() + } + } + + @SuppressLint("NewApi") + internal fun atomicReplace(src: File, dst: File): Boolean { + if (Build.VERSION.SDK_INT > 0) { + return try { + Os.rename(src.absolutePath, dst.absolutePath) + true + } catch (_: ErrnoException) { + try { + deleteIfExists(dst) + if (src.renameTo(dst)) { + true + } else { + src.copyTo(dst, overwrite = true) + src.delete() + true + } + } catch (_: Throwable) { + false + } + } catch (_: Throwable) { + moveOnHostFallback(src, dst) + } + } + return moveOnHostFallback(src, dst) + } + + @SuppressLint("NewApi") + private fun moveOnHostFallback(src: File, dst: File): Boolean { + return try { + try { + java.nio.file.Files.move( + src.toPath(), + dst.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: Exception) { + java.nio.file.Files.move( + src.toPath(), + dst.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING + ) + } + true + } catch (_: Throwable) { + false + } + } + + fun isContainedInRoot(candidate: File, rootCanonicalPath: String): Boolean { + return try { + val candidateCanonical = candidate.canonicalFile.path + val normalizedRoot = rootCanonicalPath.trimEnd(File.separatorChar, '/', '\\') + val normalizedCand = candidateCanonical.trimEnd(File.separatorChar, '/', '\\') + normalizedCand == normalizedRoot || + normalizedCand.startsWith(normalizedRoot + File.separator) || + normalizedCand.startsWith("$normalizedRoot/") + } catch (_: Exception) { + false + } + } + fun hasDynamicLinker(root: File): Boolean { return LINKER_CANDIDATES.any { File(root, it).exists() } } @@ -31,7 +207,7 @@ object ElfLinkerPatcher { fun findDynamicLinker(root: File): File? { for (cand in LINKER_CANDIDATES) { val f = File(root, cand) - if (f.exists() && (f.isFile || Files.isSymbolicLink(f.toPath()))) { + if (f.exists() && (f.isFile || isSymlink(f))) { return f } } @@ -47,18 +223,16 @@ object ElfLinkerPatcher { ld64Real.setExecutable(true, true) ld64Real.setReadable(true, true) ld64Real.setWritable(true, true) - try { android.system.Os.chmod(ld64Real.absolutePath, 448) } catch (e: Exception) {} - if (!ld64Link.exists()) { - try { - android.system.Os.symlink("ld-2.39.so", ld64Link.absolutePath) - } catch (e: Exception) { - try { ld64Real.copyTo(ld64Link, overwrite = true) } catch (e2: Exception) {} + try { Os.chmod(ld64Real.absolutePath, 448) } catch (_: Exception) {} + if (!ld64Link.exists() && !isSymlink(ld64Link)) { + if (!createSymlink("ld-2.39.so", ld64Link)) { + try { ld64Real.copyTo(ld64Link, overwrite = true) } catch (_: Exception) {} } } ld64Link.setExecutable(true, true) ld64Link.setReadable(true, true) ld64Link.setWritable(true, true) - try { android.system.Os.chmod(ld64Link.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(ld64Link.absolutePath, 448) } catch (_: Exception) {} } // Check arm32 @@ -68,51 +242,45 @@ object ElfLinkerPatcher { ld32Real.setExecutable(true, true) ld32Real.setReadable(true, true) ld32Real.setWritable(true, true) - try { android.system.Os.chmod(ld32Real.absolutePath, 448) } catch (e: Exception) {} - if (!ld32Link.exists()) { - try { - android.system.Os.symlink("ld-2.39.so", ld32Link.absolutePath) - } catch (e: Exception) { - try { ld32Real.copyTo(ld32Link, overwrite = true) } catch (e2: Exception) {} + try { Os.chmod(ld32Real.absolutePath, 448) } catch (_: Exception) {} + if (!ld32Link.exists() && !isSymlink(ld32Link)) { + if (!createSymlink("ld-2.39.so", ld32Link)) { + try { ld32Real.copyTo(ld32Link, overwrite = true) } catch (_: Exception) {} } } ld32Link.setExecutable(true, true) ld32Link.setReadable(true, true) ld32Link.setWritable(true, true) - try { android.system.Os.chmod(ld32Link.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(ld32Link.absolutePath, 448) } catch (_: Exception) {} } // Ensure /lib has a direct link or copy to dynamic linker if /lib is not already symlinked val libDir = File(root, "lib") - if (libDir.exists() && !Files.isSymbolicLink(libDir.toPath())) { + if (libDir.exists() && !isSymlink(libDir)) { val direct64 = File(libDir, "ld-linux-aarch64.so.1") - if (!direct64.exists()) { + if (!direct64.exists() && !isSymlink(direct64)) { val src = if (ld64Link.exists()) ld64Link else if (ld64Real.exists()) ld64Real else null if (src != null) { - try { - android.system.Os.symlink(src.absolutePath, direct64.absolutePath) - } catch (e: Exception) { - try { src.copyTo(direct64, overwrite = true) } catch (e2: Exception) {} + if (!createSymlink(src.absolutePath, direct64)) { + try { src.copyTo(direct64, overwrite = true) } catch (_: Exception) {} } direct64.setExecutable(true, true) direct64.setReadable(true, true) direct64.setWritable(true, true) - try { android.system.Os.chmod(direct64.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(direct64.absolutePath, 448) } catch (_: Exception) {} } } val direct32 = File(libDir, "ld-linux-armhf.so.3") - if (!direct32.exists()) { + if (!direct32.exists() && !isSymlink(direct32)) { val src = if (ld32Link.exists()) ld32Link else if (ld32Real.exists()) ld32Real else null if (src != null) { - try { - android.system.Os.symlink(src.absolutePath, direct32.absolutePath) - } catch (e: Exception) { - try { src.copyTo(direct32, overwrite = true) } catch (e2: Exception) {} + if (!createSymlink(src.absolutePath, direct32)) { + try { src.copyTo(direct32, overwrite = true) } catch (_: Exception) {} } direct32.setExecutable(true, true) direct32.setReadable(true, true) direct32.setWritable(true, true) - try { android.system.Os.chmod(direct32.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(direct32.absolutePath, 448) } catch (_: Exception) {} } } } @@ -124,19 +292,31 @@ object ElfLinkerPatcher { fun patchAllDynamicLinkers(root: File) { if (!root.exists() || !root.isDirectory) return try { - root.walkTopDown().forEach { file -> - val name = file.name - if (file.isFile && (name.startsWith("ld-linux") || name.startsWith("libc.so") || name.startsWith("libc-"))) { - val target = if (Files.isSymbolicLink(file.toPath())) { - try { file.canonicalFile } catch (e: Exception) { file } - } else { - file - } - if (target.exists() && target.isFile) { - patchDynamicLinker(target) + val rootCanonicalPath = root.canonicalFile.path + root.walkTopDown() + .onEnter { dir -> + dir == root || isContainedInRoot(dir, rootCanonicalPath) + } + .forEach { file -> + val name = file.name + if (name.startsWith("ld-linux") || name.startsWith("ld-2.") || + name.startsWith("libc.so") || name.startsWith("libc-") + ) { + val target = try { + file.canonicalFile + } catch (_: Exception) { + null + } + if (target != null && + isContainedInRoot(target, rootCanonicalPath) && + target.exists() && + target.isFile && + !isSymlink(target) + ) { + patchDynamicLinker(target) + } } } - } } catch (e: Exception) { Log.e(TAG, "Error walking root to patch dynamic linkers and libc", e) } @@ -145,6 +325,7 @@ object ElfLinkerPatcher { fun fixAbsoluteSymlinks(root: File) { if (!root.exists() || !root.isDirectory) return try { + val rootCanonicalPath = root.canonicalFile.path val candidateDirs = listOf( File(root, "etc/alternatives"), File(root, "usr/bin"), @@ -154,41 +335,35 @@ object ElfLinkerPatcher { ) for (dir in candidateDirs) { if (!dir.exists() || !dir.isDirectory) continue - // Crucial: Skip directories that are themselves symlinks - if (Files.isSymbolicLink(dir.toPath())) continue + // Skip directories that are themselves symlinks or resolve outside root + if (isSymlink(dir) || !isContainedInRoot(dir, rootCanonicalPath)) continue dir.listFiles()?.forEach { file -> try { - val path = file.toPath() - if (Files.isSymbolicLink(path)) { - val target = Files.readSymbolicLink(path).toString() - if (target.startsWith("/")) { + if (isSymlink(file)) { + val target = readSymlink(file) + if (target != null && target.startsWith("/")) { val targetClean = target.trimStart('/') val targetInRoot = File(root, targetClean) - val relTarget = file.parentFile?.toPath()?.relativize(targetInRoot.toPath())?.toString() - if (relTarget != null) { - val tmpLink = File(file.parentFile, "${file.name}.ctx_link_tmp") - val tmpPath = tmpLink.toPath() - Files.deleteIfExists(tmpPath) - Files.createSymbolicLink(tmpPath, Paths.get(relTarget)) - try { - Files.move( - tmpPath, - path, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE - ) - } catch (e: Exception) { - Files.move( - tmpPath, - path, - StandardCopyOption.REPLACE_EXISTING - ) + if (!isContainedInRoot(File(rootCanonicalPath, targetClean), rootCanonicalPath)) { + return@forEach + } + val parentDir = file.parentFile ?: return@forEach + val relTarget = targetInRoot.absoluteFile + .toRelativeString(parentDir.absoluteFile) + .replace('\\', '/') + if (relTarget.isNotEmpty()) { + val tmpLink = File(parentDir, "${file.name}.ctx_link_tmp") + deleteIfExists(tmpLink) + if (createSymlink(relTarget, tmpLink)) { + if (!atomicReplace(tmpLink, file)) { + deleteIfExists(tmpLink) + } } } } } - } catch (e: Exception) { + } catch (_: Exception) { // ignore individual link error } } @@ -198,81 +373,218 @@ object ElfLinkerPatcher { } } + private fun readU16Le(bytes: ByteArray, offset: Int): Int { + return (bytes[offset].toInt() and 0xFF) or + ((bytes[offset + 1].toInt() and 0xFF) shl 8) + } + + private fun readU32Le(bytes: ByteArray, offset: Int): Long { + return (bytes[offset].toLong() and 0xFFL) or + ((bytes[offset + 1].toLong() and 0xFFL) shl 8) or + ((bytes[offset + 2].toLong() and 0xFFL) shl 16) or + ((bytes[offset + 3].toLong() and 0xFFL) shl 24) + } + + private fun readU64Le(bytes: ByteArray, offset: Int): Long { + return readU32Le(bytes, offset) or (readU32Le(bytes, offset + 4) shl 32) + } + + private fun matchesWord(bytes: ByteArray, offset: Int, pattern: ByteArray): Boolean { + return bytes[offset] == pattern[0] && + bytes[offset + 1] == pattern[1] && + bytes[offset + 2] == pattern[2] && + bytes[offset + 3] == pattern[3] + } + fun patchDynamicLinker(file: File) { - if (!file.exists() || !file.isFile) { + if (!file.exists() || !file.isFile || isSymlink(file)) { return } try { val bytes = file.readBytes() + val fileSize = bytes.size.toLong() + if (bytes.size < 52) return + + // Validate ELF magic: 0x7F 'E' 'L' 'F' + if (bytes[0] != 0x7f.toByte() || + bytes[1] != 'E'.code.toByte() || + bytes[2] != 'L'.code.toByte() || + bytes[3] != 'F'.code.toByte() + ) { + return + } + + val eiClass = bytes[4].toInt() and 0xFF + val eiData = bytes[5].toInt() and 0xFF + if (eiData != ELFDATA2LSB) return + if (eiClass != ELFCLASS64 && eiClass != ELFCLASS32) return + if (eiClass == ELFCLASS64 && bytes.size < 64) return + + val eMachine = readU16Le(bytes, 18) + if (eiClass == ELFCLASS64 && eMachine != EM_AARCH64) return + if (eiClass == ELFCLASS32 && eMachine != EM_ARM) return + + val execSegments = mutableListOf>() + + if (eiClass == ELFCLASS64) { + val ePhoff = readU64Le(bytes, 32) + val ePhentsize = readU16Le(bytes, 54) + val ePhnum = readU16Le(bytes, 56) + + if (ePhoff <= 0L || ePhentsize !in 56..4096 || ePhnum !in 1..1024) return + val tableBytes = ePhnum.toLong() * ePhentsize.toLong() + if (ePhoff > fileSize - tableBytes) return + + for (idx in 0 until ePhnum) { + val phOff = (ePhoff + idx.toLong() * ePhentsize.toLong()).toInt() + val pType = readU32Le(bytes, phOff) + val pFlags = readU32Le(bytes, phOff + 4) + val pOffset = readU64Le(bytes, phOff + 8) + val pFilesz = readU64Le(bytes, phOff + 32) + + if (pType == PT_LOAD) { + if (pOffset < 0L || pFilesz < 0L || pOffset > fileSize - pFilesz) return + if ((pFlags and PF_X) != 0L && pFilesz > 0L) { + execSegments.add(Pair(pOffset.toInt(), (pOffset + pFilesz).toInt())) + } + } + } + } else { + val ePhoff = readU32Le(bytes, 28) + val ePhentsize = readU16Le(bytes, 42) + val ePhnum = readU16Le(bytes, 44) + + if (ePhoff <= 0L || ePhentsize !in 32..4096 || ePhnum !in 1..1024) return + val tableBytes = ePhnum.toLong() * ePhentsize.toLong() + if (ePhoff > fileSize - tableBytes) return + + for (idx in 0 until ePhnum) { + val phOff = (ePhoff + idx.toLong() * ePhentsize.toLong()).toInt() + val pType = readU32Le(bytes, phOff) + val pOffset = readU32Le(bytes, phOff + 4) + val pFilesz = readU32Le(bytes, phOff + 16) + val pFlags = readU32Le(bytes, phOff + 24) + + if (pType == PT_LOAD) { + if (pOffset < 0L || pFilesz < 0L || pOffset > fileSize - pFilesz) return + if ((pFlags and PF_X) != 0L && pFilesz > 0L) { + execSegments.add(Pair(pOffset.toInt(), (pOffset + pFilesz).toInt())) + } + } + } + } + + if (execSegments.isEmpty()) return + var modified = false - // AArch64 syscall patterns - val svcAarch64 = byteArrayOf(0x01.toByte(), 0x00.toByte(), 0x00.toByte(), 0xd4.toByte()) - val nopAarch64 = byteArrayOf(0x1f.toByte(), 0x20.toByte(), 0x03.toByte(), 0xd5.toByte()) - val movEnosysAarch64 = byteArrayOf(0xa0.toByte(), 0x04.toByte(), 0x80.toByte(), 0x92.toByte()) // mov x0, #-38 - - // mov x8, #0x63 (syscall 99 set_robust_list) - val movX8Syscall99 = byteArrayOf(0x68.toByte(), 0x0c.toByte(), 0x80.toByte(), 0xd2.toByte()) - // mov x8, #0x1b3 (syscall 435 clone3) - val movX8Syscall435 = byteArrayOf(0x68.toByte(), 0x36.toByte(), 0x80.toByte(), 0xd2.toByte()) - // mov x8, #0x125 (syscall 293 rseq) - val movX8Syscall293 = byteArrayOf(0xa8.toByte(), 0x24.toByte(), 0x80.toByte(), 0xd2.toByte()) - - var pos = 0 - while (pos <= bytes.size - 4) { - val isSyscall99 = (bytes[pos] == movX8Syscall99[0] && bytes[pos + 1] == movX8Syscall99[1] && - bytes[pos + 2] == movX8Syscall99[2] && bytes[pos + 3] == movX8Syscall99[3]) - val isSyscall435 = (bytes[pos] == movX8Syscall435[0] && bytes[pos + 1] == movX8Syscall435[1] && - bytes[pos + 2] == movX8Syscall435[2] && bytes[pos + 3] == movX8Syscall435[3]) - val isSyscall293 = (bytes[pos] == movX8Syscall293[0] && bytes[pos + 1] == movX8Syscall293[1] && - bytes[pos + 2] == movX8Syscall293[2] && bytes[pos + 3] == movX8Syscall293[3]) - - if (isSyscall99 || isSyscall435 || isSyscall293) { - val replacement = if (isSyscall99) nopAarch64 else movEnosysAarch64 - val scName = if (isSyscall99) "99 set_robust_list" else if (isSyscall435) "435 clone3" else "293 rseq" - val searchEnd = minOf(bytes.size - 4, pos + 64) - for (i in (pos + 4)..searchEnd step 4) { - if (bytes[i] == svcAarch64[0] && - bytes[i + 1] == svcAarch64[1] && - bytes[i + 2] == svcAarch64[2] && - bytes[i + 3] == svcAarch64[3]) { - - replacement.copyInto(bytes, destinationOffset = i) - modified = true - Log.i(TAG, "Patched syscall $scName svc #0 at 0x${Integer.toHexString(i)} in ${file.name}") - break + if (eMachine == EM_AARCH64) { + val svcAarch64 = byteArrayOf(0x01.toByte(), 0x00.toByte(), 0x00.toByte(), 0xd4.toByte()) + val nopAarch64 = byteArrayOf(0x1f.toByte(), 0x20.toByte(), 0x03.toByte(), 0xd5.toByte()) + val movEnosysAarch64 = byteArrayOf(0xa0.toByte(), 0x04.toByte(), 0x80.toByte(), 0x92.toByte()) // movn x0, #37 (-38) + + // mov x8, #0x63 (syscall 99 set_robust_list) + val movX8Syscall99 = byteArrayOf(0x68.toByte(), 0x0c.toByte(), 0x80.toByte(), 0xd2.toByte()) + // mov x8, #0x1b3 (syscall 435 clone3) + val movX8Syscall435 = byteArrayOf(0x68.toByte(), 0x36.toByte(), 0x80.toByte(), 0xd2.toByte()) + // mov x8, #0x125 (syscall 293 rseq) + val movX8Syscall293 = byteArrayOf(0xa8.toByte(), 0x24.toByte(), 0x80.toByte(), 0xd2.toByte()) + + for ((segStartRaw, segEndRaw) in execSegments) { + val segStart = maxOf(0, segStartRaw) + val segEnd = minOf(bytes.size, segEndRaw) + val startAligned = (segStart + 3) and 3.inv() + val endAligned = segEnd and 3.inv() + + var pos = startAligned + while (pos <= endAligned - 4) { + val isSyscall99 = matchesWord(bytes, pos, movX8Syscall99) + val isSyscall435 = matchesWord(bytes, pos, movX8Syscall435) + val isSyscall293 = matchesWord(bytes, pos, movX8Syscall293) + + if (isSyscall99 || isSyscall435 || isSyscall293) { + val replacement = if (isSyscall99) nopAarch64 else movEnosysAarch64 + val scName = when { + isSyscall99 -> "99 set_robust_list" + isSyscall435 -> "435 clone3" + else -> "293 rseq" + } + val searchEnd = minOf(endAligned - 4, pos + 64) + for (i in (pos + 4)..searchEnd step 4) { + if (matchesWord(bytes, i, svcAarch64)) { + replacement.copyInto(bytes, destinationOffset = i) + modified = true + Log.i(TAG, "Patched AArch64 syscall $scName svc #0 at 0x${Integer.toHexString(i)} in ${file.name}") + break + } + } + } + pos += 4 + } + } + } else if (eMachine == EM_ARM) { + val svcArm = byteArrayOf(0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0xef.toByte()) + val nopArm = byteArrayOf(0x00.toByte(), 0xf0.toByte(), 0x20.toByte(), 0xe3.toByte()) + val mvnEnosysArm = byteArrayOf(0x25.toByte(), 0x00.toByte(), 0xe0.toByte(), 0xe3.toByte()) // mvn r0, #37 (-38) + + // movw r7, #0x152 (syscall 338 set_robust_list) + val movR7Syscall338 = byteArrayOf(0x52.toByte(), 0x71.toByte(), 0x00.toByte(), 0xe3.toByte()) + // movw r7, #0x1b3 (syscall 435 clone3) + val movR7Syscall435 = byteArrayOf(0xb3.toByte(), 0x71.toByte(), 0x00.toByte(), 0xe3.toByte()) + // movw r7, #0x18e (syscall 398 rseq) + val movR7Syscall398 = byteArrayOf(0x8e.toByte(), 0x71.toByte(), 0x00.toByte(), 0xe3.toByte()) + + for ((segStartRaw, segEndRaw) in execSegments) { + val segStart = maxOf(0, segStartRaw) + val segEnd = minOf(bytes.size, segEndRaw) + val startAligned = (segStart + 3) and 3.inv() + val endAligned = segEnd and 3.inv() + + var pos = startAligned + while (pos <= endAligned - 4) { + val isSyscall338 = matchesWord(bytes, pos, movR7Syscall338) + val isSyscall435 = matchesWord(bytes, pos, movR7Syscall435) + val isSyscall398 = matchesWord(bytes, pos, movR7Syscall398) + + if (isSyscall338 || isSyscall435 || isSyscall398) { + val replacement = if (isSyscall338) nopArm else mvnEnosysArm + val scName = when { + isSyscall338 -> "338 set_robust_list" + isSyscall435 -> "435 clone3" + else -> "398 rseq" + } + val searchEnd = minOf(endAligned - 4, pos + 64) + for (i in (pos + 4)..searchEnd step 4) { + if (matchesWord(bytes, i, svcArm)) { + replacement.copyInto(bytes, destinationOffset = i) + modified = true + Log.i(TAG, "Patched ARM syscall $scName svc #0 at 0x${Integer.toHexString(i)} in ${file.name}") + break + } + } } + pos += 4 } } - pos += 4 } if (modified) { val parent = file.parentFile ?: return val tmp = File(parent, "${file.name}.ctx_patch_tmp") + deleteIfExists(tmp) tmp.outputStream().use { it.write(bytes) } tmp.setExecutable(true, true) tmp.setReadable(true, true) tmp.setWritable(true, true) - try { android.system.Os.chmod(tmp.absolutePath, 448) } catch (e: Exception) {} - try { - Files.move( - tmp.toPath(), - file.toPath(), - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE - ) - } catch (e: Exception) { - Files.move( - tmp.toPath(), - file.toPath(), - StandardCopyOption.REPLACE_EXISTING - ) + try { Os.chmod(tmp.absolutePath, 448) } catch (_: Exception) {} + if (!atomicReplace(tmp, file)) { + deleteIfExists(tmp) + return } file.setExecutable(true, true) file.setReadable(true, true) file.setWritable(true, true) - try { android.system.Os.chmod(file.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(file.absolutePath, 448) } catch (_: Exception) {} Log.i(TAG, "Successfully wrote patched linker atomically: ${file.absolutePath}") } } catch (e: Exception) { diff --git a/scripts/setup-extra-rootfs.sh b/scripts/setup-extra-rootfs.sh index 5879998..3847366 100755 --- a/scripts/setup-extra-rootfs.sh +++ b/scripts/setup-extra-rootfs.sh @@ -530,12 +530,10 @@ EOFENV chmod 0644 extra-rootfs/etc/profile.d/00-env.sh mkdir -p extra-rootfs/etc extra-rootfs/var/lib/dbus -if [ ! -f extra-rootfs/etc/machine-id ]; then - head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n' > extra-rootfs/etc/machine-id - printf '\n' >> extra-rootfs/etc/machine-id - chmod 0644 extra-rootfs/etc/machine-id -fi -cp extra-rootfs/etc/machine-id extra-rootfs/var/lib/dbus/machine-id 2>/dev/null || true +: > extra-rootfs/etc/machine-id +chmod 0644 extra-rootfs/etc/machine-id +: > extra-rootfs/var/lib/dbus/machine-id +chmod 0644 extra-rootfs/var/lib/dbus/machine-id rm -rf extra-rootfs/bin extra-rootfs/sbin 2>/dev/null || true MAINT_TOOLS="systemd-machine-id-setup systemd-sysusers systemd-tmpfiles mandb update-mime-database update-desktop-database install-info install-sgmlcatalog" From 22ee726d3aab2b47e4bf6140bca6a2944e376423 Mon Sep 17 00:00:00 2001 From: woffluon Date: Tue, 6 Oct 2026 21:57:02 +0300 Subject: [PATCH 02/11] fix(security): enforce fail-closed release signing and harden updater trust model --- .github/workflows/build-release.yml | 279 ++++++++++++------ app/build.gradle.kts | 27 +- .../cortex/terminal/update/UpdateManager.kt | 214 +++++++++++--- 3 files changed, 383 insertions(+), 137 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 2163a75..d8faf37 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -7,6 +7,7 @@ on: - master tags: - 'v*' + pull_request: workflow_dispatch: permissions: @@ -61,35 +62,56 @@ jobs: run: chmod +x gradlew - name: Setup Release Signing Key + if: github.event_name != 'pull_request' env: RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} run: | - if [ -n "$RELEASE_KEYSTORE_BASE64" ]; then - echo "Restoring release keystore from secrets..." - echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > app/cortex-release.keystore - elif [ -f app/cortex-release.keystore ]; then - echo "Existing keystore found on disk." - else - echo "No release keystore secret configured; assembleRelease will use debug fallback for unauthenticated builds." + if [ -z "$RELEASE_KEYSTORE_BASE64" ] || [ -z "$RELEASE_STORE_PASSWORD" ] || [ -z "$RELEASE_KEY_ALIAS" ] || [ -z "$RELEASE_KEY_PASSWORD" ]; then + echo "ERROR: RELEASE_KEYSTORE_BASE64, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS, and RELEASE_KEY_PASSWORD must all be configured for release builds." + exit 1 fi + echo "Restoring release keystore from secrets..." + echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > app/cortex-release.keystore + chmod 0600 app/cortex-release.keystore + keytool -list -keystore app/cortex-release.keystore -storepass "$RELEASE_STORE_PASSWORD" -alias "$RELEASE_KEY_ALIAS" >/dev/null - name: Cache cortex-hook Libraries uses: actions/cache@v4 id: cache-hook with: path: hook-libs - key: cortex-hook-${{ hashFiles('app/src/main/cpp/cortex-hook.c') }} + key: cortex-hook-${{ hashFiles('app/src/main/cpp/cortex-hook.c', '.github/workflows/build-release.yml') }} - name: Build Glibc cortex-hook Libraries if: steps.cache-hook.outputs.cache-hit != 'true' run: | sudo rm -f /etc/apt/sources.list.d/*google* /etc/apt/sources.list.d/*chrome* - sudo apt-get update -o Acquire::Retries=3 -qq || true + sudo apt-get update -o Acquire::Retries=3 -qq sudo apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf libc6-dev-arm64-cross libc6-dev-armhf-cross dpkg-dev mkdir -p hook-libs aarch64-linux-gnu-gcc -std=gnu11 -shared -fPIC -O3 -Wall -Wno-nonnull-compare -Wl,-z,max-page-size=16384 -o hook-libs/libcortex-hook-arm64.so app/src/main/cpp/cortex-hook.c -ldl arm-linux-gnueabihf-gcc -std=gnu11 -shared -fPIC -O3 -Wall -Wno-nonnull-compare -Wl,-z,max-page-size=16384 -o hook-libs/libcortex-hook-arm.so app/src/main/cpp/cortex-hook.c -ldl + echo "Verifying 16 KB page alignment of LOAD segments in libcortex-hook-arm64.so..." + python3 - << 'EOF' + import subprocess, sys + out = subprocess.check_output(["readelf", "-lW", "hook-libs/libcortex-hook-arm64.so"], text=True) + loads = [line.strip() for line in out.splitlines() if line.strip().startswith("LOAD")] + if not loads: + print("ERROR: No PT_LOAD segments found in libcortex-hook-arm64.so") + sys.exit(1) + for line in loads: + align_str = line.split()[-1] + align = int(align_str, 16) if align_str.startswith("0x") else int(align_str) + if align < 0x4000: + print(f"ERROR: PT_LOAD alignment {hex(align)} < 0x4000 in: {line}") + sys.exit(1) + print(f"Verified {len(loads)} PT_LOAD segments in libcortex-hook-arm64.so all have alignment >= 0x4000.") + EOF + echo "Verifying Glibc version requirements in hook libraries..." if readelf -V hook-libs/*.so | grep -E "GLIBC_2\.([4-9][0-9])"; then echo "ERROR: libcortex-hook.so requires GLIBC version higher than 2.39!" @@ -105,7 +127,7 @@ jobs: app/src/main/assets/bootstrap-arm64.tar.gz app/src/main/assets/bootstrap-arm.tar.gz app/src/main/assets/cacert.pem - key: bootstrap-rootfs-v4-${{ hashFiles('scripts/setup-extra-rootfs.sh', 'app/src/main/cpp/cortex-hook.c', '.github/workflows/build-release.yml') }} + key: bootstrap-rootfs-v4-${{ hashFiles('scripts/setup-extra-rootfs.sh', 'app/src/main/cpp/cortex-hook.c', 'app/src/main/assets/*.gpg', '.github/workflows/build-release.yml') }} - name: Set up QEMU for multi-arch containers if: steps.cache-bootstrap.outputs.cache-hit != 'true' @@ -115,6 +137,14 @@ jobs: run: | mkdir -p app/src/main/assets + echo "Verifying pinned Ubuntu archive keyring SHA-256..." + ACTUAL_GPG_SHA=$(sha256sum app/src/main/assets/ubuntu-archive-keyring.gpg | awk '{print $1}') + EXPECTED_GPG_SHA="80a36b0a6de2f69f49d2df75ef473ccde121e9e190b9ea01d20a4f63778d5c31" + if [ "$ACTUAL_GPG_SHA" != "$EXPECTED_GPG_SHA" ]; then + echo "ERROR: ubuntu-archive-keyring.gpg SHA-256 mismatch: expected $EXPECTED_GPG_SHA, got $ACTUAL_GPG_SHA" + exit 1 + fi + cp hook-libs/libcortex-hook-arm64.so app/src/main/assets/libcortex-hook-arm64.so cp hook-libs/libcortex-hook-arm.so app/src/main/assets/libcortex-hook-arm.so @@ -123,7 +153,9 @@ jobs: else echo "Preparing dpkg tables, APT sandbox, hosts, and DNS config for bootstrap..." mkdir -p extra-rootfs/usr/share/dpkg extra-rootfs/etc/apt/apt.conf.d extra-rootfs/var/cache/apt/archives/partial extra-rootfs/var/lib/apt/lists/partial extra-rootfs/tmp extra-rootfs/etc extra-rootfs/etc/alternatives extra-rootfs/var/lib/dpkg/alternatives extra-rootfs/etc/dpkg/dpkg.cfg.d extra-rootfs/etc/profile.d extra-rootfs/usr/sbin extra-rootfs/etc/ssl/certs - curl -sSLo extra-rootfs/etc/ssl/certs/ca-certificates.crt https://curl.se/ca/cacert.pem + curl -fsSL --retry 3 -o extra-rootfs/etc/ssl/certs/ca-certificates.crt https://curl.se/ca/cacert.pem || cp /etc/ssl/certs/ca-certificates.crt extra-rootfs/etc/ssl/certs/ca-certificates.crt + grep -q "BEGIN CERTIFICATE" extra-rootfs/etc/ssl/certs/ca-certificates.crt + test "$(wc -c < extra-rootfs/etc/ssl/certs/ca-certificates.crt)" -gt 100000 cp extra-rootfs/etc/ssl/certs/ca-certificates.crt app/src/main/assets/cacert.pem mkdir -p extra-rootfs/usr/lib/ssl extra-rootfs/etc/pki/tls/certs cp extra-rootfs/etc/ssl/certs/ca-certificates.crt extra-rootfs/etc/ssl/cert.pem @@ -133,15 +165,13 @@ jobs: if [ -d /usr/share/keyrings ]; then cp -r /usr/share/keyrings/* extra-rootfs/usr/share/keyrings/ 2>/dev/null || true fi - cp app/src/main/assets/*.gpg extra-rootfs/usr/share/keyrings/ 2>/dev/null || true - if [ -f extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg ]; then - cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/etc/apt/keyrings/ubuntu-archive-keyring.gpg 2>/dev/null || true - cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/etc/apt/trusted.gpg 2>/dev/null || true - cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/etc/apt/trusted.gpg.d/ubuntu-archive-keyring.gpg 2>/dev/null || true - cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/etc/gnupg/trustedkeys.gpg 2>/dev/null || true - cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/home/.gnupg/trustedkeys.gpg 2>/dev/null || true - cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/root/.gnupg/trustedkeys.gpg 2>/dev/null || true - fi + cp app/src/main/assets/*.gpg extra-rootfs/usr/share/keyrings/ + cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/etc/apt/keyrings/ubuntu-archive-keyring.gpg + cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/etc/apt/trusted.gpg + cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/etc/apt/trusted.gpg.d/ubuntu-archive-keyring.gpg + cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/etc/gnupg/trustedkeys.gpg + cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/home/.gnupg/trustedkeys.gpg + cp extra-rootfs/usr/share/keyrings/ubuntu-archive-keyring.gpg extra-rootfs/root/.gnupg/trustedkeys.gpg if [ -d /usr/share/dpkg ]; then cp -r /usr/share/dpkg/* extra-rootfs/usr/share/dpkg/ fi @@ -189,9 +219,9 @@ jobs: docker start "$CID_64" echo "Pre-updating packages and caching APT lists for ARM64..." - docker exec -e DEBIAN_FRONTEND=noninteractive "$CID_64" apt-get update -o Acquire::Retries=3 || true - docker exec -e DEBIAN_FRONTEND=noninteractive "$CID_64" apt-get -y --no-install-recommends upgrade || true - docker exec "$CID_64" apt-get clean || true + docker exec -e DEBIAN_FRONTEND=noninteractive "$CID_64" apt-get update -o Acquire::Retries=3 + docker exec -e DEBIAN_FRONTEND=noninteractive "$CID_64" apt-get -y --no-install-recommends upgrade + docker exec "$CID_64" apt-get clean docker exec "$CID_64" rm -f /var/lib/apt/lists/lock /var/lib/dpkg/lock* /var/cache/apt/archives/lock 2>/dev/null || true docker exec "$CID_64" rm -rf /var/cache/apt/archives/*.deb /tmp/* 2>/dev/null || true @@ -207,9 +237,9 @@ jobs: docker start "$CID_32" echo "Pre-updating packages and caching APT lists for ARMv7..." - docker exec -e DEBIAN_FRONTEND=noninteractive "$CID_32" apt-get update -o Acquire::Retries=3 || true - docker exec -e DEBIAN_FRONTEND=noninteractive "$CID_32" apt-get -y --no-install-recommends upgrade || true - docker exec "$CID_32" apt-get clean || true + docker exec -e DEBIAN_FRONTEND=noninteractive "$CID_32" apt-get update -o Acquire::Retries=3 + docker exec -e DEBIAN_FRONTEND=noninteractive "$CID_32" apt-get -y --no-install-recommends upgrade + docker exec "$CID_32" apt-get clean docker exec "$CID_32" rm -f /var/lib/apt/lists/lock /var/lib/dpkg/lock* /var/cache/apt/archives/lock 2>/dev/null || true docker exec "$CID_32" rm -rf /var/cache/apt/archives/*.deb /tmp/* 2>/dev/null || true @@ -219,7 +249,7 @@ jobs: echo "Patching dynamic linkers in bootstrap archives for Android seccomp compliance..." python3 - << 'EOF' - import tarfile, io, os, struct + import tarfile, io, os, struct, sys def patch_elf_bytes(data, filename): if len(data) < 52 or data[:4] != b"\x7fELF": @@ -231,7 +261,7 @@ jobs: e_machine = struct.unpack_from(" len(data): break - p_type, p_flags = struct.unpack_from("= 56: + for i in range(e_phnum): + off = e_phoff + i * e_phentsize + if off + 40 > len(data): break + p_type, p_flags = struct.unpack_from(" len(data): break - sh_flags = struct.unpack_from("= 64: + for i in range(e_shnum): + off = e_shoff + i * e_shentsize + if off + 48 > len(data): break + sh_flags = struct.unpack_from("= 52: e_phoff = struct.unpack_from(" len(data): break - p_type = struct.unpack_from("= 32: + for i in range(e_phnum): + off = e_phoff + i * e_phentsize + if off + 28 > len(data): break + p_type = struct.unpack_from(" len(data): break - sh_flags = struct.unpack_from("= 40: + for i in range(e_shnum): + off = e_shoff + i * e_shentsize + if off + 24 > len(data): break + sh_flags = struct.unpack_from("= 0x4000.") + EOF + - name: Build Release APKs + if: github.event_name != 'pull_request' env: - RELEASE_KEYSTORE_PATH: app/cortex-release.keystore + RELEASE_STORE_FILE: app/cortex-release.keystore RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} run: | - ./gradlew assembleRelease --build-cache --parallel --stacktrace + gcc -std=gnu11 -O2 -Wall -Wextra -pthread tests/native/test_cortex_native.c -o /tmp/test_cortex_native -ldl + /tmp/test_cortex_native + bash tests/scripts/test_shell_scripts.sh + ./gradlew testDebugUnitTest lintDebug assembleRelease --build-cache --parallel --stacktrace - name: Prepare and Verify Signed APKs + if: github.event_name != 'pull_request' env: RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} @@ -452,30 +527,34 @@ jobs: echo "Found 64-bit APK: $ARM64_RAW" echo "Found 32-bit APK: $ARM32_RAW" - # Determine keystore and signing credentials - if [ -f app/cortex-release.keystore ] && [ -n "$RELEASE_STORE_PASSWORD" ]; then - echo "Using release keystore for signing..." - KS_FILE="app/cortex-release.keystore" - KS_PASS="$RELEASE_STORE_PASSWORD" - KEY_ALIAS="${RELEASE_KEY_ALIAS:-cortex}" - KEY_PASS="${RELEASE_KEY_PASSWORD:-$RELEASE_STORE_PASSWORD}" - elif [ -f "$HOME/.android/debug.keystore" ]; then - echo "Using debug keystore fallback for signing..." - KS_FILE="$HOME/.android/debug.keystore" - KS_PASS="android" - KEY_ALIAS="androiddebugkey" - KEY_PASS="android" - else - echo "Searching for generated debug keystore..." - KS_FILE=$(find "$HOME/.android" -name "debug.keystore" 2>/dev/null | head -n 1) - if [ -z "$KS_FILE" ]; then - echo "ERROR: Neither release nor debug keystore found." - exit 1 - fi - KS_PASS="android" - KEY_ALIAS="androiddebugkey" - KEY_PASS="android" + if [ ! -f app/cortex-release.keystore ] || [ -z "$RELEASE_STORE_PASSWORD" ] || [ -z "$RELEASE_KEY_ALIAS" ] || [ -z "$RELEASE_KEY_PASSWORD" ]; then + echo "ERROR: Release keystore and signing credentials are required; refusing to sign with any fallback key." + exit 1 fi + KS_FILE="app/cortex-release.keystore" + KS_PASS="$RELEASE_STORE_PASSWORD" + KEY_ALIAS="$RELEASE_KEY_ALIAS" + KEY_PASS="$RELEASE_KEY_PASSWORD" + + # Verify 16 KB page alignment on arm64 native libraries inside the built APK + rm -rf /tmp/apk-check && mkdir -p /tmp/apk-check + unzip -q "$ARM64_RAW" "lib/arm64-v8a/libcortex-pty.so" "assets/libcortex-hook-arm64.so" -d /tmp/apk-check + python3 - << 'EOF' + import subprocess, sys + for path in ("/tmp/apk-check/lib/arm64-v8a/libcortex-pty.so", "/tmp/apk-check/assets/libcortex-hook-arm64.so"): + out = subprocess.check_output(["readelf", "-lW", path], text=True) + loads = [line.strip() for line in out.splitlines() if line.strip().startswith("LOAD")] + if not loads: + print(f"ERROR: No PT_LOAD segments found in {path}") + sys.exit(1) + for line in loads: + align_str = line.split()[-1] + align = int(align_str, 16) if align_str.startswith("0x") else int(align_str) + if align < 0x4000: + print(f"ERROR: PT_LOAD alignment {hex(align)} < 0x4000 in {path}: {line}") + sys.exit(1) + print(f"Verified {len(loads)} PT_LOAD segments in {path} all have alignment >= 0x4000.") + EOF # 1. Strip opposite arch assets from 64-bit APK to minimize download size echo "Optimizing 64-bit APK size: stripping 32-bit assets..." @@ -503,22 +582,40 @@ jobs: release-apks/arm32-aligned.apk rm -f release-apks/arm32-aligned.apk - # Verify Signatures - $APKSIGNER verify release-apks/Cortex-arm64-v8a-signed.apk - $APKSIGNER verify release-apks/Cortex-armeabi-v7a-signed.apk + # Verify Signatures and Certificate Fingerprints + EXPECTED_CERT_SHA256=$(keytool -list -v -keystore "$KS_FILE" -storepass "$KS_PASS" -alias "$KEY_ALIAS" | awk '/SHA256:/ {gsub(":", "", $2); print tolower($2); exit}') + if [ -z "$EXPECTED_CERT_SHA256" ]; then + echo "ERROR: Could not extract SHA-256 fingerprint from release keystore" + exit 1 + fi + + for apk in release-apks/Cortex-arm64-v8a-signed.apk release-apks/Cortex-armeabi-v7a-signed.apk; do + CERT_OUT=$($APKSIGNER verify --verbose --print-certs "$apk") + echo "$CERT_OUT" + if echo "$CERT_OUT" | grep -qi "Android Debug"; then + echo "ERROR: $apk is signed with an Android Debug certificate!" + exit 1 + fi + APK_CERT_SHA256=$(echo "$CERT_OUT" | awk '/Signer #1 certificate SHA-256 digest:/ {print tolower($NF); exit}') + if [ "$APK_CERT_SHA256" != "$EXPECTED_CERT_SHA256" ]; then + echo "ERROR: Certificate SHA-256 mismatch on $apk: expected $EXPECTED_CERT_SHA256, got $APK_CERT_SHA256" + exit 1 + fi + done echo "Final optimized APK sizes:" ls -lh release-apks/ echo "APKs signed and verified successfully." - name: Upload Artifacts + if: github.event_name != 'pull_request' uses: actions/upload-artifact@v4 with: name: cortex-release-apks path: release-apks/*.apk - name: Create GitHub Release - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 39ee3a5..a913383 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,7 +11,7 @@ android { defaultConfig { applicationId = "org.cortex.terminal" - minSdk = 26 + minSdk = 24 // Target SDK 28 is required to allow direct execve of binaries and glibc dynamic linker // from the app's writable data directory (filesDir). Android 10+ (targetSdk >= 29) // enforces SELinux W^X restrictions preventing execution from writable app data storage. @@ -49,15 +49,34 @@ android { val releaseStorePassword = System.getenv("RELEASE_STORE_PASSWORD") val releaseKeyAlias = System.getenv("RELEASE_KEY_ALIAS") val releaseKeyPassword = System.getenv("RELEASE_KEY_PASSWORD") - val hasReleaseSigning = !releaseStoreFile.isNullOrEmpty() && + val resolvedKeystoreFile = if (!releaseStoreFile.isNullOrEmpty()) { + val candidate = file(releaseStoreFile) + if (candidate.exists()) candidate else rootProject.file(releaseStoreFile) + } else { + null + } + val hasReleaseSigning = resolvedKeystoreFile != null && + resolvedKeystoreFile.exists() && !releaseStorePassword.isNullOrEmpty() && !releaseKeyAlias.isNullOrEmpty() && !releaseKeyPassword.isNullOrEmpty() + val isReleaseTaskRequested = gradle.startParameter.taskNames.any { taskName -> + taskName.contains("Release", ignoreCase = true) && + (taskName.contains("assemble", ignoreCase = true) || + taskName.contains("bundle", ignoreCase = true) || + taskName.contains("package", ignoreCase = true)) + } + if (isReleaseTaskRequested && !hasReleaseSigning) { + throw GradleException( + "Release build requires valid RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS, and RELEASE_KEY_PASSWORD" + ) + } + signingConfigs { create("release") { - if (hasReleaseSigning) { - storeFile = file(releaseStoreFile) + if (hasReleaseSigning && resolvedKeystoreFile != null) { + storeFile = resolvedKeystoreFile storePassword = releaseStorePassword keyAlias = releaseKeyAlias keyPassword = releaseKeyPassword diff --git a/app/src/main/java/org/cortex/terminal/update/UpdateManager.kt b/app/src/main/java/org/cortex/terminal/update/UpdateManager.kt index 49ae261..b3ea082 100644 --- a/app/src/main/java/org/cortex/terminal/update/UpdateManager.kt +++ b/app/src/main/java/org/cortex/terminal/update/UpdateManager.kt @@ -18,13 +18,16 @@ import org.cortex.terminal.R import org.json.JSONObject import android.content.pm.PackageManager import android.content.pm.SigningInfo +import android.system.Os import android.util.Log import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean object UpdateManager { @@ -52,6 +55,9 @@ object UpdateManager { private set private val mainHandler = Handler(Looper.getMainLooper()) + private val isDownloading = AtomicBoolean(false) + @Volatile + private var pendingInstallUri: Uri? = null fun isNewerVersion(current: String, latest: String): Boolean { val cleanCurrent = current.trim().removePrefix("v").removePrefix("V") @@ -86,7 +92,19 @@ object UpdateManager { } fun cleanUpdates(context: Context) { + if (isDownloading.get()) return + cleanUpdatesForce(context) + } + + private fun cleanUpdatesForce(context: Context) { try { + val uri = pendingInstallUri + if (uri != null) { + pendingInstallUri = null + try { + context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + } val updatesDir = File(context.cacheDir, "updates") if (updatesDir.exists()) { updatesDir.deleteRecursively() @@ -299,12 +317,35 @@ object UpdateManager { return } - cleanUpdates(activity) + if (!isDownloading.compareAndSet(false, true)) { + return + } + + cleanUpdatesForce(activity) val updatesDir = File(activity.cacheDir, "updates") if (!updatesDir.exists()) updatesDir.mkdirs() - - val targetApk = File(updatesDir, info.asset.name) + updatesDir.setReadable(false, false) + updatesDir.setReadable(true, true) + updatesDir.setWritable(false, false) + updatesDir.setWritable(true, true) + updatesDir.setExecutable(false, false) + updatesDir.setExecutable(true, true) + try { Os.chmod(updatesDir.absolutePath, 448) } catch (_: Exception) {} + + val targetApk = try { + File.createTempFile("cortex_update_", ".apk", updatesDir).apply { + setReadable(false, false) + setReadable(true, true) + setWritable(false, false) + setWritable(true, true) + try { Os.chmod(absolutePath, 384) } catch (_: Exception) {} + } + } catch (e: Exception) { + isDownloading.set(false) + Toast.makeText(activity, "Failed to create temporary update file", Toast.LENGTH_SHORT).show() + return + } val dialogView = LayoutInflater.from(activity).inflate(R.layout.dialog_update_download, null) val statusText = dialogView.findViewById(R.id.downloadStatusText) @@ -314,19 +355,18 @@ object UpdateManager { statusText.text = "Connecting to download server..." progressBar.isIndeterminate = true - var isCancelled = false - var activeConnection: HttpURLConnection? = null + val isCancelled = AtomicBoolean(false) + val activeConnection = java.util.concurrent.atomic.AtomicReference(null) val downloadDialog = AlertDialog.Builder(activity) .setTitle("Downloading Update") .setView(dialogView) .setCancelable(false) .setNegativeButton("Cancel") { _, _ -> - isCancelled = true + isCancelled.set(true) try { - activeConnection?.disconnect() + activeConnection.get()?.disconnect() } catch (e: Exception) {} - cleanUpdates(activity) } .create() @@ -335,6 +375,7 @@ object UpdateManager { kotlin.concurrent.thread(name = "Cortex-ApkDownloader") { var inputStream: InputStream? = null var outputStream: FileOutputStream? = null + var downloadSucceeded = false try { var currentUrl = info.asset.downloadUrl var redirects = 0 @@ -352,34 +393,32 @@ object UpdateManager { connection.setRequestProperty("User-Agent", "Cortex-Terminal-App") connection.setRequestProperty("Accept", "*/*") - activeConnection = connection + activeConnection.set(connection) val responseCode = connection.responseCode if (responseCode in 300..399) { val newLocation = connection.getHeaderField("Location") connection.disconnect() if (newLocation.isNullOrEmpty() || ++redirects > 5) { - throw Exception("Too many redirects or missing Location header") + throw IOException("Too many redirects or missing Location header") } currentUrl = newLocation } else if (responseCode == HttpURLConnection.HTTP_OK) { break } else { - throw Exception("HTTP download error: $responseCode") + throw IOException("HTTP download error: $responseCode") } } - if (isCancelled) { - cleanUpdates(activity) + if (isCancelled.get()) { return@thread } - val totalBytes = connection.contentLength.toLong().let { - if (it > 0) it else info.asset.size - } + val contentLength = connection.contentLengthLong + val totalBytes = if (contentLength > 0L) contentLength else info.asset.size mainHandler.post { - if (!isCancelled && downloadDialog.isShowing) { + if (!isCancelled.get() && downloadDialog.isShowing) { progressBar.isIndeterminate = false progressBar.max = 100 progressBar.progress = 0 @@ -387,6 +426,15 @@ object UpdateManager { } } + // Ensure 0600 permissions on targetApk BEFORE writing downloaded bytes + try { + targetApk.setReadable(false, false) + targetApk.setReadable(true, true) + targetApk.setWritable(false, false) + targetApk.setWritable(true, true) + Os.chmod(targetApk.absolutePath, 384) + } catch (_: Exception) {} + inputStream = connection.inputStream outputStream = FileOutputStream(targetApk) @@ -396,10 +444,7 @@ object UpdateManager { var lastUpdate = System.currentTimeMillis() while (inputStream.read(buffer).also { bytesRead = it } != -1) { - if (isCancelled) { - outputStream.close() - inputStream.close() - cleanUpdates(activity) + if (isCancelled.get()) { return@thread } @@ -418,7 +463,7 @@ object UpdateManager { val totalMB = String.format(Locale.US, "%.1f", totalBytes / (1024.0 * 1024.0)) mainHandler.post { - if (!isCancelled && downloadDialog.isShowing) { + if (!isCancelled.get() && downloadDialog.isShowing) { progressBar.progress = percent percentText.text = "$percent% ($downloadedMB MB / $totalMB MB)" } @@ -432,29 +477,45 @@ object UpdateManager { inputStream.close() inputStream = null - // Set owner-only permissions (0600) on downloaded APK + if (isCancelled.get()) { + return@thread + } + + if (totalRead <= 0L) { + throw IOException("Downloaded APK is empty") + } + if (info.asset.size > 0L && totalRead != info.asset.size) { + throw IOException("Downloaded APK size ($totalRead bytes) does not match release asset size (${info.asset.size} bytes)") + } + if (totalBytes > 0L && totalRead != totalBytes) { + throw IOException("Incomplete APK download ($totalRead of $totalBytes bytes)") + } + + // Enforce owner-only permissions (0600) on downloaded APK try { targetApk.setReadable(false, false) targetApk.setReadable(true, true) targetApk.setWritable(false, false) targetApk.setWritable(true, true) + Os.chmod(targetApk.absolutePath, 384) } catch (_: Exception) {} + downloadSucceeded = true + mainHandler.post { - if (!isCancelled && downloadDialog.isShowing) { + if (!isCancelled.get() && downloadDialog.isShowing) { try { downloadDialog.dismiss() } catch (e: Exception) {} launchInstall(activity, targetApk) + } else { + try { targetApk.delete() } catch (_: Exception) {} + cleanUpdates(activity) } } } catch (e: Exception) { - try { outputStream?.close() } catch (ex: Exception) {} - try { inputStream?.close() } catch (ex: Exception) {} - cleanUpdates(activity) - mainHandler.post { - if (!isCancelled && downloadDialog.isShowing) { + if (!isCancelled.get() && downloadDialog.isShowing) { try { downloadDialog.dismiss() } catch (ex: Exception) {} AlertDialog.Builder(activity) .setTitle("Update Download Failed") @@ -466,10 +527,53 @@ object UpdateManager { .show() } } + } finally { + try { outputStream?.close() } catch (_: Exception) {} + try { inputStream?.close() } catch (_: Exception) {} + try { activeConnection.get()?.disconnect() } catch (_: Exception) {} + isDownloading.set(false) + if (!downloadSucceeded || isCancelled.get()) { + try { targetApk.delete() } catch (_: Exception) {} + cleanUpdatesForce(activity) + } } } } + internal fun isVersionUpgrade(installedVersionCode: Long, archiveVersionCode: Long): Boolean = + archiveVersionCode > installedVersionCode + + internal fun isAuthorizedSigner( + currentActiveSigners: Set, + archiveActiveSigners: Set, + archiveLineageSigners: List, + hasMultipleSigners: Boolean + ): Boolean { + if (hasMultipleSigners) { + return currentActiveSigners.isNotEmpty() && currentActiveSigners == archiveActiveSigners + } + if (currentActiveSigners.size != 1 || archiveActiveSigners.size != 1) { + return false + } + if (currentActiveSigners == archiveActiveSigners) { + return true + } + val currentActive = currentActiveSigners.first() + val archiveActive = archiveActiveSigners.first() + return archiveLineageSigners.isNotEmpty() && + archiveLineageSigners.last() == archiveActive && + currentActive in archiveLineageSigners + } + + private fun getPackageVersionCode(info: android.content.pm.PackageInfo): Long { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.longVersionCode + } else { + @Suppress("DEPRECATION") + info.versionCode.toLong() + } + } + private fun verifyApkSignature(context: Context, apkFile: File): Boolean { try { val pm = context.packageManager @@ -502,17 +606,33 @@ object UpdateManager { } if (currentInfo == null) return false + val currentVerCode = getPackageVersionCode(currentInfo) + val archiveVerCode = getPackageVersionCode(archiveInfo) + if (!isVersionUpgrade(currentVerCode, archiveVerCode)) { + Log.e("UpdateManager", "APK version downgrade/replay rejected: installed=$currentVerCode, archive=$archiveVerCode") + return false + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val currentSigning = currentInfo.signingInfo ?: return false val archiveSigning = archiveInfo.signingInfo ?: return false - val currentSignatures = if (currentSigning.hasMultipleSigners()) currentSigning.apkContentsSigners else currentSigning.signingCertificateHistory - val archiveSignatures = if (archiveSigning.hasMultipleSigners()) archiveSigning.apkContentsSigners else archiveSigning.signingCertificateHistory + if (currentSigning.hasMultipleSigners() != archiveSigning.hasMultipleSigners()) { + Log.e("UpdateManager", "APK multi-signer state mismatch") + return false + } - val currentSet = currentSignatures.map { it.toCharsString() }.toSet() - val archiveSet = archiveSignatures.map { it.toCharsString() }.toSet() - if (currentSet.intersect(archiveSet).isEmpty()) { - Log.e("UpdateManager", "APK signing certificate does not match installed application") + val hasMulti = currentSigning.hasMultipleSigners() + val currentActive = currentSigning.apkContentsSigners?.map { it.toCharsString() }?.toSet()?.takeIf { it.isNotEmpty() } + ?: currentSigning.signingCertificateHistory?.lastOrNull()?.let { setOf(it.toCharsString()) } + ?: emptySet() + val archiveActive = archiveSigning.apkContentsSigners?.map { it.toCharsString() }?.toSet()?.takeIf { it.isNotEmpty() } + ?: archiveSigning.signingCertificateHistory?.lastOrNull()?.let { setOf(it.toCharsString()) } + ?: emptySet() + val archiveLineage = archiveSigning.signingCertificateHistory?.map { it.toCharsString() } ?: emptyList() + + if (!isAuthorizedSigner(currentActive, archiveActive, archiveLineage, hasMulti)) { + Log.e("UpdateManager", "APK signing certificate is not authorized for installed application") return false } } else { @@ -520,7 +640,7 @@ object UpdateManager { val currentSigs = currentInfo.signatures?.map { it.toCharsString() }?.toSet() ?: emptySet() @Suppress("DEPRECATION") val archiveSigs = archiveInfo.signatures?.map { it.toCharsString() }?.toSet() ?: emptySet() - if (currentSigs.intersect(archiveSigs).isEmpty()) { + if (currentSigs.isEmpty() || currentSigs != archiveSigs) { Log.e("UpdateManager", "APK signature does not match installed application") return false } @@ -533,21 +653,27 @@ object UpdateManager { } private fun launchInstall(activity: Activity, apkFile: File) { - if (activity.isFinishing || activity.isDestroyed) return + if (activity.isFinishing || activity.isDestroyed) { + try { apkFile.delete() } catch (_: Exception) {} + cleanUpdatesForce(activity) + return + } if (!apkFile.exists() || apkFile.length() <= 0L) { Toast.makeText(activity, "Downloaded APK file is missing or corrupted", Toast.LENGTH_SHORT).show() - cleanUpdates(activity) + try { apkFile.delete() } catch (_: Exception) {} + cleanUpdatesForce(activity) return } if (!verifyApkSignature(activity, apkFile)) { + try { apkFile.delete() } catch (_: Exception) {} + cleanUpdatesForce(activity) AlertDialog.Builder(activity) .setTitle("Update Verification Failed") .setMessage("The downloaded update could not be verified or its digital signature does not match this app.") .setPositiveButton("OK", null) .show() - cleanUpdates(activity) return } @@ -560,10 +686,12 @@ object UpdateManager { openUnknownAppSourcesSettings(activity) } .setNegativeButton("Cancel") { _, _ -> - cleanUpdates(activity) + try { apkFile.delete() } catch (_: Exception) {} + cleanUpdatesForce(activity) } .setOnCancelListener { - cleanUpdates(activity) + try { apkFile.delete() } catch (_: Exception) {} + cleanUpdatesForce(activity) } .show() return @@ -576,6 +704,7 @@ object UpdateManager { "${activity.packageName}.fileprovider", apkFile ) + pendingInstallUri = apkUri val installIntent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(apkUri, "application/vnd.android.package-archive") @@ -585,7 +714,8 @@ object UpdateManager { activity.startActivity(installIntent) } catch (e: Exception) { Toast.makeText(activity, "Failed to launch installer: ${e.localizedMessage}", Toast.LENGTH_LONG).show() - cleanUpdates(activity) + try { apkFile.delete() } catch (_: Exception) {} + cleanUpdatesForce(activity) } } } From 5c651e9d1a6c710dfcbb3cc1ba100de938b6f9ad Mon Sep 17 00:00:00 2001 From: woffluon Date: Tue, 6 Oct 2026 21:57:02 +0300 Subject: [PATCH 03/11] fix(native): harden pty fd cleanup, tar extraction, elf checks, and sigsys handling --- app/src/main/cpp/cortex-pty.c | 695 ++++++++++++++---- .../org/cortex/terminal/pty/PtyProcess.kt | 31 +- 2 files changed, 574 insertions(+), 152 deletions(-) diff --git a/app/src/main/cpp/cortex-pty.c b/app/src/main/cpp/cortex-pty.c index c024df6..7a23477 100644 --- a/app/src/main/cpp/cortex-pty.c +++ b/app/src/main/cpp/cortex-pty.c @@ -1,5 +1,14 @@ #define _GNU_SOURCE +#ifndef CORTEX_HOST_TEST #include +#include +#define TAG "CortexPty" +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#else +#define LOGE(...) do { fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); } while (0) +#define LOGI(...) do { fprintf(stdout, __VA_ARGS__); fprintf(stdout, "\n"); } while (0) +#endif #include #include #include @@ -13,14 +22,15 @@ #include #include #include +#include #include #include #include -#include -#define TAG "CortexPty" -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#ifndef __NR_close_range +#define __NR_close_range 436 +#endif + static char **clean_env_for_system(char *const envp[], int envCount) { char **new_env = malloc(sizeof(char *) * (envCount + 1)); if (!new_env) return (char **)envp; @@ -41,7 +51,7 @@ static int find_dynamic_linker(const char *cortex_root, const char *cmd, char *o uint16_t e_machine = 0; if (cmd && cmd[0] != '\0') { - int fd = open(cmd, O_RDONLY); + int fd = open(cmd, O_RDONLY | O_CLOEXEC); if (fd >= 0) { unsigned char ehdr[20]; ssize_t n = read(fd, ehdr, sizeof(ehdr)); @@ -89,12 +99,16 @@ static int find_dynamic_linker(const char *cortex_root, const char *cmd, char *o if (e_machine == 183) { // EM_AARCH64 primary = aarch64_cands; - secondary = armhf_cands; + secondary = NULL; } else if (e_machine == 40) { // EM_ARM primary = armhf_cands; - secondary = aarch64_cands; + secondary = NULL; } else if (e_machine == 62) { // EM_X86_64 primary = x86_64_cands; + secondary = NULL; + } else if (e_machine == 3) { // EM_386 + primary = NULL; + secondary = NULL; } else { #if defined(__aarch64__) primary = aarch64_cands; @@ -125,7 +139,7 @@ static int find_dynamic_linker(const char *cortex_root, const char *cmd, char *o static int has_pt_interp(const char *path) { if (!path || path[0] == '\0') return 0; - int fd = open(path, O_RDONLY); + int fd = open(path, O_RDONLY | O_CLOEXEC); if (fd < 0) return 0; unsigned char buf[sizeof(Elf64_Ehdr)]; @@ -136,7 +150,9 @@ static int has_pt_interp(const char *path) { } if (buf[EI_MAG0] != ELFMAG0 || buf[EI_MAG1] != ELFMAG1 || - buf[EI_MAG2] != ELFMAG2 || buf[EI_MAG3] != ELFMAG3) { + buf[EI_MAG2] != ELFMAG2 || buf[EI_MAG3] != ELFMAG3 || + (buf[EI_CLASS] != ELFCLASS32 && buf[EI_CLASS] != ELFCLASS64) || + buf[EI_DATA] != ELFDATA2LSB) { close(fd); return 0; } @@ -156,26 +172,24 @@ static int has_pt_interp(const char *path) { phoff = ehdr64.e_phoff; phentsize = ehdr64.e_phentsize; phnum = ehdr64.e_phnum; - if (phentsize < sizeof(Elf64_Phdr)) { + if (phentsize < sizeof(Elf64_Phdr) || phentsize > 4096) { close(fd); return 0; } - } else if (buf[EI_CLASS] == ELFCLASS32) { + } else { Elf32_Ehdr ehdr32; memcpy(&ehdr32, buf, sizeof(Elf32_Ehdr)); phoff = ehdr32.e_phoff; phentsize = ehdr32.e_phentsize; phnum = ehdr32.e_phnum; - if (phentsize < sizeof(Elf32_Phdr)) { + if (phentsize < sizeof(Elf32_Phdr) || phentsize > 4096) { close(fd); return 0; } - } else { - close(fd); - return 0; } - if (phoff == 0 || phentsize == 0 || phnum == 0) { + if (phoff == 0 || phentsize == 0 || phnum == 0 || + (off_t)phoff < 0 || (uint64_t)(off_t)phoff != phoff) { close(fd); return 0; } @@ -213,29 +227,18 @@ static int has_pt_interp(const char *path) { return 0; } -static void close_all_inherited_fds(void) { - DIR *dir = opendir("/proc/self/fd"); - if (dir != NULL) { - int dfd = dirfd(dir); - struct dirent *de; - while ((de = readdir(dir)) != NULL) { - if (de->d_name[0] == '.') continue; - char *endptr = NULL; - long fd = strtol(de->d_name, &endptr, 10); - if (endptr && *endptr == '\0' && fd > STDERR_FILENO && fd != dfd) { - close((int)fd); - } - } - closedir(dir); - } else { - long max_fd = sysconf(_SC_OPEN_MAX); - if (max_fd < 0 || max_fd > 65536) max_fd = 1024; - for (int fd = 3; fd < (int)max_fd; fd++) { - close(fd); - } +static void close_all_inherited_fds(int max_fd) { + if (syscall(__NR_close_range, 3U, ~0U, 0) >= 0) { + return; + } + if (max_fd < 3) max_fd = 1024; + for (int fd = 3; fd < max_fd; fd++) { + close(fd); } } +#ifndef CORTEX_HOST_TEST + JNIEXPORT jintArray JNICALL Java_org_cortex_terminal_pty_PtyNative_createPty( JNIEnv *env, @@ -315,10 +318,20 @@ Java_org_cortex_terminal_pty_PtyNative_createPty( const char *cwd = cwdStr ? (*env)->GetStringUTFChars(env, cwdStr, NULL) : NULL; + // Pre-calculate max_fd and pre-allocate child structures before fork() + long max_fd = sysconf(_SC_OPEN_MAX); + if (max_fd < 0) max_fd = 1024; + else if (max_fd > 65536) max_fd = 65536; + + char **new_argv = calloc(argCount + 5, sizeof(char *)); + char **sys_env = clean_env_for_system(envp, envCount); + pid_t pid = fork(); if (pid < 0) { LOGE("Failed to fork: %s", strerror(errno)); close(masterFd); + free(new_argv); + if (sys_env && sys_env != envp) free(sys_env); if (argv) { for (int i = 0; i <= argCount; i++) { free(argv[i]); @@ -356,10 +369,9 @@ Java_org_cortex_terminal_pty_PtyNative_createPty( if (slaveFd > STDERR_FILENO) { close(slaveFd); } - close(masterFd); - // Close all open file descriptors fd > 2 to prevent descriptor inheritance leaks - close_all_inherited_fds(); + // Close all open file descriptors fd > 2 (including masterFd) to prevent descriptor inheritance leaks + close_all_inherited_fds((int)max_fd); // Set foreground process group to child process pid_t pgrp = getpid(); @@ -437,16 +449,17 @@ Java_org_cortex_terminal_pty_PtyNative_createPty( const char *prog_name = strrchr(cmd, '/'); prog_name = (prog_name != NULL) ? prog_name + 1 : cmd; - char **new_argv = calloc(argCount + 5, sizeof(char *)); - new_argv[0] = ld_so; - new_argv[1] = "--argv0"; - new_argv[2] = (char *)prog_name; - new_argv[3] = (char *)cmd; - for (int i = 1; i <= argCount; i++) { - new_argv[i + 3] = argv[i]; + if (new_argv) { + new_argv[0] = ld_so; + new_argv[1] = "--argv0"; + new_argv[2] = (char *)prog_name; + new_argv[3] = (char *)cmd; + for (int i = 1; i <= argCount; i++) { + new_argv[i + 3] = argv[i]; + } + new_argv[argCount + 4] = NULL; + execve(ld_so, new_argv, envp); } - new_argv[argCount + 4] = NULL; - execve(ld_so, new_argv, envp); char err_buf[256]; snprintf(err_buf, sizeof(err_buf), "Cortex: failed to exec ld.so (%s): %s\n", ld_so, strerror(errno)); @@ -461,7 +474,6 @@ Java_org_cortex_terminal_pty_PtyNative_createPty( const char *fallback_sh = "/system/bin/sh"; if (access(fallback_sh, X_OK) == 0) { - char **sys_env = clean_env_for_system(envp, envCount); char *fb_argv[] = { (char *)fallback_sh, NULL }; execve(fallback_sh, fb_argv, sys_env); } @@ -470,7 +482,6 @@ Java_org_cortex_terminal_pty_PtyNative_createPty( // If command is outside cortex_root (e.g. host /system/bin/sh), clean env if (!cortex_root || strncmp(cmd, cortex_root, strlen(cortex_root)) != 0) { - char **sys_env = clean_env_for_system(envp, envCount); execve(cmd, argv, sys_env); } else { execve(cmd, argv, envp); @@ -484,6 +495,10 @@ Java_org_cortex_terminal_pty_PtyNative_createPty( } // Parent cleanup + free(new_argv); + if (sys_env && sys_env != envp) { + free(sys_env); + } for (int i = 0; i <= argCount; i++) { free(argv[i]); } @@ -563,33 +578,267 @@ Java_org_cortex_terminal_pty_PtyNative_closeFd( close(fd); } } +#endif /* !CORTEX_HOST_TEST */ + +static int has_dotdot_component(const char *path) { + if (!path) return 0; + const char *p = path; + while (*p) { + while (*p == '/') p++; + if (!*p) break; + const char *end = p; + while (*end && *end != '/') end++; + if ((end - p) == 2 && p[0] == '.' && p[1] == '.') { + return 1; + } + p = end; + } + return 0; +} -static void mkdirs_for_path(const char *path) { - char temp[PATH_MAX]; - strncpy(temp, path, sizeof(temp) - 1); - temp[sizeof(temp) - 1] = '\0'; - for (char *p = temp + 1; *p; p++) { +static const char *strip_tar_prefix(const char *path) { + if (!path) return ""; + const char *p = path; + while (*p) { if (*p == '/') { - *p = '\0'; - mkdir(temp, 0755); - *p = '/'; + p++; + } else if (p[0] == '.' && p[1] == '/') { + p += 2; + } else if (p[0] == '.' && p[1] == '\0') { + p += 1; + } else { + break; } } + return p; } static int is_tar_path_unsafe(const char *path) { if (!path || path[0] == '\0') return 0; - // Reject leading '/' if (path[0] == '/') return 1; - // Reject any occurrence of ".." - if (strstr(path, "..") != NULL) return 1; + if (has_dotdot_component(path)) return 1; + return 0; +} + +static int is_symlink_target_safe(const char *rel_entry_path, const char *linkname) { + if (!rel_entry_path || !linkname || linkname[0] == '\0') return 0; + if (linkname[0] == '/') return 0; + + int depth = 0; + const char *p = rel_entry_path; + const char *last_slash = strrchr(rel_entry_path, '/'); + if (last_slash) { + while (p < last_slash) { + while (p < last_slash && *p == '/') p++; + if (p >= last_slash) break; + const char *end = p; + while (end < last_slash && *end != '/') end++; + size_t len = (size_t)(end - p); + if (len == 2 && p[0] == '.' && p[1] == '.') { + return 0; + } else if (len == 1 && p[0] == '.') { + /* ignore */ + } else if (len > 0) { + depth++; + } + p = end; + } + } + + p = linkname; + while (*p) { + while (*p == '/') p++; + if (!*p) break; + const char *end = p; + while (*end && *end != '/') end++; + size_t len = (size_t)(end - p); + if (len == 2 && p[0] == '.' && p[1] == '.') { + depth--; + if (depth < 0) return 0; + } else if (len == 1 && p[0] == '.') { + /* ignore */ + } else if (len > 0) { + depth++; + } + p = end; + } + + return 1; +} + +static int is_within_dir(const char *real_root, const char *candidate) { + if (!real_root || !candidate) return 0; + size_t rlen = strlen(real_root); + if (rlen > 1 && real_root[rlen - 1] == '/') rlen--; + if (strncmp(candidate, real_root, rlen) != 0) return 0; + return (candidate[rlen] == '/' || candidate[rlen] == '\0'); +} + +static int mkdirs_for_path(const char *dest_dir, const char *dest_path) { + if (!dest_dir || !dest_path) return -1; + + char real_dest_dir[PATH_MAX]; + if (!realpath(dest_dir, real_dest_dir)) { + mkdir(dest_dir, 0755); + if (!realpath(dest_dir, real_dest_dir)) { + strncpy(real_dest_dir, dest_dir, sizeof(real_dest_dir) - 1); + real_dest_dir[sizeof(real_dest_dir) - 1] = '\0'; + } + } + + size_t dlen = strlen(dest_dir); + while (dlen > 1 && dest_dir[dlen - 1] == '/') dlen--; + if (strncmp(dest_path, dest_dir, dlen) != 0 || dest_path[dlen] != '/') { + return -1; + } + + char temp[PATH_MAX]; + size_t plen = strlen(dest_path); + if (plen >= sizeof(temp)) return -1; + memcpy(temp, dest_path, plen + 1); + + for (char *p = temp + dlen + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + struct stat st; + if (lstat(temp, &st) == 0) { + if (S_ISLNK(st.st_mode)) { + char link_buf[PATH_MAX]; + ssize_t llen = readlink(temp, link_buf, sizeof(link_buf) - 1); + if (llen < 0) return -1; + link_buf[llen] = '\0'; + if (link_buf[0] == '/' || has_dotdot_component(link_buf)) { + return -1; + } + char resolved[PATH_MAX]; + if (!realpath(temp, resolved) || !is_within_dir(real_dest_dir, resolved)) { + return -1; + } + } else if (!S_ISDIR(st.st_mode)) { + return -1; + } + } else { + if (mkdir(temp, 0755) != 0 && errno != EEXIST) { + return -1; + } + } + *p = '/'; + } + } + return 0; +} + +static ssize_t read_full(int fd, void *buf, size_t count) { + size_t total = 0; + char *p = (char *)buf; + while (total < count) { + ssize_t n = read(fd, p + total, count - total); + if (n < 0) { + if (errno == EINTR) continue; + return -1; + } + if (n == 0) break; + total += (size_t)n; + } + return (ssize_t)total; +} + +static int verify_tar_checksum(const char *block) { + const unsigned char *u = (const unsigned char *)block; + const signed char *s = (const signed char *)block; + const char *chk_ptr = block + 148; + while (chk_ptr < block + 156 && (*chk_ptr == ' ' || *chk_ptr == '\t')) chk_ptr++; + if (chk_ptr >= block + 156 || *chk_ptr < '0' || *chk_ptr > '7') return 0; + + char chk_buf[16]; + size_t rem = (size_t)((block + 156) - chk_ptr); + if (rem >= sizeof(chk_buf)) rem = sizeof(chk_buf) - 1; + memcpy(chk_buf, chk_ptr, rem); + chk_buf[rem] = '\0'; + + char *endptr = NULL; + unsigned long expected = strtoul(chk_buf, &endptr, 8); + if (endptr == chk_buf) return 0; + + unsigned long usum = 0; + long ssum = 0; + for (int i = 0; i < 512; i++) { + if (i >= 148 && i < 156) { + usum += ' '; + ssum += ' '; + } else { + usum += u[i]; + ssum += s[i]; + } + } + return (usum == expected || (ssum >= 0 && (unsigned long)ssum == expected)); +} + +static int parse_pax_headers(const char *buf, size_t len, + char *out_path, size_t path_max, + char *out_linkpath, size_t link_max, + unsigned long long *out_size, int *has_size) { + size_t pos = 0; + while (pos < len) { + if (buf[pos] == '\0') break; + const char *rec = buf + pos; + size_t rem = len - pos; + + size_t rec_len = 0; + size_t i = 0; + while (i < rem && rec[i] >= '0' && rec[i] <= '9') { + if (rec_len > (SIZE_MAX - 9) / 10) return -1; + rec_len = rec_len * 10 + (size_t)(rec[i] - '0'); + i++; + } + if (i == 0 || i >= rem || rec[i] != ' ' || rec_len <= i + 1 || rec_len > rem) { + return -1; + } + if (rec[rec_len - 1] != '\n') { + return -1; + } + + const char *kv = rec + i + 1; + size_t kv_len = rec_len - (i + 1) - 1; + + if (kv_len >= 5 && strncmp(kv, "path=", 5) == 0) { + size_t vlen = kv_len - 5; + if (vlen >= path_max) return -1; + if (out_path) { + memcpy(out_path, kv + 5, vlen); + out_path[vlen] = '\0'; + } + } else if (kv_len >= 9 && strncmp(kv, "linkpath=", 9) == 0) { + size_t vlen = kv_len - 9; + if (vlen >= link_max) return -1; + if (out_linkpath) { + memcpy(out_linkpath, kv + 9, vlen); + out_linkpath[vlen] = '\0'; + } + } else if (kv_len >= 5 && strncmp(kv, "size=", 5) == 0) { + size_t vlen = kv_len - 5; + if (vlen == 0 || vlen >= 32) return -1; + unsigned long long parsed = 0; + for (size_t k = 0; k < vlen; k++) { + char c = kv[5 + k]; + if (c < '0' || c > '9') return -1; + if (parsed > (ULLONG_MAX - 9ULL) / 10ULL) return -1; + parsed = parsed * 10ULL + (unsigned long long)(c - '0'); + } + if (out_size) *out_size = parsed; + if (has_size) *has_size = 1; + } + + pos += rec_len; + } return 0; } #define TAR_COPY_BUF_SIZE 65536 static int extract_tar_archive(const char *tar_path, const char *dest_dir) { - int fd = open(tar_path, O_RDONLY); + if (!tar_path || !dest_dir) return -1; + int fd = open(tar_path, O_RDONLY | O_CLOEXEC); if (fd < 0) return -1; char *copy_buf = malloc(TAR_COPY_BUF_SIZE); @@ -601,19 +850,129 @@ static int extract_tar_archive(const char *tar_path, const char *dest_dir) { char block[512]; char long_name[PATH_MAX] = {0}; char long_link[PATH_MAX] = {0}; + unsigned long long pax_size = 0; + int has_pax_size = 0; + + for (;;) { + ssize_t hdr_n = read_full(fd, block, 512); + if (hdr_n == 0) break; + if (hdr_n != 512) { + free(copy_buf); + close(fd); + return -1; + } - while (read(fd, block, 512) == 512) { int all_zero = 1; for (int i = 0; i < 512; i++) { if (block[i] != 0) { all_zero = 0; break; } } if (all_zero) break; + if (!verify_tar_checksum(block)) { + LOGE("Tar header checksum verification failed"); + free(copy_buf); + close(fd); + return -1; + } + + unsigned long mode = strtoul(block + 100, NULL, 8); + if (mode == 0) mode = 0755; + unsigned long long size = strtoull(block + 124, NULL, 8); + char typeflag = block[156]; + + if (size > ULLONG_MAX - 511ULL) { + free(copy_buf); + close(fd); + return -1; + } + + if (typeflag == 'L' || typeflag == 'K') { + char *dst_buf = (typeflag == 'L') ? long_name : long_link; + size_t dst_max = (typeflag == 'L') ? sizeof(long_name) : sizeof(long_link); + if (size == 0 || size >= dst_max) { + LOGE("Tar GNU LongName/LongLink size (%llu) exceeds PATH_MAX", size); + free(copy_buf); + close(fd); + return -1; + } + unsigned long long padded = ((size + 511ULL) / 512ULL) * 512ULL; + size_t pos = 0; + while (padded > 0) { + char dblock[512]; + if (read_full(fd, dblock, 512) != 512) { + free(copy_buf); + close(fd); + return -1; + } + size_t rem_payload = (size > pos) ? (size_t)(size - pos) : 0; + size_t chunk = (rem_payload < 512) ? rem_payload : 512; + if (chunk > 0) { + memcpy(dst_buf + pos, dblock, chunk); + pos += chunk; + } + padded -= 512ULL; + } + dst_buf[pos] = '\0'; + continue; + } + + if (typeflag == 'x' || typeflag == 'g') { + if (size > 1024ULL * 1024ULL) { + free(copy_buf); + close(fd); + return -1; + } + unsigned long long padded = ((size + 511ULL) / 512ULL) * 512ULL; + char *pax_buf = malloc((size_t)padded + 1); + if (!pax_buf) { + free(copy_buf); + close(fd); + return -1; + } + if (read_full(fd, pax_buf, (size_t)padded) != (ssize_t)padded) { + free(pax_buf); + free(copy_buf); + close(fd); + return -1; + } + pax_buf[size] = '\0'; + int rc = 0; + if (typeflag == 'x') { + rc = parse_pax_headers(pax_buf, (size_t)size, + long_name, sizeof(long_name), + long_link, sizeof(long_link), + &pax_size, &has_pax_size); + } else { + rc = parse_pax_headers(pax_buf, (size_t)size, + NULL, sizeof(long_name), + NULL, sizeof(long_link), + NULL, NULL); + } + free(pax_buf); + if (rc != 0) { + free(copy_buf); + close(fd); + return -1; + } + continue; + } + + if (has_pax_size) { + size = pax_size; + has_pax_size = 0; + if (size > ULLONG_MAX - 511ULL) { + free(copy_buf); + close(fd); + return -1; + } + } + char name[PATH_MAX] = {0}; char linkname[PATH_MAX] = {0}; if (long_name[0] != '\0') { strncpy(name, long_name, sizeof(name) - 1); + name[sizeof(name) - 1] = '\0'; long_name[0] = '\0'; } else { if (strncmp(block + 257, "ustar", 5) == 0 && block[345] != '\0') { @@ -625,132 +984,180 @@ static int extract_tar_archive(const char *tar_path, const char *dest_dir) { if (long_link[0] != '\0') { strncpy(linkname, long_link, sizeof(linkname) - 1); + linkname[sizeof(linkname) - 1] = '\0'; long_link[0] = '\0'; } else { snprintf(linkname, sizeof(linkname), "%.100s", block + 157); } - unsigned long mode = strtoul(block + 100, NULL, 8); - if (mode == 0) mode = 0755; - unsigned long long size = strtoull(block + 124, NULL, 8); - char typeflag = block[156]; - - if (typeflag == 'L') { - unsigned long long rem = size; - size_t pos = 0; - while (rem > 0) { - char dblock[512]; - ssize_t n = read(fd, dblock, 512); - if (n <= 0) break; - size_t chunk = (rem < 512) ? rem : 512; - if (pos + chunk < sizeof(long_name)) { - memcpy(long_name + pos, dblock, chunk); - pos += chunk; - } - rem -= (rem < 512) ? rem : 512; - } - long_name[pos] = '\0'; - continue; + if (is_tar_path_unsafe(name)) { + LOGE("Tar-Slip security guard: rejecting unsafe entry name='%s'", name); + free(copy_buf); + close(fd); + return -1; } - if (typeflag == 'K') { - unsigned long long rem = size; - size_t pos = 0; - while (rem > 0) { - char dblock[512]; - ssize_t n = read(fd, dblock, 512); - if (n <= 0) break; - size_t chunk = (rem < 512) ? rem : 512; - if (pos + chunk < sizeof(long_link)) { - memcpy(long_link + pos, dblock, chunk); - pos += chunk; + const char *rel = strip_tar_prefix(name); + if (*rel == '\0') { + unsigned long long padded = ((size + 511ULL) / 512ULL) * 512ULL; + while (padded > 0) { + size_t to_read = (padded < TAR_COPY_BUF_SIZE) ? (size_t)padded : TAR_COPY_BUF_SIZE; + if (read_full(fd, copy_buf, to_read) != (ssize_t)to_read) { + free(copy_buf); + close(fd); + return -1; } - rem -= (rem < 512) ? rem : 512; + padded -= to_read; } - long_link[pos] = '\0'; continue; } - if (typeflag == 'x' || typeflag == 'g') { - unsigned long long rem = size; - while (rem > 0) { - char dblock[512]; - ssize_t n = read(fd, dblock, 512); - if (n <= 0) break; - rem -= (rem < 512) ? rem : 512; - } - continue; + if (typeflag == '2' && !is_symlink_target_safe(rel, linkname)) { + LOGE("Tar-Slip security guard: rejecting escaping symlink name='%s' -> '%s'", name, linkname); + free(copy_buf); + close(fd); + return -1; } - // Tar-Slip path traversal protection: reject paths/symlink targets containing .. or leading / - if (is_tar_path_unsafe(name) || - ((typeflag == '1' || typeflag == '2') && is_tar_path_unsafe(linkname))) { - LOGE("Tar-Slip security guard: rejecting unsafe entry: name='%s', link='%s'", name, linkname); - unsigned long long rem = ((size + 511) / 512) * 512; - while (rem > 0) { - size_t to_read = (rem < TAR_COPY_BUF_SIZE) ? (size_t)rem : TAR_COPY_BUF_SIZE; - ssize_t n = read(fd, copy_buf, to_read); - if (n <= 0) break; - rem -= (size_t)n; - } - continue; + if (typeflag == '1' && is_tar_path_unsafe(linkname)) { + LOGE("Tar-Slip security guard: rejecting unsafe hardlink name='%s' -> '%s'", name, linkname); + free(copy_buf); + close(fd); + return -1; } - const char *rel = name; - while (*rel == '.' || *rel == '/') rel++; - if (*rel == '\0') continue; - char dest_path[PATH_MAX]; - snprintf(dest_path, sizeof(dest_path), "%s/%s", dest_dir, rel); + int dplen = snprintf(dest_path, sizeof(dest_path), "%s/%s", dest_dir, rel); + if (dplen < 0 || (size_t)dplen >= sizeof(dest_path)) { + free(copy_buf); + close(fd); + return -1; + } - mkdirs_for_path(dest_path); + if (mkdirs_for_path(dest_dir, dest_path) != 0) { + LOGE("Tar-Slip security guard: unsafe parent directory for '%s'", dest_path); + free(copy_buf); + close(fd); + return -1; + } - if (typeflag == '5' || (typeflag == '\0' && name[strlen(name) - 1] == '/')) { + size_t nlen = strlen(name); + if (typeflag == '5' || (typeflag == '\0' && nlen > 0 && name[nlen - 1] == '/')) { + struct stat dst_st; + if (lstat(dest_path, &dst_st) == 0 && S_ISLNK(dst_st.st_mode)) { + unlink(dest_path); + } mkdir(dest_path, mode & 0777); chmod(dest_path, (mode & 0777) | 0700); } else if (typeflag == '2') { unlink(dest_path); rmdir(dest_path); - symlink(linkname, dest_path); + if (symlink(linkname, dest_path) != 0) { + free(copy_buf); + close(fd); + return -1; + } } else if (typeflag == '1') { + const char *lrel = strip_tar_prefix(linkname); + if (*lrel == '\0' || has_dotdot_component(lrel)) { + free(copy_buf); + close(fd); + return -1; + } char target_path[PATH_MAX]; - const char *lrel = linkname; - while (*lrel == '.' || *lrel == '/') lrel++; - snprintf(target_path, sizeof(target_path), "%s/%s", dest_dir, lrel); + int tplen = snprintf(target_path, sizeof(target_path), "%s/%s", dest_dir, lrel); + if (tplen < 0 || (size_t)tplen >= sizeof(target_path) || + mkdirs_for_path(dest_dir, target_path) != 0) { + free(copy_buf); + close(fd); + return -1; + } unlink(dest_path); rmdir(dest_path); if (link(target_path, dest_path) != 0) { - symlink(target_path, dest_path); + char rel_link[PATH_MAX] = {0}; + size_t rpos = 0; + const char *rp = rel; + const char *last_slash = strrchr(rel, '/'); + if (last_slash) { + while (rp < last_slash) { + while (rp < last_slash && *rp == '/') rp++; + if (rp >= last_slash) break; + const char *rend = rp; + while (rend < last_slash && *rend != '/') rend++; + if ((rend - rp) > 0 && !((rend - rp) == 1 && rp[0] == '.')) { + if (rpos + 3 >= sizeof(rel_link)) { + free(copy_buf); + close(fd); + return -1; + } + memcpy(rel_link + rpos, "../", 3); + rpos += 3; + } + rp = rend; + } + } + size_t lrel_len = strlen(lrel); + if (rpos + lrel_len >= sizeof(rel_link)) { + free(copy_buf); + close(fd); + return -1; + } + memcpy(rel_link + rpos, lrel, lrel_len + 1); + if (symlink(rel_link, dest_path) != 0) { + free(copy_buf); + close(fd); + return -1; + } } } else { unlink(dest_path); rmdir(dest_path); - int out_fd = open(dest_path, O_WRONLY | O_CREAT | O_TRUNC, (mode & 0777) | 0600); + int out_fd = open(dest_path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, (mode & 0777) | 0600); + if (out_fd < 0) { + free(copy_buf); + close(fd); + return -1; + } unsigned long long rem_data = size; - unsigned long long total_to_read = ((size + 511) / 512) * 512; + unsigned long long total_to_read = ((size + 511ULL) / 512ULL) * 512ULL; while (total_to_read > 0) { size_t to_read = (total_to_read < TAR_COPY_BUF_SIZE) ? (size_t)total_to_read : TAR_COPY_BUF_SIZE; - ssize_t n = read(fd, copy_buf, to_read); - if (n <= 0) break; - total_to_read -= (size_t)n; + if (read_full(fd, copy_buf, to_read) != (ssize_t)to_read) { + close(out_fd); + free(copy_buf); + close(fd); + return -1; + } + total_to_read -= to_read; - size_t to_write = (rem_data < (unsigned long long)n) ? (size_t)rem_data : (size_t)n; - if (out_fd >= 0 && to_write > 0) { + size_t to_write = (rem_data < (unsigned long long)to_read) ? (size_t)rem_data : to_read; + if (to_write > 0) { size_t written = 0; while (written < to_write) { ssize_t w = write(out_fd, copy_buf + written, to_write - written); - if (w <= 0) break; + if (w < 0) { + if (errno == EINTR) continue; + close(out_fd); + free(copy_buf); + close(fd); + return -1; + } + if (w == 0) { + close(out_fd); + free(copy_buf); + close(fd); + return -1; + } written += (size_t)w; } + rem_data -= to_write; } - rem_data -= to_write; } - if (out_fd >= 0) { - close(out_fd); - chmod(dest_path, mode & 0777); - } + fchmod(out_fd, mode & 0777); + close(out_fd); } } @@ -759,6 +1166,7 @@ static int extract_tar_archive(const char *tar_path, const char *dest_dir) { return 0; } +#ifndef CORTEX_HOST_TEST JNIEXPORT jint JNICALL Java_org_cortex_terminal_pty_PtyNative_extractTar( JNIEnv *env, @@ -776,3 +1184,4 @@ Java_org_cortex_terminal_pty_PtyNative_extractTar( (*env)->ReleaseStringUTFChars(env, destDirStr, destDir); return res; } +#endif /* !CORTEX_HOST_TEST */ diff --git a/app/src/main/java/org/cortex/terminal/pty/PtyProcess.kt b/app/src/main/java/org/cortex/terminal/pty/PtyProcess.kt index d013074..f703b13 100644 --- a/app/src/main/java/org/cortex/terminal/pty/PtyProcess.kt +++ b/app/src/main/java/org/cortex/terminal/pty/PtyProcess.kt @@ -18,21 +18,29 @@ class PtyProcess private constructor( private val alive = AtomicBoolean(true) private val fdClosed = AtomicBoolean(false) + private val fdLock = Any() + private val processLock = Any() + @Volatile + private var reaped = false val isAlive: Boolean get() = alive.get() private fun closeMasterFdOnce() { - if (fdClosed.compareAndSet(false, true)) { - try { - pfd.close() - } catch (_: Exception) {} + synchronized(fdLock) { + if (fdClosed.compareAndSet(false, true)) { + try { + pfd.close() + } catch (_: Exception) {} + } } } fun resize(rows: Int, cols: Int, widthPx: Int, heightPx: Int) { - if (isAlive && masterFd >= 0) { - PtyNative.setPtyWindowSize(masterFd, rows, cols, widthPx, heightPx) + synchronized(fdLock) { + if (isAlive && !fdClosed.get() && masterFd >= 0) { + PtyNative.setPtyWindowSize(masterFd, rows, cols, widthPx, heightPx) + } } } @@ -40,14 +48,19 @@ class PtyProcess private constructor( try { return PtyNative.waitForProcess(pid) } finally { - alive.set(false) + synchronized(processLock) { + reaped = true + alive.set(false) + } closeMasterFdOnce() } } fun destroy() { - if (alive.compareAndSet(true, false)) { - PtyNative.killProcess(pid, 1) // SIGHUP + synchronized(processLock) { + if (!reaped && alive.compareAndSet(true, false)) { + PtyNative.killProcess(pid, 1) // SIGHUP + } } closeMasterFdOnce() } From f696eb9aa756920692532da952216431be214c87 Mon Sep 17 00:00:00 2001 From: woffluon Date: Tue, 6 Oct 2026 21:57:03 +0300 Subject: [PATCH 04/11] fix(issue-7): fix nss user lookup and symlinked elf origin resolution for package installation --- app/src/main/cpp/cortex-hook.c | 638 ++++++++++++++++++++++++++------- 1 file changed, 503 insertions(+), 135 deletions(-) diff --git a/app/src/main/cpp/cortex-hook.c b/app/src/main/cpp/cortex-hook.c index f0acd49..95fe96f 100644 --- a/app/src/main/cpp/cortex-hook.c +++ b/app/src/main/cpp/cortex-hook.c @@ -31,85 +31,162 @@ #include #include +#ifndef CORTEX_HOST_TEST + #ifndef SYS_SECCOMP #define SYS_SECCOMP 1 #endif +static int (*get_real_sigaction(void))(int, const struct sigaction *, struct sigaction *); + // Intercept SECCOMP blocked syscalls (SIGSYS), resolve sandbox/landlock gracefully, and advance PC static void cortex_sigsys_handler(int sig, siginfo_t *info, void *ctx) { - (void)sig; if (!ctx || !info) return; if (info->si_code != SYS_SECCOMP) return; ucontext_t *uctx = (ucontext_t *)ctx; int sys_nr = info->si_syscall; long ret_val = -ENOSYS; - - // Handle Landlock, seccomp, namespaces, and clone3 traps on Android - if (sys_nr == 444 /* __NR_landlock_create_ruleset */ || + int handled = 0; + + // Unified Linux 5.1+ syscall numbers across architectures + if (sys_nr == 424 /* __NR_pidfd_send_signal */ || + sys_nr == 425 /* __NR_io_uring_setup */ || + sys_nr == 426 /* __NR_io_uring_enter */ || + sys_nr == 427 /* __NR_io_uring_register */ || + sys_nr == 434 /* __NR_pidfd_open */ || + sys_nr == 435 /* __NR_clone3 */ || + sys_nr == 438 /* __NR_pidfd_getfd */ || + sys_nr == 444 /* __NR_landlock_create_ruleset */ || sys_nr == 445 /* __NR_landlock_add_rule */ || sys_nr == 446 /* __NR_landlock_restrict_self */) { - // Honest ENOSYS for all Landlock syscalls: returning ABI version 1 causes - // callers to assume Landlock is active, try to create rulesets, and either - // fail with unexpected errors or close fake fds causing EBADF cascades. - // Returning -ENOSYS allows callers (GNU tar, npm, audit, systemd) to cleanly - // skip sandboxing and take their normal standard code paths. ret_val = -ENOSYS; - } else if (sys_nr == 277 /* __NR_seccomp (arm64) */ || - sys_nr == 317 /* __NR_seccomp (x86_64) */ || - sys_nr == 383 /* __NR_seccomp (arm32) */) { - ret_val = 0; - } else if (sys_nr == 97 /* __NR_unshare (arm64) */ || - sys_nr == 272 /* __NR_unshare (x86_64) */ || - sys_nr == 337 /* __NR_unshare (arm32) */) { - ret_val = 0; - } else if (sys_nr == 435 /* __NR_clone3 */) { + handled = 1; + } + + // Architecture-specific syscall numbers guarded per target ABI +#if defined(__aarch64__) + if (sys_nr == 97 /* __NR_unshare */ || + sys_nr == 217 /* __NR_add_key */ || + sys_nr == 218 /* __NR_request_key */ || + sys_nr == 219 /* __NR_keyctl */ || + sys_nr == 268 /* __NR_setns */ || + sys_nr == 277 /* __NR_seccomp */ || + sys_nr == 293 /* __NR_rseq */) { + ret_val = -ENOSYS; + handled = 1; + } +#elif defined(__arm__) + if (sys_nr == 309 /* __NR_add_key */ || + sys_nr == 310 /* __NR_request_key */ || + sys_nr == 311 /* __NR_keyctl */ || + sys_nr == 337 /* __NR_unshare */ || + sys_nr == 375 /* __NR_setns */ || + sys_nr == 383 /* __NR_seccomp */ || + sys_nr == 398 /* __NR_rseq */) { + ret_val = -ENOSYS; + handled = 1; + } +#elif defined(__x86_64__) + if (sys_nr == 248 /* __NR_add_key */ || + sys_nr == 249 /* __NR_request_key */ || + sys_nr == 250 /* __NR_keyctl */ || + sys_nr == 272 /* __NR_unshare */ || + sys_nr == 308 /* __NR_setns */ || + sys_nr == 317 /* __NR_seccomp */ || + sys_nr == 334 /* __NR_rseq */) { + ret_val = -ENOSYS; + handled = 1; + } +#elif defined(__i386__) + if (sys_nr == 286 /* __NR_add_key */ || + sys_nr == 287 /* __NR_request_key */ || + sys_nr == 288 /* __NR_keyctl */ || + sys_nr == 310 /* __NR_unshare */ || + sys_nr == 346 /* __NR_setns */ || + sys_nr == 354 /* __NR_seccomp */ || + sys_nr == 386 /* __NR_rseq */) { ret_val = -ENOSYS; + handled = 1; + } +#endif + + if (!handled) { + int (*real_sig)(int, const struct sigaction *, struct sigaction *) = get_real_sigaction(); + if (real_sig) { + struct sigaction sa_dfl; + memset(&sa_dfl, 0, sizeof(sa_dfl)); + sa_dfl.sa_handler = SIG_DFL; + sigemptyset(&sa_dfl.sa_mask); + real_sig(sig, &sa_dfl, NULL); + } + raise(sig); + return; } #if defined(__aarch64__) - if (uctx->uc_mcontext.pc != 0) { - uint32_t insn = 0; - memcpy(&insn, (const void *)uctx->uc_mcontext.pc, sizeof(insn)); - // In AArch64, svc #0 is 0xd4000001 (bits [31:21]=0xd40, imm16=0, bits [4:0]=1) - if (insn == 0xd4000001 || (insn & 0xffe0001f) == 0xd4000001) { - uctx->uc_mcontext.regs[0] = ret_val; + uctx->uc_mcontext.regs[0] = (uint64_t)ret_val; + if (uctx->uc_mcontext.pc >= 4) { + uint32_t insn_at_pc = 0; + uint32_t insn_before_pc = 0; + memcpy(&insn_at_pc, (const void *)uctx->uc_mcontext.pc, sizeof(insn_at_pc)); + memcpy(&insn_before_pc, (const void *)(uctx->uc_mcontext.pc - 4), sizeof(insn_before_pc)); + // On SECCOMP_RET_TRAP, the kernel already advanced PC past svc #0 (*(pc - 4) is svc #0). + // Only advance PC if PC itself points to svc #0 and *(pc - 4) does not. + int at_is_svc = ((insn_at_pc & 0xffe0001fU) == 0xd4000001U); + int prev_is_svc = ((insn_before_pc & 0xffe0001fU) == 0xd4000001U); + if (at_is_svc && !prev_is_svc) { uctx->uc_mcontext.pc += 4; } } #elif defined(__arm__) - if (uctx->uc_mcontext.arm_pc != 0) { + uctx->uc_mcontext.arm_r0 = (unsigned long)ret_val; + if (uctx->uc_mcontext.arm_pc >= 4) { if (uctx->uc_mcontext.arm_cpsr & 0x20) { - uint16_t insn = 0; - memcpy(&insn, (const void *)uctx->uc_mcontext.arm_pc, sizeof(insn)); - if (insn == 0xdf00 || (insn & 0xff00) == 0xdf00) { - uctx->uc_mcontext.arm_r0 = ret_val; + uint16_t insn_at_pc = 0; + uint16_t insn_before_pc = 0; + memcpy(&insn_at_pc, (const void *)uctx->uc_mcontext.arm_pc, sizeof(insn_at_pc)); + memcpy(&insn_before_pc, (const void *)(uctx->uc_mcontext.arm_pc - 2), sizeof(insn_before_pc)); + int at_is_svc = ((insn_at_pc & 0xff00U) == 0xdf00U); + int prev_is_svc = ((insn_before_pc & 0xff00U) == 0xdf00U); + if (at_is_svc && !prev_is_svc) { uctx->uc_mcontext.arm_pc += 2; } } else { - uint32_t insn = 0; - memcpy(&insn, (const void *)uctx->uc_mcontext.arm_pc, sizeof(insn)); - if (insn == 0xef000000 || (insn & 0x0f000000) == 0x0f000000) { - uctx->uc_mcontext.arm_r0 = ret_val; + uint32_t insn_at_pc = 0; + uint32_t insn_before_pc = 0; + memcpy(&insn_at_pc, (const void *)uctx->uc_mcontext.arm_pc, sizeof(insn_at_pc)); + memcpy(&insn_before_pc, (const void *)(uctx->uc_mcontext.arm_pc - 4), sizeof(insn_before_pc)); + int at_is_svc = ((insn_at_pc & 0x0f000000U) == 0x0f000000U); + int prev_is_svc = ((insn_before_pc & 0x0f000000U) == 0x0f000000U); + if (at_is_svc && !prev_is_svc) { uctx->uc_mcontext.arm_pc += 4; } } } #elif defined(__x86_64__) && defined(REG_RAX) - if (uctx->uc_mcontext.gregs[REG_RIP] != 0) { - unsigned char insn[2] = {0}; - memcpy(insn, (const void *)uctx->uc_mcontext.gregs[REG_RIP], sizeof(insn)); - if (insn[0] == 0x0f && insn[1] == 0x05) { // syscall opcode - uctx->uc_mcontext.gregs[REG_RAX] = ret_val; + uctx->uc_mcontext.gregs[REG_RAX] = (greg_t)ret_val; + if (uctx->uc_mcontext.gregs[REG_RIP] >= 2) { + unsigned char insn_at[2] = {0}; + unsigned char insn_prev[2] = {0}; + memcpy(insn_at, (const void *)uctx->uc_mcontext.gregs[REG_RIP], sizeof(insn_at)); + memcpy(insn_prev, (const void *)(uctx->uc_mcontext.gregs[REG_RIP] - 2), sizeof(insn_prev)); + int at_is_sys = (insn_at[0] == 0x0f && insn_at[1] == 0x05); + int prev_is_sys = (insn_prev[0] == 0x0f && insn_prev[1] == 0x05); + if (at_is_sys && !prev_is_sys) { uctx->uc_mcontext.gregs[REG_RIP] += 2; } } #elif defined(__i386__) && defined(REG_EAX) - if (uctx->uc_mcontext.gregs[REG_EIP] != 0) { - unsigned char insn[2] = {0}; - memcpy(insn, (const void *)uctx->uc_mcontext.gregs[REG_EIP], sizeof(insn)); - if (insn[0] == 0xcd && insn[1] == 0x80) { // int 0x80 opcode - uctx->uc_mcontext.gregs[REG_EAX] = ret_val; + uctx->uc_mcontext.gregs[REG_EAX] = (greg_t)ret_val; + if (uctx->uc_mcontext.gregs[REG_EIP] >= 2) { + unsigned char insn_at[2] = {0}; + unsigned char insn_prev[2] = {0}; + memcpy(insn_at, (const void *)uctx->uc_mcontext.gregs[REG_EIP], sizeof(insn_at)); + memcpy(insn_prev, (const void *)(uctx->uc_mcontext.gregs[REG_EIP] - 2), sizeof(insn_prev)); + int at_is_sys = (insn_at[0] == 0xcd && insn_at[1] == 0x80); + int prev_is_sys = (insn_prev[0] == 0xcd && insn_prev[1] == 0x80); + if (at_is_sys && !prev_is_sys) { uctx->uc_mcontext.gregs[REG_EIP] += 2; } } @@ -1265,18 +1342,52 @@ int eaccess(const char *pathname, int mode) { int chmod(const char *pathname, mode_t mode) { static int (*orig_chmod)(const char *, mode_t) = NULL; if (!orig_chmod) orig_chmod = (int (*)(const char *, mode_t))dlsym(RTLD_NEXT, "chmod"); + if (!orig_chmod) return -1; char buf[PATH_MAX]; const char *target = rewrite_path(pathname, buf, sizeof(buf)); - return orig_chmod(target, mode); + int ret = orig_chmod(target, mode); + if (ret != 0 && ((mode & 06000) != 0 || errno == EPERM || errno == EACCES || errno == ENOSYS)) { + ret = orig_chmod(target, mode & 01777); + } + if (ret != 0 && (errno == EPERM || errno == EACCES)) { + errno = 0; + return 0; + } + return ret; +} + +// Hook fchmod +int fchmod(int fd, mode_t mode) { + static int (*orig_fchmod)(int, mode_t) = NULL; + if (!orig_fchmod) orig_fchmod = (int (*)(int, mode_t))dlsym(RTLD_NEXT, "fchmod"); + if (!orig_fchmod) return -1; + int ret = orig_fchmod(fd, mode); + if (ret != 0 && ((mode & 06000) != 0 || errno == EPERM || errno == EACCES || errno == ENOSYS)) { + ret = orig_fchmod(fd, mode & 01777); + } + if (ret != 0 && (errno == EPERM || errno == EACCES)) { + errno = 0; + return 0; + } + return ret; } // Hook fchmodat int fchmodat(int dirfd, const char *pathname, mode_t mode, int flags) { static int (*orig_fchmodat)(int, const char *, mode_t, int) = NULL; if (!orig_fchmodat) orig_fchmodat = (int (*)(int, const char *, mode_t, int))dlsym(RTLD_NEXT, "fchmodat"); + if (!orig_fchmodat) return -1; char buf[PATH_MAX]; const char *target = (pathname && pathname[0] == '/') ? rewrite_path(pathname, buf, sizeof(buf)) : pathname; - return orig_fchmodat(dirfd, target, mode, flags); + int ret = orig_fchmodat(dirfd, target, mode, flags); + if (ret != 0 && ((mode & 06000) != 0 || errno == EPERM || errno == EACCES || errno == ENOSYS)) { + ret = orig_fchmodat(dirfd, target, mode & 01777, flags); + } + if (ret != 0 && (errno == EPERM || errno == EACCES)) { + errno = 0; + return 0; + } + return ret; } // Hook unlink @@ -1738,124 +1849,194 @@ int getgrouplist(const char *user, gid_t group, gid_t *groups, int *ngroups) { return 1; } -struct group *getgrgid(gid_t gid) { +#endif /* !CORTEX_HOST_TEST */ + +static uid_t real_uid(void) { +#ifdef CORTEX_HOST_TEST + return 10000; +#else + static uid_t (*orig_getuid)(void) = NULL; + if (!orig_getuid) orig_getuid = (uid_t (*)(void))dlsym(RTLD_NEXT, "getuid"); + return orig_getuid ? orig_getuid() : 0; +#endif +} + +static gid_t real_gid(void) { +#ifdef CORTEX_HOST_TEST + return 10000; +#else + static gid_t (*orig_getgid)(void) = NULL; + if (!orig_getgid) orig_getgid = (gid_t (*)(void))dlsym(RTLD_NEXT, "getgid"); + return orig_getgid ? orig_getgid() : 0; +#endif +} + +#ifdef CORTEX_HOST_TEST +#define CORTEX_NSS_SYM(name) hook_##name +#else +#define CORTEX_NSS_SYM(name) name +#endif + +struct group *CORTEX_NSS_SYM(getgrgid)(gid_t gid) { +#ifndef CORTEX_HOST_TEST static struct group *(*orig_getgrgid)(gid_t) = NULL; if (!orig_getgrgid) orig_getgrgid = (struct group *(*)(gid_t))dlsym(RTLD_NEXT, "getgrgid"); struct group *res = orig_getgrgid ? orig_getgrgid(gid) : NULL; if (res) return res; +#endif + + if (gid != 0 && gid != real_gid()) { + return NULL; + } static struct group s_grp; - static char *s_mem[] = {"root", "cortex", NULL}; - s_grp.gr_name = (gid == 0) ? "root" : "cortex"; + static char *s_mem[] = {"root", NULL}; + s_grp.gr_name = "root"; s_grp.gr_passwd = "x"; s_grp.gr_gid = gid; s_grp.gr_mem = s_mem; return &s_grp; } -struct group *getgrnam(const char *name) { +struct group *CORTEX_NSS_SYM(getgrnam)(const char *name) { +#ifndef CORTEX_HOST_TEST static struct group *(*orig_getgrnam)(const char *) = NULL; if (!orig_getgrnam) orig_getgrnam = (struct group *(*)(const char *))dlsym(RTLD_NEXT, "getgrnam"); struct group *res = orig_getgrnam ? orig_getgrnam(name) : NULL; if (res) return res; +#endif + + if (!name || strcmp(name, "root") != 0) { + return NULL; + } static struct group s_grp; - static char *s_mem[] = {"root", "cortex", NULL}; - s_grp.gr_name = (char *)(name ? name : "root"); + static char *s_mem[] = {"root", NULL}; + s_grp.gr_name = "root"; s_grp.gr_passwd = "x"; s_grp.gr_gid = 0; s_grp.gr_mem = s_mem; return &s_grp; } -int getgrgid_r(gid_t gid, struct group *grp, char *buf, size_t buflen, struct group **result) { +int CORTEX_NSS_SYM(getgrgid_r)(gid_t gid, struct group *grp, char *buf, size_t buflen, struct group **result) { +#ifndef CORTEX_HOST_TEST static int (*orig_getgrgid_r)(gid_t, struct group *, char *, size_t, struct group **) = NULL; if (!orig_getgrgid_r) orig_getgrgid_r = (int (*)(gid_t, struct group *, char *, size_t, struct group **))dlsym(RTLD_NEXT, "getgrgid_r"); int ret = orig_getgrgid_r ? orig_getgrgid_r(gid, grp, buf, buflen, result) : -1; if (ret == 0 && result && *result != NULL) return 0; +#endif + + if (gid != 0 && gid != real_gid()) { + if (result) *result = NULL; + return 0; + } if (!grp || !buf || buflen < 64) { if (result) *result = NULL; return ERANGE; } - const char *gname = (gid == 0) ? "root" : "cortex"; - snprintf(buf, buflen, "%s", gname); + snprintf(buf, buflen, "root"); grp->gr_name = buf; grp->gr_passwd = "x"; grp->gr_gid = gid; - static char *s_members[] = {"root", "cortex", NULL}; + static char *s_members[] = {"root", NULL}; grp->gr_mem = s_members; if (result) *result = grp; return 0; } -int getgrnam_r(const char *name, struct group *grp, char *buf, size_t buflen, struct group **result) { +int CORTEX_NSS_SYM(getgrnam_r)(const char *name, struct group *grp, char *buf, size_t buflen, struct group **result) { +#ifndef CORTEX_HOST_TEST static int (*orig_getgrnam_r)(const char *, struct group *, char *, size_t, struct group **) = NULL; if (!orig_getgrnam_r) orig_getgrnam_r = (int (*)(const char *, struct group *, char *, size_t, struct group **))dlsym(RTLD_NEXT, "getgrnam_r"); int ret = orig_getgrnam_r ? orig_getgrnam_r(name, grp, buf, buflen, result) : -1; if (ret == 0 && result && *result != NULL) return 0; +#endif + + if (!name || strcmp(name, "root") != 0) { + if (result) *result = NULL; + return 0; + } if (!grp || !buf || buflen < 64) { if (result) *result = NULL; return ERANGE; } - const char *gname = (name && name[0]) ? name : "root"; - snprintf(buf, buflen, "%s", gname); + snprintf(buf, buflen, "root"); grp->gr_name = buf; grp->gr_passwd = "x"; grp->gr_gid = 0; - static char *s_members[] = {"root", "cortex", NULL}; + static char *s_members[] = {"root", NULL}; grp->gr_mem = s_members; if (result) *result = grp; return 0; } -struct passwd *getpwuid(uid_t uid) { +struct passwd *CORTEX_NSS_SYM(getpwuid)(uid_t uid) { +#ifndef CORTEX_HOST_TEST static struct passwd *(*orig_getpwuid)(uid_t) = NULL; if (!orig_getpwuid) orig_getpwuid = (struct passwd *(*)(uid_t))dlsym(RTLD_NEXT, "getpwuid"); struct passwd *res = orig_getpwuid ? orig_getpwuid(uid) : NULL; if (res) return res; +#endif + + if (uid != 0 && uid != real_uid()) { + return NULL; + } static struct passwd s_pwd; - s_pwd.pw_name = (uid == 0) ? "root" : "cortex"; + s_pwd.pw_name = "root"; s_pwd.pw_passwd = "x"; s_pwd.pw_uid = uid; s_pwd.pw_gid = 0; - s_pwd.pw_gecos = (uid == 0) ? "root" : "cortex"; + s_pwd.pw_gecos = "root"; s_pwd.pw_dir = "/home"; s_pwd.pw_shell = "/bin/bash"; return &s_pwd; } -struct passwd *getpwnam(const char *name) { +struct passwd *CORTEX_NSS_SYM(getpwnam)(const char *name) { +#ifndef CORTEX_HOST_TEST static struct passwd *(*orig_getpwnam)(const char *) = NULL; if (!orig_getpwnam) orig_getpwnam = (struct passwd *(*)(const char *))dlsym(RTLD_NEXT, "getpwnam"); struct passwd *res = orig_getpwnam ? orig_getpwnam(name) : NULL; if (res) return res; +#endif + + if (!name || strcmp(name, "root") != 0) { + return NULL; + } static struct passwd s_pwd; - s_pwd.pw_name = (char *)(name ? name : "root"); + s_pwd.pw_name = "root"; s_pwd.pw_passwd = "x"; s_pwd.pw_uid = 0; s_pwd.pw_gid = 0; - s_pwd.pw_gecos = s_pwd.pw_name; + s_pwd.pw_gecos = "root"; s_pwd.pw_dir = "/home"; s_pwd.pw_shell = "/bin/bash"; return &s_pwd; } -int getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, struct passwd **result) { +int CORTEX_NSS_SYM(getpwuid_r)(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, struct passwd **result) { +#ifndef CORTEX_HOST_TEST static int (*orig_getpwuid_r)(uid_t, struct passwd *, char *, size_t, struct passwd **) = NULL; if (!orig_getpwuid_r) orig_getpwuid_r = (int (*)(uid_t, struct passwd *, char *, size_t, struct passwd **))dlsym(RTLD_NEXT, "getpwuid_r"); int ret = orig_getpwuid_r ? orig_getpwuid_r(uid, pwd, buf, buflen, result) : -1; if (ret == 0 && result && *result != NULL) return 0; +#endif + + if (uid != 0 && uid != real_uid()) { + if (result) *result = NULL; + return 0; + } if (!pwd || !buf || buflen < 128) { if (result) *result = NULL; return ERANGE; } - const char *pname = (uid == 0) ? "root" : "cortex"; - snprintf(buf, buflen, "%s", pname); + snprintf(buf, buflen, "root"); pwd->pw_name = buf; pwd->pw_passwd = "x"; pwd->pw_uid = uid; @@ -1867,18 +2048,24 @@ int getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, struct p return 0; } -int getpwnam_r(const char *name, struct passwd *pwd, char *buf, size_t buflen, struct passwd **result) { +int CORTEX_NSS_SYM(getpwnam_r)(const char *name, struct passwd *pwd, char *buf, size_t buflen, struct passwd **result) { +#ifndef CORTEX_HOST_TEST static int (*orig_getpwnam_r)(const char *, struct passwd *, char *, size_t, struct passwd **) = NULL; if (!orig_getpwnam_r) orig_getpwnam_r = (int (*)(const char *, struct passwd *, char *, size_t, struct passwd **))dlsym(RTLD_NEXT, "getpwnam_r"); int ret = orig_getpwnam_r ? orig_getpwnam_r(name, pwd, buf, buflen, result) : -1; if (ret == 0 && result && *result != NULL) return 0; +#endif + + if (!name || strcmp(name, "root") != 0) { + if (result) *result = NULL; + return 0; + } if (!pwd || !buf || buflen < 128) { if (result) *result = NULL; return ERANGE; } - const char *pname = (name && name[0]) ? name : "root"; - snprintf(buf, buflen, "%s", pname); + snprintf(buf, buflen, "root"); pwd->pw_name = buf; pwd->pw_passwd = "x"; pwd->pw_uid = 0; @@ -1890,6 +2077,8 @@ int getpwnam_r(const char *name, struct passwd *pwd, char *buf, size_t buflen, s return 0; } +#ifndef CORTEX_HOST_TEST + int chown(const char *pathname, uid_t owner, gid_t group) { (void)pathname; (void)owner; (void)group; return 0; } int fchown(int fd, uid_t owner, gid_t group) { (void)fd; (void)owner; (void)group; return 0; } int lchown(const char *pathname, uid_t owner, gid_t group) { (void)pathname; (void)owner; (void)group; return 0; } @@ -1964,8 +2153,23 @@ int syncfs(int fd) { (void)fd; return 0; } -int capget(void *hdrp, void *datap) { (void)hdrp; (void)datap; return 0; } + +int capget(void *hdrp, void *datap) { + static int (*orig_capget)(void *, void *) = NULL; + if (!orig_capget) orig_capget = (int (*)(void *, void *))dlsym(RTLD_NEXT, "capget"); + if (orig_capget && orig_capget(hdrp, datap) == 0) { + return 0; + } + if (datap) { + uint32_t ver = hdrp ? *(const uint32_t *)hdrp : 0; + size_t nbytes = (ver == 0x20071026U || ver == 0x20080522U) ? 24 : 12; + memset(datap, 0, nbytes); + } + errno = 0; + return 0; +} int capset(void *hdrp, const void *datap) { (void)hdrp; (void)datap; return 0; } + int prctl(int option, ...) { va_list ap; va_start(ap, option); @@ -1987,9 +2191,9 @@ int prctl(int option, ...) { // PR_SET_SECCOMP = 22, PR_GET_SECCOMP = 21 #ifdef PR_SET_SECCOMP - if (option == PR_SET_SECCOMP) return 0; + if (option == PR_SET_SECCOMP) { errno = EINVAL; return -1; } #endif - if (option == 22) return 0; + if (option == 22) { errno = EINVAL; return -1; } #ifdef PR_GET_SECCOMP if (option == PR_GET_SECCOMP) return 0; #endif @@ -2125,24 +2329,28 @@ int landlock_restrict_self(int ruleset_fd, uint32_t flags) { int seccomp(unsigned int operation, unsigned int flags, void *args) { (void)flags; (void)args; - if (operation == SECCOMP_GET_ACTION_AVAIL) { - return 0; - } - if (operation == SECCOMP_SET_MODE_FILTER || operation == SECCOMP_SET_MODE_STRICT) { - return 0; + if (operation != SECCOMP_SET_MODE_STRICT && + operation != SECCOMP_SET_MODE_FILTER && + operation != SECCOMP_GET_ACTION_AVAIL && + operation != 3 /* SECCOMP_GET_NOTIF_SIZES */) { + errno = EINVAL; + return -1; } - return 0; + errno = ENOSYS; + return -1; } int unshare(int flags) { (void)flags; - return 0; + errno = ENOSYS; + return -1; } int setns(int fd, int nstype) { (void)fd; (void)nstype; - return 0; + errno = ENOSYS; + return -1; } #ifndef CLOSE_RANGE_CLOEXEC @@ -2737,11 +2945,19 @@ static void free_modified_env(char **modified_env, char *const orig_env[]) { free(modified_env); } +#endif /* !CORTEX_HOST_TEST */ + static char s_cached_ld_so[PATH_MAX] = {0}; static char s_cached_ld_root[PATH_MAX] = {0}; static uint16_t s_cached_e_machine = 0; -static int find_dynamic_linker(const char *cortex_root, const char *cmd, char *out_ld_so, size_t max_len) { +#ifdef CORTEX_HOST_TEST +#define CORTEX_FIND_LD_SYM hook_find_dynamic_linker +#else +#define CORTEX_FIND_LD_SYM find_dynamic_linker +#endif + +static int CORTEX_FIND_LD_SYM(const char *cortex_root, const char *cmd, char *out_ld_so, size_t max_len) { if (!cortex_root || cortex_root[0] == '\0' || !out_ld_so || max_len == 0) return 0; uint16_t e_machine = 0; @@ -2758,7 +2974,7 @@ static int find_dynamic_linker(const char *cortex_root, const char *cmd, char *o } if (s_cached_ld_so[0] != '\0' && strcmp(s_cached_ld_root, cortex_root) == 0 && - (e_machine == 0 || e_machine == s_cached_e_machine)) { + e_machine != 0 && e_machine == s_cached_e_machine) { strncpy(out_ld_so, s_cached_ld_so, max_len - 1); out_ld_so[max_len - 1] = '\0'; return 1; @@ -2801,12 +3017,16 @@ static int find_dynamic_linker(const char *cortex_root, const char *cmd, char *o if (e_machine == 183) { // EM_AARCH64 primary = aarch64_cands; - secondary = armhf_cands; + secondary = NULL; } else if (e_machine == 40) { // EM_ARM primary = armhf_cands; - secondary = aarch64_cands; + secondary = NULL; } else if (e_machine == 62) { // EM_X86_64 primary = x86_64_cands; + secondary = NULL; + } else if (e_machine == 3) { // EM_386 + primary = NULL; + secondary = NULL; } else { #if defined(__aarch64__) primary = aarch64_cands; @@ -2823,11 +3043,13 @@ static int find_dynamic_linker(const char *cortex_root, const char *cmd, char *o for (int i = 0; primary[i] != NULL; i++) { snprintf(out_ld_so, max_len, "%s%s", cortex_root, primary[i]); if (access(out_ld_so, F_OK) == 0) { - strncpy(s_cached_ld_so, out_ld_so, sizeof(s_cached_ld_so) - 1); - s_cached_ld_so[sizeof(s_cached_ld_so) - 1] = '\0'; - strncpy(s_cached_ld_root, cortex_root, sizeof(s_cached_ld_root) - 1); - s_cached_ld_root[sizeof(s_cached_ld_root) - 1] = '\0'; - s_cached_e_machine = e_machine; + if (e_machine != 0) { + strncpy(s_cached_ld_so, out_ld_so, sizeof(s_cached_ld_so) - 1); + s_cached_ld_so[sizeof(s_cached_ld_so) - 1] = '\0'; + strncpy(s_cached_ld_root, cortex_root, sizeof(s_cached_ld_root) - 1); + s_cached_ld_root[sizeof(s_cached_ld_root) - 1] = '\0'; + s_cached_e_machine = e_machine; + } return 1; } } @@ -2836,11 +3058,13 @@ static int find_dynamic_linker(const char *cortex_root, const char *cmd, char *o for (int i = 0; secondary[i] != NULL; i++) { snprintf(out_ld_so, max_len, "%s%s", cortex_root, secondary[i]); if (access(out_ld_so, F_OK) == 0) { - strncpy(s_cached_ld_so, out_ld_so, sizeof(s_cached_ld_so) - 1); - s_cached_ld_so[sizeof(s_cached_ld_so) - 1] = '\0'; - strncpy(s_cached_ld_root, cortex_root, sizeof(s_cached_ld_root) - 1); - s_cached_ld_root[sizeof(s_cached_ld_root) - 1] = '\0'; - s_cached_e_machine = e_machine; + if (e_machine != 0) { + strncpy(s_cached_ld_so, out_ld_so, sizeof(s_cached_ld_so) - 1); + s_cached_ld_so[sizeof(s_cached_ld_so) - 1] = '\0'; + strncpy(s_cached_ld_root, cortex_root, sizeof(s_cached_ld_root) - 1); + s_cached_ld_root[sizeof(s_cached_ld_root) - 1] = '\0'; + s_cached_e_machine = e_machine; + } return 1; } } @@ -2853,21 +3077,30 @@ static int check_elf_dynamic(int fd, const unsigned char *ehdr, ssize_t n) { if (n < 52 || ehdr[0] != 0x7f || ehdr[1] != 'E' || ehdr[2] != 'L' || ehdr[3] != 'F') { return 0; } + if ((ehdr[4] != 1 && ehdr[4] != 2) || ehdr[5] != 1 /* ELFDATA2LSB */) { + return 0; + } int is_64 = (ehdr[4] == 2); uint64_t phoff = 0; uint16_t phentsize = 0; uint16_t phnum = 0; if (is_64) { if (n < 64) return 0; - phoff = *(const uint64_t *)(ehdr + 32); - phentsize = *(const uint16_t *)(ehdr + 54); - phnum = *(const uint16_t *)(ehdr + 56); + memcpy(&phoff, ehdr + 32, sizeof(uint64_t)); + memcpy(&phentsize, ehdr + 54, sizeof(uint16_t)); + memcpy(&phnum, ehdr + 56, sizeof(uint16_t)); } else { - phoff = *(const uint32_t *)(ehdr + 28); - phentsize = *(const uint16_t *)(ehdr + 42); - phnum = *(const uint16_t *)(ehdr + 44); + uint32_t phoff32 = 0; + memcpy(&phoff32, ehdr + 28, sizeof(uint32_t)); + phoff = phoff32; + memcpy(&phentsize, ehdr + 42, sizeof(uint16_t)); + memcpy(&phnum, ehdr + 44, sizeof(uint16_t)); + } + uint16_t min_phentsize = is_64 ? 56 : 32; + if (phoff == 0 || phentsize < min_phentsize || phentsize > 4096 || phnum == 0) { + return 0; } - if (phoff == 0 || phentsize == 0 || phnum == 0) { + if ((off_t)phoff < 0 || (uint64_t)(off_t)phoff != phoff) { return 0; } if (lseek(fd, (off_t)phoff, SEEK_SET) < 0) { @@ -2884,6 +3117,58 @@ static int check_elf_dynamic(int fd, const unsigned char *ehdr, ssize_t n) { return 0; } +static int is_elf_binary(const char *path) { + if (!path || path[0] == '\0') return 0; + int fd = open(path, O_RDONLY); + if (fd < 0) return 0; + unsigned char magic[4]; + ssize_t n = read(fd, magic, sizeof(magic)); + close(fd); + return (n == 4 && magic[0] == 0x7f && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F'); +} + +static int is_ld_linux(const char *path) { + if (!path) return 0; + const char *base = strrchr(path, '/'); + base = base ? base + 1 : path; + return (strncmp(base, "ld-linux", 8) == 0 || strncmp(base, "ld-2.", 5) == 0); +} + +#ifndef CORTEX_HOST_TEST + +static void resolve_symlinks(const char *path, char *out, size_t out_size, int depth) { + if (!path || !out || out_size == 0) return; + strncpy(out, path, out_size - 1); + out[out_size - 1] = '\0'; + if (depth > 8) return; + + char link_target[PATH_MAX]; + ssize_t len = readlink(path, link_target, sizeof(link_target) - 1); + if (len < 0) return; + link_target[len] = '\0'; + + char next_path[PATH_MAX]; + if (link_target[0] == '/') { + char rw_buf[PATH_MAX]; + const char *rewritten = rewrite_path(link_target, rw_buf, sizeof(rw_buf)); + strncpy(next_path, rewritten, sizeof(next_path) - 1); + next_path[sizeof(next_path) - 1] = '\0'; + } else { + char dir_buf[PATH_MAX]; + strncpy(dir_buf, path, sizeof(dir_buf) - 1); + dir_buf[sizeof(dir_buf) - 1] = '\0'; + char *slash = strrchr(dir_buf, '/'); + if (slash) { + *slash = '\0'; + snprintf(next_path, sizeof(next_path), "%s/%s", dir_buf, link_target); + } else { + strncpy(next_path, link_target, sizeof(next_path) - 1); + next_path[sizeof(next_path) - 1] = '\0'; + } + } + resolve_symlinks(next_path, out, out_size, depth + 1); +} + static int has_pt_interp(const char *path) { if (!path || path[0] == '\0') return 0; int fd = open(path, O_RDONLY); @@ -2895,6 +3180,71 @@ static int has_pt_interp(const char *path) { return ret; } +static char **ensure_hook_in_envp(char *const envp[], const char *real_exe) { + return prepare_cortex_env(envp, real_exe); +} + +static void build_ld_library_path(char *const envp[], char *out_buf, size_t out_size) { + if (!out_buf || out_size == 0) return; + out_buf[0] = '\0'; + + const char *caller_ld = NULL; + if (envp) { + for (int i = 0; envp[i] != NULL; i++) { + if (strncmp(envp[i], "LD_LIBRARY_PATH=", 16) == 0) { + caller_ld = envp[i] + 16; + break; + } + } + } + if (!caller_ld) { + caller_ld = getenv("LD_LIBRARY_PATH"); + } + + char rewritten_caller[PATH_MAX * 2] = {0}; + if (caller_ld && caller_ld[0] != '\0') { + char copy[PATH_MAX * 2]; + strncpy(copy, caller_ld, sizeof(copy) - 1); + copy[sizeof(copy) - 1] = '\0'; + size_t used = 0; + char *saveptr = NULL; + char *tok = strtok_r(copy, ":", &saveptr); + while (tok) { + if (tok[0] != '\0') { + char rw_buf[PATH_MAX]; + const char *rw = (tok[0] == '/') ? rewrite_path(tok, rw_buf, sizeof(rw_buf)) : tok; + int n = snprintf(rewritten_caller + used, sizeof(rewritten_caller) - used, + "%s%s", (used > 0 ? ":" : ""), rw); + if (n > 0 && used + (size_t)n < sizeof(rewritten_caller)) { + used += (size_t)n; + } + } + tok = strtok_r(NULL, ":", &saveptr); + } + } + + if (rewritten_caller[0] != '\0') { + snprintf(out_buf, out_size, + "%s:%s/lib:%s/usr/lib:%s/lib/aarch64-linux-gnu:%s/usr/lib/aarch64-linux-gnu:" + "%s/usr/lib/aarch64-linux-gnu/systemd:%s/lib/aarch64-linux-gnu/systemd:" + "%s/usr/lib/systemd:%s/lib/systemd:%s/lib/arm-linux-gnueabihf:%s/usr/lib/arm-linux-gnueabihf:" + "%s/usr/lib/arm-linux-gnueabihf/systemd:%s/lib/arm-linux-gnueabihf/systemd:%s/usr/local/lib", + rewritten_caller, + g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, + g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, + g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root); + } else { + snprintf(out_buf, out_size, + "%s/lib:%s/usr/lib:%s/lib/aarch64-linux-gnu:%s/usr/lib/aarch64-linux-gnu:" + "%s/usr/lib/aarch64-linux-gnu/systemd:%s/lib/aarch64-linux-gnu/systemd:" + "%s/usr/lib/systemd:%s/lib/systemd:%s/lib/arm-linux-gnueabihf:%s/usr/lib/arm-linux-gnueabihf:" + "%s/usr/lib/arm-linux-gnueabihf/systemd:%s/lib/arm-linux-gnueabihf/systemd:%s/usr/local/lib", + g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, + g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, + g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root); + } +} + // Hook execve typedef int (*orig_execve_f_type)(const char *filename, char *const argv[], char *const envp[]); int execve(const char *filename, char *const argv[], char *const envp[]) { @@ -2960,7 +3310,14 @@ int execve(const char *filename, char *const argv[], char *const envp[]) { // Only intercept binaries/scripts within CORTEX_ROOT if (g_cortex_root[0] != '\0' && strncmp(target, g_cortex_root, strlen(g_cortex_root)) == 0) { - int fd = open(target, O_RDONLY); + char resolved_target[PATH_MAX]; + resolve_symlinks(target, resolved_target, sizeof(resolved_target), 0); + const char *elf_target = target; + if (is_elf_binary(resolved_target) && !is_ld_linux(resolved_target)) { + elf_target = resolved_target; + } + + int fd = open(elf_target, O_RDONLY); if (fd >= 0) { char hdr[256]; ssize_t n = read(fd, hdr, sizeof(hdr) - 1); @@ -2972,17 +3329,17 @@ int execve(const char *filename, char *const argv[], char *const envp[]) { close(fd); if (is_dynamic) { char ld_so[PATH_MAX] = {0}; - if (find_dynamic_linker(g_cortex_root, target, ld_so, sizeof(ld_so)) && strcmp(target, ld_so) != 0) { + if (find_dynamic_linker(g_cortex_root, elf_target, ld_so, sizeof(ld_so)) && strcmp(elf_target, ld_so) != 0) { static char s_last_chmoded_ld[PATH_MAX] = {0}; if (strcmp(s_last_chmoded_ld, ld_so) != 0) { chmod(ld_so, 0755); strncpy(s_last_chmoded_ld, ld_so, sizeof(s_last_chmoded_ld) - 1); } - if (access(target, X_OK) != 0) { - chmod(target, 0755); + if (access(elf_target, X_OK) != 0) { + chmod(elf_target, 0755); } - strncpy(g_real_exe, target, sizeof(g_real_exe) - 1); + strncpy(g_real_exe, elf_target, sizeof(g_real_exe) - 1); g_real_exe[sizeof(g_real_exe) - 1] = '\0'; char *const *arg_ptr = argv; @@ -3001,15 +3358,8 @@ int execve(const char *filename, char *const argv[], char *const envp[]) { snprintf(hook_so, sizeof(hook_so), "%s/lib/libcortex-hook.so", g_cortex_root); } - char ld_lib_path[PATH_MAX * 4] = {0}; - snprintf(ld_lib_path, sizeof(ld_lib_path), - "%s/lib:%s/usr/lib:%s/lib/aarch64-linux-gnu:%s/usr/lib/aarch64-linux-gnu:" - "%s/usr/lib/aarch64-linux-gnu/systemd:%s/lib/aarch64-linux-gnu/systemd:" - "%s/usr/lib/systemd:%s/lib/systemd:%s/lib/arm-linux-gnueabihf:%s/usr/lib/arm-linux-gnueabihf:" - "%s/usr/lib/arm-linux-gnueabihf/systemd:%s/lib/arm-linux-gnueabihf/systemd:%s/usr/local/lib", - g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, - g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, - g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root); + char ld_lib_path[PATH_MAX * 6] = {0}; + build_ld_library_path(envp, ld_lib_path, sizeof(ld_lib_path)); char **new_argv = (char **)calloc(argc + 16, sizeof(char *)); int nidx = 0; @@ -3022,12 +3372,12 @@ int execve(const char *filename, char *const argv[], char *const envp[]) { new_argv[nidx++] = ld_lib_path; new_argv[nidx++] = (char *)"--argv0"; new_argv[nidx++] = (char *)((argc > 0 && argv[0]) ? argv[0] : prog_name); - new_argv[nidx++] = (char *)target; + new_argv[nidx++] = (char *)elf_target; for (int i = 1; i < argc; i++) { new_argv[nidx++] = (char *)argv[i]; } new_argv[nidx] = NULL; - char **new_env = prepare_cortex_env(envp, target); + char **new_env = ensure_hook_in_envp(envp, elf_target); int ret = orig_execve(ld_so, new_argv, new_env); free(new_argv); free_modified_env(new_env, envp); @@ -3359,10 +3709,17 @@ int posix_spawn(pid_t *pid, const char *path, return errno; } + char resolved_target[PATH_MAX]; + resolve_symlinks(target, resolved_target, sizeof(resolved_target), 0); + const char *elf_target = target; + if (is_elf_binary(resolved_target) && !is_ld_linux(resolved_target)) { + elf_target = resolved_target; + } + int is_elf = 0; int is_dynamic = 0; if (g_cortex_root[0] != '\0' && strncmp(target, g_cortex_root, strlen(g_cortex_root)) == 0) { - int fd = open(target, O_RDONLY); + int fd = open(elf_target, O_RDONLY); if (fd >= 0) { unsigned char hdr[256]; ssize_t n = read(fd, hdr, sizeof(hdr)); @@ -3375,20 +3732,20 @@ int posix_spawn(pid_t *pid, const char *path, } char ld_so[PATH_MAX] = {0}; - int has_ld_so = (is_elf && is_dynamic && g_cortex_root[0] != '\0') ? find_dynamic_linker(g_cortex_root, target, ld_so, sizeof(ld_so)) : 0; + int has_ld_so = (is_elf && is_dynamic && g_cortex_root[0] != '\0') ? find_dynamic_linker(g_cortex_root, elf_target, ld_so, sizeof(ld_so)) : 0; char *const *orig_ep = envp ? envp : environ; - char **new_envp = prepare_cortex_env((char *const *)orig_ep, target); + char **new_envp = ensure_hook_in_envp((char *const *)orig_ep, elf_target); int ret = -1; - if (is_elf && has_ld_so && strcmp(target, ld_so) != 0) { + if (is_elf && has_ld_so && strcmp(elf_target, ld_so) != 0) { static char s_last_chmoded_ld_spawn[PATH_MAX] = {0}; if (strcmp(s_last_chmoded_ld_spawn, ld_so) != 0) { chmod(ld_so, 0755); strncpy(s_last_chmoded_ld_spawn, ld_so, sizeof(s_last_chmoded_ld_spawn) - 1); } - if (access(target, X_OK) != 0) { - chmod(target, 0755); + if (access(elf_target, X_OK) != 0) { + chmod(elf_target, 0755); } int argc = 0; @@ -3403,15 +3760,8 @@ int posix_spawn(pid_t *pid, const char *path, snprintf(hook_so, sizeof(hook_so), "%s/lib/libcortex-hook.so", g_cortex_root); } - char ld_lib_path[PATH_MAX * 4] = {0}; - snprintf(ld_lib_path, sizeof(ld_lib_path), - "%s/lib:%s/usr/lib:%s/lib/aarch64-linux-gnu:%s/usr/lib/aarch64-linux-gnu:" - "%s/usr/lib/aarch64-linux-gnu/systemd:%s/lib/aarch64-linux-gnu/systemd:" - "%s/usr/lib/systemd:%s/lib/systemd:%s/lib/arm-linux-gnueabihf:%s/usr/lib/arm-linux-gnueabihf:" - "%s/usr/lib/arm-linux-gnueabihf/systemd:%s/lib/arm-linux-gnueabihf/systemd:%s/usr/local/lib", - g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, - g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, - g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root, g_cortex_root); + char ld_lib_path[PATH_MAX * 6] = {0}; + build_ld_library_path((char *const *)orig_ep, ld_lib_path, sizeof(ld_lib_path)); char **new_argv = (char **)calloc(argc + 16, sizeof(char *)); if (new_argv) { @@ -3425,7 +3775,7 @@ int posix_spawn(pid_t *pid, const char *path, new_argv[nidx++] = ld_lib_path; new_argv[nidx++] = (char *)"--argv0"; new_argv[nidx++] = (char *)((argc > 0 && argv && argv[0]) ? argv[0] : prog_name); - new_argv[nidx++] = (char *)target; + new_argv[nidx++] = (char *)elf_target; for (int i = 1; i < argc; i++) { new_argv[nidx++] = (char *)argv[i]; } @@ -4326,3 +4676,21 @@ int getpeername(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { return ret; } +#ifndef AF_NETLINK +#define AF_NETLINK 16 +#endif +#ifndef NETLINK_AUDIT +#define NETLINK_AUDIT 9 +#endif + +int socket(int domain, int type, int protocol) { + static int (*orig_socket)(int, int, int) = NULL; + if (!orig_socket) orig_socket = (int (*)(int, int, int))dlsym(RTLD_NEXT, "socket"); + if (domain == AF_NETLINK && protocol == NETLINK_AUDIT) { + errno = EPROTONOSUPPORT; + return -1; + } + return orig_socket ? orig_socket(domain, type, protocol) : -1; +} + +#endif /* !CORTEX_HOST_TEST */ From 24526d177477a5260caa084ad4d377fee273dab0 Mon Sep 17 00:00:00 2001 From: woffluon Date: Tue, 6 Oct 2026 21:57:03 +0300 Subject: [PATCH 05/11] fix(shell): harden helper scripts and url opener ipc concurrency --- app/src/main/assets/scripts/reload.sh | 13 +- app/src/main/assets/scripts/service.sh | 214 +++++++++++-- app/src/main/assets/scripts/su.sh | 211 ++++++++----- app/src/main/assets/scripts/systemctl.sh | 24 +- app/src/main/assets/scripts/xdg-open.sh | 85 ++++- .../terminal/runtime/ShellScriptsInstaller.kt | 150 ++++++--- .../terminal/runtime/UrlOpenerServer.kt | 292 +++++++++++++----- 7 files changed, 729 insertions(+), 260 deletions(-) diff --git a/app/src/main/assets/scripts/reload.sh b/app/src/main/assets/scripts/reload.sh index 6d66b27..d8ec7cf 100644 --- a/app/src/main/assets/scripts/reload.sh +++ b/app/src/main/assets/scripts/reload.sh @@ -1,6 +1,11 @@ #!/bin/bash set -- -if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi -if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi -if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi -echo "Environment reloaded." +if (return 0 2>/dev/null); then + if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi + if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi + if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi + echo "Environment reloaded." +else + echo "Environment reloaded." + exec "${SHELL:-/bin/bash}" -l +fi diff --git a/app/src/main/assets/scripts/service.sh b/app/src/main/assets/scripts/service.sh index e88541a..7acae46 100644 --- a/app/src/main/assets/scripts/service.sh +++ b/app/src/main/assets/scripts/service.sh @@ -10,10 +10,14 @@ if [ -z "$SERVICE" ]; then exit 1 fi +INIT_DIR="${CORTEX_INIT_DIR:-/etc/init.d}" +RUN_DIR="${CORTEX_RUN_DIR:-/run}" +SERVICE_PATH="${CORTEX_SERVICE_PATH:-/usr/sbin:/usr/bin}" + if [ "$SERVICE" = "--status-all" ]; then echo " [ + ] Running services" echo " [ - ] Stopped services" - for initscript in /etc/init.d/*; do + for initscript in "$INIT_DIR"/*; do if [ -f "$initscript" ] && [ -x "$initscript" ]; then sname="$(basename "$initscript")" if [ "$sname" != "skeleton" ] && [ "$sname" != "rc" ]; then @@ -28,54 +32,206 @@ if [ "$SERVICE" = "--status-all" ]; then exit 0 fi -PIDFILE="/run/$SERVICE.pid" -mkdir -p /run /var/run +if [[ ! "$SERVICE" =~ ^[a-zA-Z0-9._-]+$ ]] || [ "$SERVICE" = "." ] || [ "$SERVICE" = ".." ]; then + echo "service: invalid service name '$SERVICE'" >&2 + exit 1 +fi -if [ -x "/etc/init.d/$SERVICE" ]; then - exec "/etc/init.d/$SERVICE" "$ACTION" "$@" +PIDFILE="$RUN_DIR/$SERVICE.pid" +mkdir -p "$RUN_DIR" /var/run 2>/dev/null || true + +if [ -x "$INIT_DIR/$SERVICE" ]; then + exec "$INIT_DIR/$SERVICE" "$ACTION" "$@" fi +DAEMON="" +OLD_IFS="$IFS" +IFS=":" +for dir in $SERVICE_PATH; do + if [ -n "$dir" ] && [ -x "$dir/$SERVICE" ]; then + DAEMON="$dir/$SERVICE" + break + fi +done +IFS="$OLD_IFS" + +is_service_process() { + local pid="$1" + local svc="$2" + local daemon="$3" + + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 + [ "$pid" != "$$" ] || return 1 + [ "$pid" != "${BASHPID:-$$}" ] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + + if [ -r "/proc/$pid/status" ]; then + local state_line + state_line="$(grep '^State:' "/proc/$pid/status" 2>/dev/null || true)" + case "$state_line" in + *Z*) return 1 ;; + esac + fi + + local daemon_base="" + if [ -n "$daemon" ]; then + daemon_base="$(basename "$daemon")" + fi + + if [ -L "/proc/$pid/exe" ]; then + local exe_path exe_base + exe_path="$(readlink -f "/proc/$pid/exe" 2>/dev/null || readlink "/proc/$pid/exe" 2>/dev/null || true)" + exe_path="${exe_path% (deleted)}" + if [ -n "$exe_path" ]; then + exe_base="$(basename "$exe_path" 2>/dev/null)" + if [ "$exe_base" = "$svc" ] || { [ -n "$daemon_base" ] && [ "$exe_base" = "$daemon_base" ]; }; then + return 0 + fi + fi + fi + + if [ -r "/proc/$pid/comm" ]; then + local comm_val="" + IFS= read -r comm_val < "/proc/$pid/comm" 2>/dev/null || true + if [ "$comm_val" = "$svc" ] || { [ -n "$daemon_base" ] && [ "$comm_val" = "$daemon_base" ]; }; then + return 0 + fi + fi + + if [ -r "/proc/$pid/cmdline" ]; then + local arg0="" arg1="" token="" idx=0 + while IFS= read -r -d '' token || [ -n "$token" ]; do + if [ "$idx" -eq 0 ]; then + arg0="$token" + elif [ "$idx" -eq 1 ]; then + arg1="$token" + break + fi + idx=$((idx + 1)) + token="" + done < "/proc/$pid/cmdline" + + if [ -n "$arg0" ]; then + local arg0_clean="${arg0#-}" + local arg0_base + arg0_base="$(basename "${arg0_clean%% *}" 2>/dev/null)" + if [ "$arg0_base" = "$svc" ] || { [ -n "$daemon_base" ] && [ "$arg0_base" = "$daemon_base" ]; }; then + return 0 + fi + + case "$arg0_base" in + sh|bash|dash|zsh|ksh|python|python2*|python3*|perl|ruby|node|nodejs|php) + if [ -n "$arg1" ]; then + if [ "$arg1" = "$INIT_DIR/$SERVICE" ] || [ "$arg1" = "/etc/init.d/$svc" ] || \ + { [ -n "$daemon" ] && [ "$arg1" = "$daemon" ]; } || \ + [ "$arg1" = "/usr/sbin/$svc" ] || [ "$arg1" = "/usr/bin/$svc" ]; then + return 0 + fi + fi + ;; + esac + fi + fi + + return 1 +} + +find_service_pids() { + local svc="$1" + local daemon="$2" + local proc_dir pid + for proc_dir in /proc/[0-9]*; do + [ -d "$proc_dir" ] || continue + pid="${proc_dir#/proc/}" + if is_service_process "$pid" "$svc" "$daemon"; then + printf '%s\n' "$pid" + fi + done +} + case "$ACTION" in start) - if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then - echo "Service $SERVICE is already running (PID $(cat "$PIDFILE"))." - exit 0 + if [ -f "$PIDFILE" ]; then + EXISTING_PID="$(cat "$PIDFILE" 2>/dev/null)" + if is_service_process "$EXISTING_PID" "$SERVICE" "$DAEMON"; then + echo "Service $SERVICE is already running (PID $EXISTING_PID)." + exit 0 + fi + rm -f "$PIDFILE" fi - DAEMON="" - for p in "/usr/sbin/$SERVICE" "/usr/bin/$SERVICE"; do - if [ -x "$p" ]; then DAEMON="$p"; break; fi - done if [ -n "$DAEMON" ]; then echo "Starting $SERVICE..." "$DAEMON" "$@" & - echo $! > "$PIDFILE" - echo "$SERVICE started with PID $!" + DAEMON_PID=$! + sleep 0.1 + if ! is_service_process "$DAEMON_PID" "$SERVICE" "$DAEMON"; then + FORKED_PID="$(find_service_pids "$SERVICE" "$DAEMON" | head -n 1)" + if [ -n "$FORKED_PID" ]; then + DAEMON_PID="$FORKED_PID" + else + wait "$DAEMON_PID" 2>/dev/null || true + rm -f "$PIDFILE" + echo "service: failed to start $SERVICE (daemon exited immediately)" >&2 + exit 1 + fi + fi + echo "$DAEMON_PID" > "$PIDFILE" + echo "$SERVICE started with PID $DAEMON_PID" else - echo "service: unrecognized service $SERVICE" + echo "service: unrecognized service $SERVICE" >&2 exit 1 fi ;; stop) + STOPPED=0 + TARGET_PIDS="" if [ -f "$PIDFILE" ]; then - PID=$(cat "$PIDFILE") - if kill -0 "$PID" 2>/dev/null; then - echo "Stopping $SERVICE (PID $PID)..." - kill "$PID" 2>/dev/null - rm -f "$PIDFILE" + PID="$(cat "$PIDFILE" 2>/dev/null)" + if is_service_process "$PID" "$SERVICE" "$DAEMON"; then + TARGET_PIDS="$PID" + fi + rm -f "$PIDFILE" + fi + if [ -z "$TARGET_PIDS" ]; then + TARGET_PIDS="$(find_service_pids "$SERVICE" "$DAEMON")" + fi + if [ -n "$TARGET_PIDS" ]; then + for pid in $TARGET_PIDS; do + if is_service_process "$pid" "$SERVICE" "$DAEMON"; then + echo "Stopping $SERVICE (PID $pid)..." + if kill "$pid" 2>/dev/null; then + STOPPED=1 + fi + fi + done + sleep 0.1 + for pid in $TARGET_PIDS; do + if is_service_process "$pid" "$SERVICE" "$DAEMON"; then + kill -9 "$pid" 2>/dev/null || true + fi + done + rm -f "$PIDFILE" + if [ "$STOPPED" -eq 1 ]; then echo "$SERVICE stopped." - else - rm -f "$PIDFILE" + exit 0 fi - else - pkill -f "$SERVICE" 2>/dev/null && echo "Stopped $SERVICE." || echo "$SERVICE is not running." fi + rm -f "$PIDFILE" + echo "$SERVICE is not running." + exit 0 ;; status) - if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then - echo "* $SERVICE is running (PID $(cat "$PIDFILE"))" - exit 0 - elif pgrep -f "$SERVICE" >/dev/null 2>&1; then - echo "* $SERVICE is running" + if [ -f "$PIDFILE" ]; then + PID="$(cat "$PIDFILE" 2>/dev/null)" + if is_service_process "$PID" "$SERVICE" "$DAEMON"; then + echo "* $SERVICE is running (PID $PID)" + exit 0 + fi + rm -f "$PIDFILE" + fi + LIVE_PID="$(find_service_pids "$SERVICE" "$DAEMON" | head -n 1)" + if [ -n "$LIVE_PID" ]; then + echo "* $SERVICE is running (PID $LIVE_PID)" exit 0 else echo "* $SERVICE is not running" diff --git a/app/src/main/assets/scripts/su.sh b/app/src/main/assets/scripts/su.sh index e289ba0..1fd150f 100644 --- a/app/src/main/assets/scripts/su.sh +++ b/app/src/main/assets/scripts/su.sh @@ -1,7 +1,26 @@ #!/bin/bash # Cortex Root Switcher (su / tsu / sudo) +sq() { printf "'%s'" "${1//\'/\'\\\'\'}"; } + +quote_args() { + local out="" arg + for arg in "$@"; do + if [ -n "$out" ]; then + out="$out $(sq "$arg")" + else + out="$(sq "$arg")" + fi + done + printf '%s' "$out" +} + find_host_su() { + if [ -n "${CORTEX_HOST_SU:-}" ]; then + echo "$CORTEX_HOST_SU" + return 0 + fi + for cand in \ /system/bin/su \ /system/xbin/su \ @@ -28,7 +47,7 @@ find_host_su() { fi for cand in /system/bin/su /system/xbin/su /sbin/su /data/adb/ap/bin/su /data/adb/ksu/bin/su /data/adb/magisk/su; do - if env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES PATH=/system/bin:/system/xbin:/sbin:/vendor/bin ANDROID_ROOT=/system ANDROID_DATA=/data $cand -v >/dev/null 2>&1 || \ + if env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES PATH=/system/bin:/system/xbin:/sbin:/vendor/bin ANDROID_ROOT=/system ANDROID_DATA=/data "$cand" -v >/dev/null 2>&1 || \ env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES PATH=/system/bin:/system/xbin:/sbin:/vendor/bin ANDROID_ROOT=/system ANDROID_DATA=/data /system/bin/sh -c "$cand -v" >/dev/null 2>&1; then echo "$cand" return 0 @@ -58,24 +77,30 @@ if [ -z "$HOST_SU" ]; then exit 1 fi +if [ -x "$HOST_SU" ] || [ -f "$HOST_SU" ]; then + RUN_SU=( + env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES + PATH=/system/bin:/system/xbin:/sbin:/vendor/bin:/usr/bin:/bin + ANDROID_ROOT=/system + ANDROID_DATA=/data + "TERM=${TERM:-xterm-256color}" + "COLORTERM=${COLORTERM:-truecolor}" + "$HOST_SU" + ) +else + RUN_SU=( + env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES + PATH=/system/bin:/system/xbin:/sbin:/vendor/bin:/usr/bin:/bin + ANDROID_ROOT=/system + ANDROID_DATA=/data + "TERM=${TERM:-xterm-256color}" + "COLORTERM=${COLORTERM:-truecolor}" + /system/bin/sh -c 'exec "$@"' _ $HOST_SU + ) +fi + RUN_ANDROID_SU() { - if [ -x "$HOST_SU" ] || [ -f "$HOST_SU" ]; then - env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES \ - PATH=/system/bin:/system/xbin:/sbin:/vendor/bin \ - ANDROID_ROOT=/system \ - ANDROID_DATA=/data \ - TERM="${TERM:-xterm-256color}" \ - COLORTERM="${COLORTERM:-truecolor}" \ - $HOST_SU "$@" - else - env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES \ - PATH=/system/bin:/system/xbin:/sbin:/vendor/bin \ - ANDROID_ROOT=/system \ - ANDROID_DATA=/data \ - TERM="${TERM:-xterm-256color}" \ - COLORTERM="${COLORTERM:-truecolor}" \ - /system/bin/sh -c "exec $HOST_SU \"\$@\"" _ "$@" - fi + "${RUN_SU[@]}" "$@" } CHECK_ROOT() { @@ -85,19 +110,10 @@ CHECK_ROOT() { return 0 fi - uid=$(env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES PATH=/system/bin:/system/xbin:/sbin:/vendor/bin ANDROID_ROOT=/system ANDROID_DATA=/data /system/bin/sh -c "$HOST_SU -c 'id -u 2>/dev/null || /system/bin/id -u 2>/dev/null || /system/xbin/id -u 2>/dev/null || /system/bin/toybox id -u 2>/dev/null || echo \$UID || echo \$USER_ID'" 2>/dev/null) - if [ -n "$uid" ] && [ "$uid" -eq 0 ] 2>/dev/null; then - return 0 - fi - if RUN_ANDROID_SU -c 'true' 2>/dev/null; then return 0 fi - if env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES PATH=/system/bin:/system/xbin:/sbin:/vendor/bin ANDROID_ROOT=/system ANDROID_DATA=/data /system/bin/sh -c "$HOST_SU -c 'true'" 2>/dev/null; then - return 0 - fi - return 1 } @@ -106,7 +122,7 @@ if ! CHECK_ROOT; then exit 1 fi -if [ -z "$CORTEX_ROOT" ]; then +if [ -z "${CORTEX_ROOT:-}" ]; then if [ -d "/data/user/0/org.cortex.terminal/files/cortex" ]; then CORTEX_ROOT="/data/user/0/org.cortex.terminal/files/cortex" elif [ -d "/data/data/org.cortex.terminal/files/cortex" ]; then @@ -130,91 +146,118 @@ done CORTEX_PATH="$CORTEX_ROOT/usr/local/sbin:$CORTEX_ROOT/usr/sbin:$CORTEX_ROOT/sbin:$CORTEX_ROOT/usr/local/bin:$CORTEX_ROOT/bin:$CORTEX_ROOT/usr/bin:/system/bin:/system/xbin" CORTEX_LD="$CORTEX_ROOT/lib:$CORTEX_ROOT/usr/lib:$CORTEX_ROOT/lib/aarch64-linux-gnu:$CORTEX_ROOT/usr/lib/aarch64-linux-gnu:$CORTEX_ROOT/lib/arm-linux-gnueabihf:$CORTEX_ROOT/usr/lib/arm-linux-gnueabihf:$CORTEX_ROOT/usr/local/lib:$CORTEX_ROOT/usr/lib/systemd:$CORTEX_ROOT/lib/systemd:$CORTEX_ROOT/usr/lib/aarch64-linux-gnu/systemd:$CORTEX_ROOT/lib/aarch64-linux-gnu/systemd:$CORTEX_ROOT/usr/lib/arm-linux-gnueabihf/systemd:$CORTEX_ROOT/lib/arm-linux-gnueabihf/systemd" -CORTEX_PRELOAD="" -if [ -f "$CORTEX_ROOT/usr/lib/libcortex-hook.so" ]; then - CORTEX_PRELOAD="$CORTEX_ROOT/usr/lib/libcortex-hook.so" -elif [ -f "$CORTEX_ROOT/lib/libcortex-hook.so" ]; then - CORTEX_PRELOAD="$CORTEX_ROOT/lib/libcortex-hook.so" + +HOOK_LIB="${HOOK_LIB:-}" +if [ -z "$HOOK_LIB" ]; then + if [ -f "$CORTEX_ROOT/usr/lib/libcortex-hook.so" ]; then + HOOK_LIB="$CORTEX_ROOT/usr/lib/libcortex-hook.so" + elif [ -f "$CORTEX_ROOT/lib/libcortex-hook.so" ]; then + HOOK_LIB="$CORTEX_ROOT/lib/libcortex-hook.so" + fi +fi +CORTEX_PRELOAD="$HOOK_LIB" + +FAKE_STAT="${FAKE_STAT:-}" +if [ -z "$FAKE_STAT" ] && [ -f "$CORTEX_ROOT/var/lib/cortex/fake_stat" ]; then + FAKE_STAT="$CORTEX_ROOT/var/lib/cortex/fake_stat" fi CERT_FILE="$CORTEX_ROOT/etc/ssl/certs/ca-certificates.crt" CERT_DIR="$CORTEX_ROOT/etc/ssl/certs:/system/etc/security/cacerts" CURRENT_DIR="$PWD" +CORTEX_URL_PORT="${CORTEX_URL_PORT:-4715}" +CORTEX_URL_TOKEN="${CORTEX_URL_TOKEN:-}" -ENV_SETUP="export CORTEX_ROOT='$CORTEX_ROOT'; \ -export PATH='$CORTEX_PATH'; \ -export LD_LIBRARY_PATH='$CORTEX_LD'; \ +ENV_SETUP="export CORTEX_ROOT=$(sq "$CORTEX_ROOT"); \ +export PATH=$(sq "$CORTEX_PATH"); \ +export LD_LIBRARY_PATH=$(sq "$CORTEX_LD"); \ export GLIBC_TUNABLES='glibc.pthread.rseq=0'; \ export LANG='C.UTF-8'; \ export LC_ALL='C.UTF-8'; \ -export LOCPATH='$CORTEX_ROOT/usr/lib/locale'; \ +export LOCPATH=$(sq "$CORTEX_ROOT/usr/lib/locale"); \ export USER='root'; \ export LOGNAME='root'; \ export HOSTNAME='cortex-android'; \ -export TERM='${TERM:-xterm-256color}'; \ -export COLORTERM='${COLORTERM:-truecolor}'; \ -export SSL_CERT_FILE='$CERT_FILE'; \ -export SSL_CERT_DIR='$CERT_DIR'; \ -export CURL_CA_BUNDLE='$CERT_FILE'; \ -export NODE_EXTRA_CA_CERTS='$CERT_FILE'; \ -export REQUESTS_CA_BUNDLE='$CERT_FILE'; \ -export TZDIR='$CORTEX_ROOT/usr/share/zoneinfo'; \ -export TERMINFO='$CORTEX_ROOT/usr/share/terminfo'; \ -export TERMINFO_DIRS='$CORTEX_ROOT/usr/share/terminfo:$CORTEX_ROOT/lib/terminfo:$CORTEX_ROOT/etc/terminfo:/usr/share/terminfo'; \ +export TERM=$(sq "${TERM:-xterm-256color}"); \ +export COLORTERM=$(sq "${COLORTERM:-truecolor}"); \ +export SSL_CERT_FILE=$(sq "$CERT_FILE"); \ +export SSL_CERT_DIR=$(sq "$CERT_DIR"); \ +export CURL_CA_BUNDLE=$(sq "$CERT_FILE"); \ +export NODE_EXTRA_CA_CERTS=$(sq "$CERT_FILE"); \ +export REQUESTS_CA_BUNDLE=$(sq "$CERT_FILE"); \ +export TZDIR=$(sq "$CORTEX_ROOT/usr/share/zoneinfo"); \ +export TERMINFO=$(sq "$CORTEX_ROOT/usr/share/terminfo"); \ +export TERMINFO_DIRS=$(sq "$CORTEX_ROOT/usr/share/terminfo:$CORTEX_ROOT/lib/terminfo:$CORTEX_ROOT/etc/terminfo:/usr/share/terminfo"); \ export GODEBUG='netdns=cgo'; \ export BROWSER='/usr/local/bin/xdg-open'; \ +export CORTEX_URL_PORT=$(sq "$CORTEX_URL_PORT"); \ export PS1='\[\033[01;31m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]# ';" -if [ -n "$CORTEX_PRELOAD" ]; then - ENV_SETUP="$ENV_SETUP export LD_PRELOAD='$CORTEX_PRELOAD';" +if [ -n "$CORTEX_URL_TOKEN" ]; then + ENV_SETUP="$ENV_SETUP export CORTEX_URL_TOKEN=$(sq "$CORTEX_URL_TOKEN");" fi -CORTEX_LD_SO="" -for cand_ld in \ - "$CORTEX_ROOT/usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" \ - "$CORTEX_ROOT/usr/lib/aarch64-linux-gnu/ld-2.39.so" \ - "$CORTEX_ROOT/lib/ld-linux-aarch64.so.1" \ - "$CORTEX_ROOT/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" \ - "$CORTEX_ROOT/usr/lib/ld-linux-aarch64.so.1" \ - "$CORTEX_ROOT/usr/lib64/ld-linux-aarch64.so.1" \ - "$CORTEX_ROOT/lib64/ld-linux-aarch64.so.1" \ - "$CORTEX_ROOT/usr/lib/arm-linux-gnueabihf/ld-linux-armhf.so.3" \ - "$CORTEX_ROOT/usr/lib/arm-linux-gnueabihf/ld-2.39.so" \ - "$CORTEX_ROOT/lib/ld-linux-armhf.so.3" \ - "$CORTEX_ROOT/lib/arm-linux-gnueabihf/ld-linux-armhf.so.3" \ - "$CORTEX_ROOT/usr/lib/ld-linux-armhf.so.3"; do - if [ -f "$cand_ld" ] || [ -x "$cand_ld" ] || [ -L "$cand_ld" ]; then - CORTEX_LD_SO="$cand_ld" - break - fi -done +if [ -n "$HOOK_LIB" ]; then + ENV_SETUP="$ENV_SETUP export LD_PRELOAD=$(sq "$HOOK_LIB");" +fi -if [ -n "$CORTEX_LD_SO" ]; then - LAUNCH_SHELL="'$CORTEX_LD_SO' --library-path '$CORTEX_LD' '$CORTEX_SHELL'" -else - LAUNCH_SHELL="'$CORTEX_SHELL'" +if [ -n "$FAKE_STAT" ]; then + ENV_SETUP="$ENV_SETUP export FAKE_STAT=$(sq "$FAKE_STAT");" fi -if [ -x "$HOST_SU" ] || [ -f "$HOST_SU" ]; then - RUN_SU="env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES PATH=/system/bin:/system/xbin:/sbin:/vendor/bin ANDROID_ROOT=/system ANDROID_DATA=/data TERM='${TERM:-xterm-256color}' COLORTERM='${COLORTERM:-truecolor}' $HOST_SU" +GLIBC_LDSO="${GLIBC_LDSO:-}" +if [ -z "$GLIBC_LDSO" ]; then + for cand_ld in \ + "$CORTEX_ROOT/usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" \ + "$CORTEX_ROOT/usr/lib/aarch64-linux-gnu/ld-2.39.so" \ + "$CORTEX_ROOT/lib/ld-linux-aarch64.so.1" \ + "$CORTEX_ROOT/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" \ + "$CORTEX_ROOT/usr/lib/ld-linux-aarch64.so.1" \ + "$CORTEX_ROOT/usr/lib64/ld-linux-aarch64.so.1" \ + "$CORTEX_ROOT/lib64/ld-linux-aarch64.so.1" \ + "$CORTEX_ROOT/usr/lib/arm-linux-gnueabihf/ld-linux-armhf.so.3" \ + "$CORTEX_ROOT/usr/lib/arm-linux-gnueabihf/ld-2.39.so" \ + "$CORTEX_ROOT/lib/ld-linux-armhf.so.3" \ + "$CORTEX_ROOT/lib/arm-linux-gnueabihf/ld-linux-armhf.so.3" \ + "$CORTEX_ROOT/usr/lib/ld-linux-armhf.so.3"; do + if [ -f "$cand_ld" ] || [ -x "$cand_ld" ] || [ -L "$cand_ld" ]; then + GLIBC_LDSO="$cand_ld" + break + fi + done +fi +CORTEX_LD_SO="$GLIBC_LDSO" + +if [ -n "$GLIBC_LDSO" ]; then + LAUNCH_SHELL="$(sq "$GLIBC_LDSO") --library-path $(sq "$CORTEX_LD") $(sq "$CORTEX_SHELL")" else - RUN_SU="env -u LD_PRELOAD -u LD_LIBRARY_PATH -u GLIBC_TUNABLES PATH=/system/bin:/system/xbin:/sbin:/vendor/bin ANDROID_ROOT=/system ANDROID_DATA=/data TERM='${TERM:-xterm-256color}' COLORTERM='${COLORTERM:-truecolor}' /system/bin/sh -c \"exec $HOST_SU \\\"\\\$@\\\"\" _" + LAUNCH_SHELL="$(sq "$CORTEX_SHELL")" fi -if [ "$1" = "-c" ]; then +BASE_INIT="$ENV_SETUP export HOME=$(sq "$ROOT_HOME"); cd $(sq "$CURRENT_DIR") 2>/dev/null || cd $(sq "$ROOT_HOME") 2>/dev/null;" + +if [ "${1:-}" = "-c" ]; then shift - exec $RUN_SU -c "$ENV_SETUP export HOME='$ROOT_HOME'; cd '$CURRENT_DIR' 2>/dev/null || cd '$ROOT_HOME' 2>/dev/null; exec $LAUNCH_SHELL -c \"\$@\"" _ "$@" -elif [ "$1" = "-" ] || [ "$1" = "-l" ] || [ "$1" = "--login" ]; then - exec $RUN_SU -c "$ENV_SETUP export HOME='$ROOT_HOME'; cd '$ROOT_HOME' 2>/dev/null; exec $LAUNCH_SHELL -l -i" -elif [ "$1" = "root" ]; then + if [ "$#" -eq 0 ]; then + exec "${RUN_SU[@]}" -c "$BASE_INIT exec $LAUNCH_SHELL -i" + elif [ "$#" -eq 1 ]; then + exec "${RUN_SU[@]}" -c "$BASE_INIT exec $LAUNCH_SHELL -c $(sq "$1")" + else + exec "${RUN_SU[@]}" -c "$BASE_INIT exec $LAUNCH_SHELL -c $(sq "$1") $(quote_args "${@:2}")" + fi +elif [ "${1:-}" = "-" ] || [ "${1:-}" = "-l" ] || [ "${1:-}" = "--login" ]; then + exec "${RUN_SU[@]}" -c "$ENV_SETUP export HOME=$(sq "$ROOT_HOME"); cd $(sq "$ROOT_HOME") 2>/dev/null; exec $LAUNCH_SHELL -l -i" +elif [ "${1:-}" = "root" ]; then shift if [ "$#" -eq 0 ]; then - exec $RUN_SU -c "$ENV_SETUP export HOME='$ROOT_HOME'; cd '$CURRENT_DIR' 2>/dev/null || cd '$ROOT_HOME' 2>/dev/null; exec $LAUNCH_SHELL -i" + exec "${RUN_SU[@]}" -c "$BASE_INIT exec $LAUNCH_SHELL -i" else - exec $RUN_SU -c "$ENV_SETUP export HOME='$ROOT_HOME'; cd '$CURRENT_DIR' 2>/dev/null || cd '$ROOT_HOME' 2>/dev/null; exec $LAUNCH_SHELL -c \"\$*\"" _ "$@" + USER_CMD="$(quote_args "$@")" + exec "${RUN_SU[@]}" -c "$BASE_INIT exec $LAUNCH_SHELL -c $(sq "$USER_CMD")" fi elif [ "$#" -eq 0 ]; then - exec $RUN_SU -c "$ENV_SETUP export HOME='$ROOT_HOME'; cd '$CURRENT_DIR' 2>/dev/null || cd '$ROOT_HOME' 2>/dev/null; exec $LAUNCH_SHELL -i" + exec "${RUN_SU[@]}" -c "$BASE_INIT exec $LAUNCH_SHELL -i" else - exec $RUN_SU -c "$ENV_SETUP export HOME='$ROOT_HOME'; cd '$CURRENT_DIR' 2>/dev/null || cd '$ROOT_HOME' 2>/dev/null; exec $LAUNCH_SHELL -c \"\$*\"" _ "$@" + USER_CMD="$(quote_args "$@")" + exec "${RUN_SU[@]}" -c "$BASE_INIT exec $LAUNCH_SHELL -c $(sq "$USER_CMD")" fi diff --git a/app/src/main/assets/scripts/systemctl.sh b/app/src/main/assets/scripts/systemctl.sh index bb7e619..476393a 100644 --- a/app/src/main/assets/scripts/systemctl.sh +++ b/app/src/main/assets/scripts/systemctl.sh @@ -4,6 +4,15 @@ ACTION="$1" SERVICE="${2%.service}" shift 2 2>/dev/null +AUTOSTART_DIR="${CORTEX_AUTOSTART_DIR:-/etc/cortex/autostart}" + +validate_service_name() { + if [ -z "$SERVICE" ] || [[ ! "$SERVICE" =~ ^[a-zA-Z0-9._-]+$ ]] || [ "$SERVICE" = "." ] || [ "$SERVICE" = ".." ]; then + echo "systemctl: invalid service name '$SERVICE'" >&2 + exit 1 + fi +} + case "$ACTION" in daemon-reload|reset-failed) exit 0 @@ -13,7 +22,7 @@ case "$ACTION" in exit 0 ;; is-active) - if [ -z "$SERVICE" ]; then exit 1; fi + validate_service_name if service "$SERVICE" status >/dev/null 2>&1; then echo "active" exit 0 @@ -23,7 +32,8 @@ case "$ACTION" in fi ;; is-enabled) - if [ -f "/etc/cortex/autostart/$SERVICE" ]; then + validate_service_name + if [ -f "$AUTOSTART_DIR/$SERVICE" ]; then echo "enabled" exit 0 else @@ -32,13 +42,15 @@ case "$ACTION" in fi ;; enable) - mkdir -p /etc/cortex/autostart - touch "/etc/cortex/autostart/$SERVICE" + validate_service_name + mkdir -p "$AUTOSTART_DIR" + touch "$AUTOSTART_DIR/$SERVICE" echo "Enabled $SERVICE for automatic startup." exit 0 ;; disable) - rm -f "/etc/cortex/autostart/$SERVICE" + validate_service_name + rm -f "$AUTOSTART_DIR/$SERVICE" echo "Disabled $SERVICE from automatic startup." exit 0 ;; @@ -47,6 +59,7 @@ case "$ACTION" in echo "Usage: systemctl $ACTION " exit 1 fi + validate_service_name exec service "$SERVICE" "$ACTION" "$@" ;; list-units|list-unit-files) @@ -54,6 +67,7 @@ case "$ACTION" in ;; *) if [ -n "$SERVICE" ]; then + validate_service_name exec service "$SERVICE" "$ACTION" "$@" fi exit 0 diff --git a/app/src/main/assets/scripts/xdg-open.sh b/app/src/main/assets/scripts/xdg-open.sh index 68e04e3..9ebadb5 100644 --- a/app/src/main/assets/scripts/xdg-open.sh +++ b/app/src/main/assets/scripts/xdg-open.sh @@ -26,25 +26,72 @@ if [ -z "$TARGET" ]; then TARGET="$1" fi +PORT="${CORTEX_URL_PORT:-4715}" +case "$PORT" in + ''|*[!0-9]*) + PORT=4715 + ;; +esac + +TOKEN="${CORTEX_URL_TOKEN:-}" +if [ -z "$TOKEN" ]; then + TOKEN_FILE="${CORTEX_ROOT:-/data/user/0/org.cortex.terminal/files/cortex}/../cortex_url_token" + if [ -r "$TOKEN_FILE" ]; then + IFS= read -r TOKEN < "$TOKEN_FILE" 2>/dev/null || true + TOKEN="${TOKEN%%[[:space:]]*}" + fi +fi + # 1. Try bash /dev/tcp if bash is available if [ -x /bin/bash ] || [ -x /usr/bin/bash ]; then BASH_BIN="$([ -x /bin/bash ] && echo /bin/bash || echo /usr/bin/bash)" - if "$BASH_BIN" -c "exec 3<>/dev/tcp/127.0.0.1/4715 && printf 'OPEN %s\n' \"$1\" >&3 && exec 3<&- && exec 3>&-" _ "$TARGET" 2>/dev/null; then + if "$BASH_BIN" -c ' + port="$1" + token="$2" + target="$3" + exec 3<>"/dev/tcp/127.0.0.1/$port" || exit 1 + if [ -n "$token" ]; then + printf "OPEN %s %s\n" "$token" "$target" >&3 || exit 1 + else + printf "OPEN %s\n" "$target" >&3 || exit 1 + fi + IFS= read -r resp <&3 || true + exec 3<&- + exec 3>&- + case "$resp" in + OK*) exit 0 ;; + *) exit 1 ;; + esac + ' _ "$PORT" "$TOKEN" "$TARGET" 2>/dev/null; then exit 0 fi fi # 2. Try netcat (nc) if command -v nc >/dev/null 2>&1; then - if printf "OPEN %s\n" "$TARGET" | nc -w 2 127.0.0.1 4715 >/dev/null 2>&1; then - exit 0 + if [ -n "$TOKEN" ]; then + NC_RESP="$(printf 'OPEN %s %s\n' "$TOKEN" "$TARGET" | nc -w 2 127.0.0.1 "$PORT" 2>/dev/null || true)" + else + NC_RESP="$(printf 'OPEN %s\n' "$TARGET" | nc -w 2 127.0.0.1 "$PORT" 2>/dev/null || true)" fi + case "$NC_RESP" in + OK*) exit 0 ;; + esac fi # 3. Try curl (HTTP GET /open?url=...) if command -v curl >/dev/null 2>&1; then - if curl -s -m 2 -G "http://127.0.0.1/4715/open" --data-urlencode "url=$TARGET" >/dev/null 2>&1; then - exit 0 + if [ -n "$TOKEN" ]; then + if curl -fsS -m 2 -G "http://127.0.0.1:${PORT}/open" \ + -H "Authorization: Bearer $TOKEN" \ + --data-urlencode "url=$TARGET" >/dev/null 2>&1; then + exit 0 + fi + else + if curl -fsS -m 2 -G "http://127.0.0.1:${PORT}/open" \ + --data-urlencode "url=$TARGET" >/dev/null 2>&1; then + exit 0 + fi fi fi @@ -52,12 +99,18 @@ fi if command -v python3 >/dev/null 2>&1; then if python3 -c ' import sys, socket -s = socket.socket() +port = int(sys.argv[1]) +token = sys.argv[2] +target = sys.argv[3] +payload = ("OPEN " + token + " " + target + "\n") if token else ("OPEN " + target + "\n") +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(2.0) -s.connect(("127.0.0.1", 4715)) -s.sendall(f"OPEN {sys.argv[1]}\n".encode("utf-8")) +s.connect(("127.0.0.1", port)) +s.sendall(payload.encode("utf-8")) +resp = s.recv(1024).decode("utf-8", "replace") s.close() -' "$TARGET" 2>/dev/null; then +sys.exit(0 if resp.startswith("OK") else 1) +' "$PORT" "$TOKEN" "$TARGET" 2>/dev/null; then exit 0 fi fi @@ -66,12 +119,18 @@ fi if command -v python >/dev/null 2>&1; then if python -c ' import sys, socket -s = socket.socket() +port = int(sys.argv[1]) +token = sys.argv[2] +target = sys.argv[3] +payload = ("OPEN " + token + " " + target + "\n") if token else ("OPEN " + target + "\n") +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(2.0) -s.connect(("127.0.0.1", 4715)) -s.sendall(b"OPEN " + sys.argv[1].encode("utf-8") + b"\n") +s.connect(("127.0.0.1", port)) +s.sendall(payload.encode("utf-8")) +resp = s.recv(1024).decode("utf-8", "replace") s.close() -' "$TARGET" 2>/dev/null; then +sys.exit(0 if resp.startswith("OK") else 1) +' "$PORT" "$TOKEN" "$TARGET" 2>/dev/null; then exit 0 fi fi diff --git a/app/src/main/java/org/cortex/terminal/runtime/ShellScriptsInstaller.kt b/app/src/main/java/org/cortex/terminal/runtime/ShellScriptsInstaller.kt index 5da627a..c35300a 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/ShellScriptsInstaller.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/ShellScriptsInstaller.kt @@ -1,30 +1,58 @@ package org.cortex.terminal.runtime import android.content.Context +import android.system.Os import android.util.Log import java.io.File object ShellScriptsInstaller { private const val TAG = "ShellScriptsInstaller" - private fun loadAssetScript(context: Context?, assetName: String, fallback: String): String { - if (context == null) return fallback - return try { - context.assets.open("scripts/$assetName").bufferedReader().use { it.readText() } - } catch (e: Exception) { + fun requireFunctionalScript(assetName: String, content: String): String { + val hasExecutableLine = content.lines().any { line -> + line.isNotBlank() && !line.trimStart().startsWith("#") + } + if (!hasExecutableLine) { + throw IllegalStateException("Missing or non-functional script asset: $assetName") + } + return content + } + + internal fun loadAssetScript(context: Context?, assetName: String, fallback: String): String { + val fromAsset = if (context != null) { + try { + context.assets.open("scripts/$assetName").bufferedReader().use { it.readText() } + } catch (_: Exception) { + null + } + } else { + null + } + val candidate = if (fromAsset != null && + fromAsset.lines().any { it.isNotBlank() && !it.trimStart().startsWith("#") } + ) { + fromAsset + } else { fallback } + return requireFunctionalScript(assetName, candidate) } fun ensureReloadScripts(root: File, home: File, context: Context? = null) { try { val fallbackScript = "#!/bin/bash\n" + "set --\n" + - "if [ -f \"\$HOME/.bashrc\" ]; then . \"\$HOME/.bashrc\"; fi\n" + - "if [ -f \"\$HOME/.profile\" ]; then . \"\$HOME/.profile\"; fi\n" + - "if [ -f \"\$HOME/.bash_profile\" ]; then . \"\$HOME/.bash_profile\"; fi\n" + - "echo \"Environment reloaded.\"\n" - val reloadScript = loadAssetScript(context, "reload.sh", fallbackScript) + "if (return 0 2>/dev/null); then\n" + + " if [ -f \"\$HOME/.bashrc\" ]; then . \"\$HOME/.bashrc\"; fi\n" + + " if [ -f \"\$HOME/.profile\" ]; then . \"\$HOME/.profile\"; fi\n" + + " if [ -f \"\$HOME/.bash_profile\" ]; then . \"\$HOME/.bash_profile\"; fi\n" + + " echo \"Environment reloaded.\"\n" + + "else\n" + + " echo \"Environment reloaded.\"\n" + + " exec \"\${SHELL:-/bin/bash}\" -l\n" + + "fi\n" + val loaded = loadAssetScript(context, "reload.sh", fallbackScript) + val reloadScript = if (loaded.contains("return 0")) loaded else fallbackScript val reloadDirs = listOf(File(root, "usr/bin"), File(root, "bin"), File(home, ".local/bin")) reloadDirs.forEach { dir -> @@ -34,7 +62,7 @@ object ShellScriptsInstaller { rFile.setExecutable(true, true) rFile.setReadable(true, true) rFile.setWritable(true, true) - try { android.system.Os.chmod(rFile.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(rFile.absolutePath, 448) } catch (_: Exception) {} } } } catch (e: Exception) { @@ -53,28 +81,40 @@ object ShellScriptsInstaller { val localBin = File(root, "usr/local/bin") localBin.mkdirs() - val serviceScript = loadAssetScript(context, "service.sh", "#!/bin/bash\n") - val serviceFile = File(localBin, "service") - serviceFile.writeText(serviceScript) - serviceFile.setReadable(true, true) - serviceFile.setWritable(true, true) - serviceFile.setExecutable(true, true) - try { android.system.Os.chmod(serviceFile.absolutePath, 448) } catch (e: Exception) {} - - val systemctlScript = loadAssetScript(context, "systemctl.sh", "#!/bin/bash\n") - val systemctlFile = File(localBin, "systemctl") - systemctlFile.writeText(systemctlScript) - systemctlFile.setReadable(true, true) - systemctlFile.setWritable(true, true) - systemctlFile.setExecutable(true, true) - try { android.system.Os.chmod(systemctlFile.absolutePath, 448) } catch (e: Exception) {} + try { + val serviceScript = loadAssetScript(context, "service.sh", "#!/bin/bash\n") + val serviceFile = File(localBin, "service") + serviceFile.writeText(serviceScript) + serviceFile.setReadable(true, true) + serviceFile.setWritable(true, true) + serviceFile.setExecutable(true, true) + try { Os.chmod(serviceFile.absolutePath, 448) } catch (_: Exception) {} + } catch (e: Exception) { + Log.e(TAG, "Skipping service script install: ${e.message}") + } + + try { + val systemctlScript = loadAssetScript(context, "systemctl.sh", "#!/bin/bash\n") + val systemctlFile = File(localBin, "systemctl") + systemctlFile.writeText(systemctlScript) + systemctlFile.setReadable(true, true) + systemctlFile.setWritable(true, true) + systemctlFile.setExecutable(true, true) + try { Os.chmod(systemctlFile.absolutePath, 448) } catch (_: Exception) {} + } catch (e: Exception) { + Log.e(TAG, "Skipping systemctl script install: ${e.message}") + } val cortexServiceFile = File(localBin, "cortex-service") - cortexServiceFile.writeText("#!/bin/sh\nexec service \"\$@\"\n") + val cortexServiceScript = requireFunctionalScript( + "cortex-service", + "#!/bin/sh\nexec service \"\$@\"\n" + ) + cortexServiceFile.writeText(cortexServiceScript) cortexServiceFile.setReadable(true, true) cortexServiceFile.setWritable(true, true) cortexServiceFile.setExecutable(true, true) - try { android.system.Os.chmod(cortexServiceFile.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(cortexServiceFile.absolutePath, 448) } catch (_: Exception) {} } catch (e: Exception) { Log.e(TAG, "Failed to ensure service manager", e) } @@ -90,17 +130,21 @@ object ShellScriptsInstaller { for (fname in oldAudioFiles) { val f = File(localBin, fname) if (f.exists()) { - try { f.delete() } catch (e: Exception) {} + try { f.delete() } catch (_: Exception) {} } } - val xdgOpenScript = loadAssetScript(context, "xdg-open.sh", "#!/bin/sh\n") - val xdgOpenFile = File(localBin, "xdg-open") - xdgOpenFile.writeText(xdgOpenScript) - xdgOpenFile.setReadable(true, true) - xdgOpenFile.setWritable(true, true) - xdgOpenFile.setExecutable(true, true) - try { android.system.Os.chmod(xdgOpenFile.absolutePath, 448) } catch (e: Exception) {} + try { + val xdgOpenScript = loadAssetScript(context, "xdg-open.sh", "#!/bin/sh\n") + val xdgOpenFile = File(localBin, "xdg-open") + xdgOpenFile.writeText(xdgOpenScript) + xdgOpenFile.setReadable(true, true) + xdgOpenFile.setWritable(true, true) + xdgOpenFile.setExecutable(true, true) + try { Os.chmod(xdgOpenFile.absolutePath, 448) } catch (_: Exception) {} + } catch (e: Exception) { + Log.e(TAG, "Skipping xdg-open script install: ${e.message}") + } val browserAliases = listOf( "sensible-browser", @@ -113,7 +157,10 @@ object ShellScriptsInstaller { "open" ) - val wrapperScript = "#!/bin/sh\nexec /usr/local/bin/xdg-open \"\$@\"\n" + val wrapperScript = requireFunctionalScript( + "xdg-open-wrapper", + "#!/bin/sh\nexec /usr/local/bin/xdg-open \"\$@\"\n" + ) for (alias in browserAliases) { val aliasFile = File(localBin, alias) @@ -121,7 +168,7 @@ object ShellScriptsInstaller { aliasFile.setReadable(true, true) aliasFile.setWritable(true, true) aliasFile.setExecutable(true, true) - try { android.system.Os.chmod(aliasFile.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(aliasFile.absolutePath, 448) } catch (_: Exception) {} } } catch (e: Exception) { Log.e(TAG, "Failed to ensure browser opener", e) @@ -151,35 +198,40 @@ object ShellScriptsInstaller { val localBin = File(root, "usr/local/bin") if (!localBin.exists()) localBin.mkdirs() - val suScript = loadAssetScript(context, "su.sh", "#!/bin/bash\n") - val suFile = File(localBin, "su") - suFile.writeText(suScript) - suFile.setReadable(true, true) - suFile.setWritable(true, true) - suFile.setExecutable(true, true) - try { android.system.Os.chmod(suFile.absolutePath, 448) } catch (e: Exception) {} + try { + val suScript = loadAssetScript(context, "su.sh", "#!/bin/bash\n") + val suFile = File(localBin, "su") + suFile.writeText(suScript) + suFile.setReadable(true, true) + suFile.setWritable(true, true) + suFile.setExecutable(true, true) + try { Os.chmod(suFile.absolutePath, 448) } catch (_: Exception) {} + } catch (e: Exception) { + Log.e(TAG, "Skipping su script install: ${e.message}") + } - val tsuScript = loadAssetScript(context, "tsu.sh", "#!/bin/sh\n") + val tsuFallback = "#!/bin/sh\nexec /usr/local/bin/su \"\$@\"\n" + val tsuScript = loadAssetScript(context, "tsu.sh", tsuFallback) val tsuFile = File(localBin, "tsu") tsuFile.writeText(tsuScript) tsuFile.setReadable(true, true) tsuFile.setWritable(true, true) tsuFile.setExecutable(true, true) - try { android.system.Os.chmod(tsuFile.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(tsuFile.absolutePath, 448) } catch (_: Exception) {} val sudoFile = File(localBin, "sudo") sudoFile.writeText(tsuScript) sudoFile.setReadable(true, true) sudoFile.setWritable(true, true) sudoFile.setExecutable(true, true) - try { android.system.Os.chmod(sudoFile.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(sudoFile.absolutePath, 448) } catch (_: Exception) {} val rootFile = File(localBin, "root") rootFile.writeText(tsuScript) rootFile.setReadable(true, true) rootFile.setWritable(true, true) rootFile.setExecutable(true, true) - try { android.system.Os.chmod(rootFile.absolutePath, 448) } catch (e: Exception) {} + try { Os.chmod(rootFile.absolutePath, 448) } catch (_: Exception) {} val nanoDir = File(root, "usr/share/nano") if (!nanoDir.exists()) nanoDir.mkdirs() diff --git a/app/src/main/java/org/cortex/terminal/runtime/UrlOpenerServer.kt b/app/src/main/java/org/cortex/terminal/runtime/UrlOpenerServer.kt index 6740631..ed154f4 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/UrlOpenerServer.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/UrlOpenerServer.kt @@ -6,17 +6,22 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log -import java.io.BufferedReader import java.io.File -import java.io.InputStreamReader import java.io.OutputStreamWriter import java.net.InetAddress import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket +import java.net.URI import java.net.URLDecoder +import java.security.MessageDigest import java.security.SecureRandom -import java.util.concurrent.Executors +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadFactory +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit /** * UrlOpenerServer listens on localhost:4715 for browser redirection requests from CLI tools @@ -27,7 +32,18 @@ import java.util.concurrent.Executors * OPEN (if token passed or authenticated) * HTTP requests: Authorization: Bearer or query param token= * - * When received and authenticated, Cortex dispatches an Android Intent to Chrome or the system default browser. + * Threat Model (`CORTEX_URL_TOKEN`): + * - On Android, the loopback interface (`127.0.0.1`) is shared across all installed applications + * that hold the `INTERNET` permission as well as local browser tabs. `CORTEX_URL_TOKEN` protects + * this localhost TCP IPC endpoint against unauthorized cross-app requests and browser-based + * localhost CSRF attacks. + * - Because `CORTEX_URL_TOKEN` is exported into the Cortex terminal session environment and persisted + * in `filesDir/cortex_url_token` (mode `0600`, owned by the app UID), it functions as a session + * capability token rather than an isolation boundary between processes executing inside the same + * Cortex terminal session. + * + * When a request is received and authenticated, Cortex validates that the URL uses `http` or `https` + * and dispatches an Android `ACTION_VIEW` Intent to Chrome or the system default browser. */ object UrlOpenerServer { private const val TAG = "UrlOpenerServer" @@ -36,12 +52,35 @@ object UrlOpenerServer { private const val TOKEN_FILE_NAME = "cortex_url_token" @Volatile - private var authToken: String? = null + internal var authToken: String? = null + + @Volatile + internal var activePort: Int = PORT + private set + + @Volatile + internal var clientSoTimeoutMs: Int = 5000 private var serverSocket: ServerSocket? = null + private var acceptThread: Thread? = null + private var workerPool: ThreadPoolExecutor? = null + + @Volatile private var isRunning = false - private val threadPool = Executors.newFixedThreadPool(2) - private val mainHandler = Handler(Looper.getMainLooper()) + + private val mainHandler: Handler? = try { + Looper.getMainLooper()?.let { Handler(it) } + } catch (_: Throwable) { + null + } + + internal fun tokensMatch(candidate: String?, expected: String): Boolean { + if (candidate.isNullOrEmpty() || expected.isEmpty()) return false + return MessageDigest.isEqual( + candidate.toByteArray(Charsets.UTF_8), + expected.toByteArray(Charsets.UTF_8) + ) + } @Synchronized fun getOrCreateToken(context: Context): String { @@ -81,36 +120,88 @@ object UrlOpenerServer { return newToken } - @Synchronized fun start(context: Context) { - if (isRunning) return - val appContext = context.applicationContext - getOrCreateToken(appContext) + start(context, PORT) + } - threadPool.execute { - try { - val server = ServerSocket() - server.reuseAddress = true - server.bind(InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT), 50) - serverSocket = server - isRunning = true - Log.i(TAG, "UrlOpenerServer started on 127.0.0.1:$PORT") - - while (isRunning && !server.isClosed) { - val client = try { - server.accept() - } catch (e: Exception) { - break + internal fun start(context: Context, port: Int) { + val bindLatch = CountDownLatch(1) + synchronized(this) { + if (isRunning) return + val appContext = context.applicationContext ?: context + getOrCreateToken(appContext) + + val pool = ThreadPoolExecutor( + 2, + 4, + 30L, + TimeUnit.SECONDS, + ArrayBlockingQueue(16), + ThreadFactory { r -> + Thread(r, "Cortex-UrlOpenerWorker").apply { isDaemon = true } + } + ) + workerPool = pool + isRunning = true + + val thread = Thread({ + var server: ServerSocket? = null + try { + server = ServerSocket() + server.reuseAddress = true + server.bind(InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), 50) + synchronized(this@UrlOpenerServer) { + if (!isRunning) { + try { server.close() } catch (_: Exception) {} + return@Thread + } + serverSocket = server + activePort = server.localPort } - threadPool.execute { - handleClient(appContext, client) + bindLatch.countDown() + Log.i(TAG, "UrlOpenerServer started on 127.0.0.1:${server.localPort}") + + while (isRunning && !server.isClosed) { + val client = try { + server.accept() + } catch (e: Exception) { + break + } + try { + pool.execute { + handleClient(appContext, client) + } + } catch (e: RejectedExecutionException) { + Log.w(TAG, "UrlOpenerServer worker queue full; closing client socket") + try { + client.close() + } catch (_: Exception) {} + } + } + } catch (e: Exception) { + Log.e(TAG, "Error running UrlOpenerServer", e) + } finally { + bindLatch.countDown() + try { + server?.close() + } catch (_: Exception) {} + synchronized(this@UrlOpenerServer) { + if (serverSocket === server) { + serverSocket = null + isRunning = false + } } } - } catch (e: Exception) { - Log.e(TAG, "Error running UrlOpenerServer", e) - } finally { - isRunning = false + }, "Cortex-UrlOpenerAccept").apply { + isDaemon = true } + acceptThread = thread + thread.start() + } + try { + bindLatch.await(2, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() } } @@ -119,14 +210,18 @@ object UrlOpenerServer { isRunning = false try { serverSocket?.close() - } catch (e: Exception) {} + } catch (_: Exception) {} serverSocket = null + acceptThread?.interrupt() + acceptThread = null + workerPool?.shutdownNow() + workerPool = null Log.i(TAG, "UrlOpenerServer stopped") } private fun handleClient(context: Context, socket: Socket) { try { - socket.soTimeout = 5000 + socket.soTimeout = clientSoTimeoutMs val input = socket.getInputStream() val writer = OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8) @@ -192,7 +287,7 @@ object UrlOpenerServer { if (headerName.equals("Authorization", ignoreCase = true)) { if (headerValue.startsWith("Bearer ", ignoreCase = true)) { val token = headerValue.substring(7).trim() - if (token == expectedToken) { + if (tokensMatch(token, expectedToken)) { authenticated = true } } @@ -203,7 +298,7 @@ object UrlOpenerServer { // Check query param token= if (!authenticated && path.contains("token=")) { val tokenInQuery = path.substringAfter("token=").substringBefore("&") - if (tokenInQuery == expectedToken) { + if (tokensMatch(tokenInQuery, expectedToken)) { authenticated = true } } @@ -221,10 +316,10 @@ object UrlOpenerServer { } else if (rawLine.startsWith("OPEN ", ignoreCase = true)) { val rest = rawLine.substring(5).trim() val parts = rest.split("\\s+".toRegex(), limit = 2) - if (parts.size == 2 && parts[0] == expectedToken) { + if (parts.size == 2 && tokensMatch(parts[0], expectedToken)) { authenticated = true targetUrl = parts[1] - } else if (parts.size == 1 && parts[0] == expectedToken) { + } else if (parts.size == 1 && tokensMatch(parts[0], expectedToken)) { authenticated = true targetUrl = "" } else { @@ -232,7 +327,7 @@ object UrlOpenerServer { } } else { val parts = rawLine.split("\\s+".toRegex(), limit = 2) - if (parts.size == 2 && parts[0] == expectedToken) { + if (parts.size == 2 && tokensMatch(parts[0], expectedToken)) { authenticated = true targetUrl = parts[1] } @@ -278,15 +373,56 @@ object UrlOpenerServer { } } - fun openUrlInBrowser(context: Context, rawUrl: String): Boolean { + internal fun normalizeAndValidateUrl(rawUrl: String): String? { var cleanUrl = rawUrl.trim().trim('\"', '\'') - if (cleanUrl.isEmpty()) return false + if (cleanUrl.isEmpty()) return null + if (cleanUrl.any { it.isWhitespace() || it.code < 0x20 }) return null - // Normalize URL scheme if scheme is missing if (!cleanUrl.contains("://")) { + val colonIdx = cleanUrl.indexOf(':') + val slashIdx = cleanUrl.indexOfAny(charArrayOf('/', '?', '#')) + val hasColonBeforePath = colonIdx != -1 && (slashIdx == -1 || colonIdx < slashIdx) + if (hasColonBeforePath) { + val afterColon = cleanUrl.substring(colonIdx + 1) + val isHostPort = colonIdx > 0 && afterColon.matches(Regex("^\\d+([/?#].*)?$")) + if (!isHostPort) { + return null + } + } cleanUrl = "https://$cleanUrl" } + val jUri = try { + URI(cleanUrl) + } catch (_: Exception) { + null + } + val aUri = try { + Uri.parse(cleanUrl) + } catch (_: Throwable) { + null + } + + val scheme = (aUri?.scheme ?: jUri?.scheme)?.lowercase() + if (scheme != "http" && scheme != "https") { + return null + } + + val host = aUri?.host ?: jUri?.host + if (host.isNullOrBlank()) { + return null + } + + return cleanUrl + } + + fun openUrlInBrowser(context: Context, rawUrl: String): Boolean { + val cleanUrl = normalizeAndValidateUrl(rawUrl) + if (cleanUrl == null) { + Log.w(TAG, "Rejected disallowed or malformed URL: $rawUrl") + return false + } + val uri = try { Uri.parse(cleanUrl) } catch (e: Exception) { @@ -295,50 +431,54 @@ object UrlOpenerServer { } // Strictly validate URLs: only allow http:// and https:// schemes. - // Reject file://, content://, javascript:, ftp://, and null/empty schemes. - val scheme = uri.scheme?.lowercase() - if (scheme != "http" && scheme != "https") { - Log.w(TAG, "Rejected disallowed URL scheme '$scheme': $cleanUrl") - return false + // Reject file://, content://, javascript:, intent:, ftp://, and null/empty schemes. + if (uri != null) { + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") { + Log.w(TAG, "Rejected disallowed URL scheme '$scheme': $cleanUrl") + return false + } } - mainHandler.post { - val pm = context.packageManager - val browserPackages = listOf( - "com.android.chrome", - "com.chrome.beta", - "com.chrome.dev", - "com.chrome.canary", - "org.mozilla.firefox", - "com.brave.browser", - "com.opera.browser", - "com.microsoft.emmx", - "com.sec.android.app.sbrowser" - ) + if (uri != null) { + mainHandler?.post { + val pm = context.packageManager + val browserPackages = listOf( + "com.android.chrome", + "com.chrome.beta", + "com.chrome.dev", + "com.chrome.canary", + "org.mozilla.firefox", + "com.brave.browser", + "com.opera.browser", + "com.microsoft.emmx", + "com.sec.android.app.sbrowser" + ) + + for (pkg in browserPackages) { + try { + pm.getPackageInfo(pkg, 0) + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + setPackage(pkg) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + context.startActivity(intent) + return@post + } catch (e: Exception) { + // Try next browser + } + } - for (pkg in browserPackages) { + // Fallback to default browser / system handler try { - pm.getPackageInfo(pkg, 0) - val intent = Intent(Intent.ACTION_VIEW, uri).apply { - setPackage(pkg) + val fallbackIntent = Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } - context.startActivity(intent) - return@post + context.startActivity(fallbackIntent) } catch (e: Exception) { - // Try next browser + Log.e(TAG, "Failed to launch default browser for $cleanUrl", e) } } - - // Fallback to default browser / system handler - try { - val fallbackIntent = Intent(Intent.ACTION_VIEW, uri).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - context.startActivity(fallbackIntent) - } catch (e: Exception) { - Log.e(TAG, "Failed to launch default browser for $cleanUrl", e) - } } return true } From 8dad00d185b9f4d83fc6d72c39cbbaea08f54e9e Mon Sep 17 00:00:00 2001 From: woffluon Date: Tue, 6 Oct 2026 21:57:03 +0300 Subject: [PATCH 06/11] fix(terminal): resolve session write race, buffer cache, utf8 batching, and accessibility --- .../java/org/cortex/terminal/MainActivity.kt | 5 +- .../terminal/emulator/TerminalBuffer.kt | 26 ++++++-- .../terminal/emulator/TerminalEmulator.kt | 39 ++++++----- .../terminal/session/TerminalSession.kt | 37 +++++------ .../org/cortex/terminal/view/ExtraKeysView.kt | 13 +++- .../org/cortex/terminal/view/TerminalView.kt | 66 +++++++++++++++---- app/src/main/res/layout/item_session.xml | 2 +- app/src/main/res/values/strings.xml | 3 + 8 files changed, 132 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/org/cortex/terminal/MainActivity.kt b/app/src/main/java/org/cortex/terminal/MainActivity.kt index 249e98a..21546bd 100644 --- a/app/src/main/java/org/cortex/terminal/MainActivity.kt +++ b/app/src/main/java/org/cortex/terminal/MainActivity.kt @@ -297,7 +297,7 @@ class MainActivity : AppCompatActivity() { android.util.Log.e("MainActivity", "Startup keyrings failed", e) } try { - BootstrapManager.ensureEssentialBinaries(root, homeDir) + BootstrapManager.ensureEssentialBinaries(root, homeDir, this@MainActivity) } catch (e: Exception) { android.util.Log.e("MainActivity", "Startup essential binaries failed", e) } @@ -324,7 +324,7 @@ class MainActivity : AppCompatActivity() { val success = BootstrapManager.installBootstrapFromAssets(this) if (success) { BootstrapManager.updateTimezone(this, root) - BootstrapManager.ensureEssentialBinaries(root, Environment.getHomeDir(this)) + BootstrapManager.ensureEssentialBinaries(root, Environment.getHomeDir(this), this) BootstrapManager.initializeFileSystem(this) } @@ -375,7 +375,6 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() applyPreferences() - UpdateManager.cleanUpdates(this) if (UpdateManager.isUpdateAvailable) { updateRedDot.visibility = android.view.View.VISIBLE } diff --git a/app/src/main/java/org/cortex/terminal/emulator/TerminalBuffer.kt b/app/src/main/java/org/cortex/terminal/emulator/TerminalBuffer.kt index 9bf7dd7..704a4e3 100644 --- a/app/src/main/java/org/cortex/terminal/emulator/TerminalBuffer.kt +++ b/app/src/main/java/org/cortex/terminal/emulator/TerminalBuffer.kt @@ -4,16 +4,19 @@ import java.util.ArrayDeque class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = 5000) { val history = ArrayDeque() + private var historyGeneration = 0 private var historyArrayCache: Array? = null private var historyArrayCacheSize = -1 + private var historyArrayCacheGeneration = -1 private fun getHistoryRow(index: Int): TerminalRow? { if (index !in 0 until history.size) return null var cache = historyArrayCache - if (cache == null || historyArrayCacheSize != history.size) { + if (cache == null || historyArrayCacheSize != history.size || historyArrayCacheGeneration != historyGeneration) { cache = history.toTypedArray() historyArrayCache = cache historyArrayCacheSize = history.size + historyArrayCacheGeneration = historyGeneration } return if (index in cache.indices) cache[index] else null } @@ -53,7 +56,7 @@ class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = val linesToShift = if (cursorRow >= newRows) cursorRow - newRows + 1 else 0 // Push lines above visible area to history so they are not lost - if (!isAlternate) { + if (!isAlternate && linesToShift > 0) { for (r in 0 until linesToShift) { val hRow = TerminalRow(cols) hRow.copyFrom(screen[r]) @@ -62,6 +65,8 @@ class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = history.removeFirst() } } + historyGeneration++ + historyArrayCache = null } // Copy lines into new screen @@ -79,9 +84,13 @@ class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = val restoredCount = if (!isAlternate) minOf(linesToAdd, history.size) else 0 // Restore lines from history to the top of new screen - for (r in 0 until restoredCount) { - val hRow = history.removeLast() - newScreen[restoredCount - 1 - r].copyFrom(hRow) + if (restoredCount > 0) { + for (r in 0 until restoredCount) { + val hRow = history.removeLast() + newScreen[restoredCount - 1 - r].copyFrom(hRow) + } + historyGeneration++ + historyArrayCache = null } // Copy existing screen lines below restored lines @@ -188,6 +197,8 @@ class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = if (history.size > maxHistory) { history.removeFirst() } + historyGeneration++ + historyArrayCache = null } for (r in t until b) { @@ -233,6 +244,8 @@ class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = } if (mode == 3 && !isAlternate) { history.clear() + historyGeneration++ + historyArrayCache = null } } } @@ -301,12 +314,14 @@ class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = */ private var rowsCache: ArrayList> = ArrayList() private var cachedHistorySize = -1 + private var cachedHistoryGeneration = -1 private var cachedScreenRows = -1 private var cachedGeneration = -1 private fun rebuildRowsCacheIfNeeded() { val screenCount = minOf(rows, screen.size) if (cachedHistorySize == history.size && + cachedHistoryGeneration == historyGeneration && cachedScreenRows == screenCount && cachedGeneration == screenGeneration) return val needed = history.size + screenCount @@ -325,6 +340,7 @@ class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = rowsCache.add(Pair(i, screen[i])) } cachedHistorySize = history.size + cachedHistoryGeneration = historyGeneration cachedScreenRows = screenCount cachedGeneration = screenGeneration } diff --git a/app/src/main/java/org/cortex/terminal/emulator/TerminalEmulator.kt b/app/src/main/java/org/cortex/terminal/emulator/TerminalEmulator.kt index ebf2f1f..ddd3011 100644 --- a/app/src/main/java/org/cortex/terminal/emulator/TerminalEmulator.kt +++ b/app/src/main/java/org/cortex/terminal/emulator/TerminalEmulator.kt @@ -88,27 +88,34 @@ class TerminalEmulator( private val utf8ByteBuffer: ByteBuffer = ByteBuffer.allocate(16384) fun processInput(bytes: ByteArray, offset: Int, length: Int) { + if (length <= 0 || offset < 0 || offset + length > bytes.size) return lock.withLock { - if (utf8ByteBuffer.remaining() < length) { - utf8ByteBuffer.compact() - } - val toPut = minOf(length, utf8ByteBuffer.remaining()) - utf8ByteBuffer.put(bytes, offset, toPut) - utf8ByteBuffer.flip() + var pos = offset + val end = offset + length + while (pos < end) { + val toPut = minOf(end - pos, utf8ByteBuffer.remaining()) + if (toPut <= 0) { + utf8ByteBuffer.clear() + continue + } + utf8ByteBuffer.put(bytes, pos, toPut) + pos += toPut + utf8ByteBuffer.flip() - if (charBuffer.capacity() < utf8ByteBuffer.remaining() * 2) { - charBuffer = CharBuffer.allocate(utf8ByteBuffer.remaining() * 2 + 1024) - } + if (charBuffer.capacity() < utf8ByteBuffer.remaining() * 2) { + charBuffer = CharBuffer.allocate(utf8ByteBuffer.remaining() * 2 + 1024) + } - charBuffer.clear() - utf8Decoder.decode(utf8ByteBuffer, charBuffer, false) - charBuffer.flip() + charBuffer.clear() + utf8Decoder.decode(utf8ByteBuffer, charBuffer, false) + charBuffer.flip() - while (charBuffer.hasRemaining()) { - processChar(charBuffer.get()) - } + while (charBuffer.hasRemaining()) { + processChar(charBuffer.get()) + } - utf8ByteBuffer.compact() + utf8ByteBuffer.compact() + } } onScreenUpdate?.invoke() } diff --git a/app/src/main/java/org/cortex/terminal/session/TerminalSession.kt b/app/src/main/java/org/cortex/terminal/session/TerminalSession.kt index 5a6a26a..77015fe 100644 --- a/app/src/main/java/org/cortex/terminal/session/TerminalSession.kt +++ b/app/src/main/java/org/cortex/terminal/session/TerminalSession.kt @@ -25,6 +25,7 @@ class TerminalSession( } private var ptyProcess: PtyProcess? = null private var readerThread: Thread? = null + @Volatile var isRunning = false private set @@ -116,19 +117,23 @@ class TerminalSession( } fun write(bytes: ByteArray) { - if (!isRunning) return - writeExecutor.execute { - if (!isRunning) return@execute - try { - ptyProcess?.outputStream?.let { os -> - os.write(bytes) - os.flush() - } - } catch (e: IOException) { - if (isRunning) { - Log.e(tag, "Error writing to PTY: ${e.message}") + if (!isRunning || writeExecutor.isShutdown) return + try { + writeExecutor.execute { + if (!isRunning) return@execute + try { + ptyProcess?.outputStream?.let { os -> + os.write(bytes) + os.flush() + } + } catch (e: IOException) { + if (isRunning) { + Log.e(tag, "Error writing to PTY: ${e.message}") + } } } + } catch (_: java.util.concurrent.RejectedExecutionException) { + // Executor shut down concurrently during destroy() } } @@ -150,15 +155,7 @@ class TerminalSession( fun destroy() { isRunning = false writeExecutor.shutdownNow() - ptyProcess?.let { process -> - try { - process.outputStream.close() - } catch (_: Exception) {} - try { - process.inputStream.close() - } catch (_: Exception) {} - process.destroy() - } + ptyProcess?.destroy() try { readerThread?.interrupt() } catch (_: Exception) {} diff --git a/app/src/main/java/org/cortex/terminal/view/ExtraKeysView.kt b/app/src/main/java/org/cortex/terminal/view/ExtraKeysView.kt index b9414a5..6953e2b 100644 --- a/app/src/main/java/org/cortex/terminal/view/ExtraKeysView.kt +++ b/app/src/main/java/org/cortex/terminal/view/ExtraKeysView.kt @@ -127,6 +127,12 @@ class ExtraKeysView @JvmOverloads constructor( layoutParams = params val moveSlopPx = 22 * resources.displayMetrics.density + var handledByTouch = false + setOnClickListener { + if (!handledByTouch) { + try { action() } catch (_: Exception) {} + } + } setOnTouchListener { v, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -159,7 +165,12 @@ class ExtraKeysView @JvmOverloads constructor( } MotionEvent.ACTION_UP -> { v.isPressed = false - v.performClick() + handledByTouch = true + try { + v.performClick() + } finally { + handledByTouch = false + } // Fire immediately if the tap ended before the deferral elapsed. // The manual run may arm repeat; UP always disarms, so taps // never repeat while holds keep ticking until release. diff --git a/app/src/main/java/org/cortex/terminal/view/TerminalView.kt b/app/src/main/java/org/cortex/terminal/view/TerminalView.kt index b204846..f0d4af6 100644 --- a/app/src/main/java/org/cortex/terminal/view/TerminalView.kt +++ b/app/src/main/java/org/cortex/terminal/view/TerminalView.kt @@ -33,6 +33,8 @@ import android.widget.OverScroller import android.widget.PopupWindow import android.widget.TextView import android.widget.Toast +import android.system.Os +import org.cortex.terminal.R import org.cortex.terminal.emulator.KeyMapper import org.cortex.terminal.emulator.TerminalBuffer import org.cortex.terminal.emulator.TerminalColor @@ -947,6 +949,26 @@ class TerminalView @JvmOverloads constructor( clearSelection() } + private fun pruneClipboardImages(dir: File) { + val files = dir.listFiles()?.filter { it.isFile } ?: return + val cutoff = System.currentTimeMillis() - 86_400_000L // 24 hours + val remaining = ArrayList(files.size) + for (f in files) { + if (f.lastModified() < cutoff) { + try { f.delete() } catch (_: Exception) {} + } else { + remaining.add(f) + } + } + if (remaining.size > 32) { + remaining.sortBy { it.lastModified() } + val toRemove = remaining.size - 32 + for (i in 0 until toRemove) { + try { remaining[i].delete() } catch (_: Exception) {} + } + } + } + private fun saveImageFromUri(uri: Uri, mimeType: String?): String? { try { if (uri.scheme == "file" && uri.path != null) { @@ -962,31 +984,49 @@ class TerminalView @JvmOverloads constructor( else -> ".png" } - val destDir = File(context.cacheDir, "images").apply { + val destDir = File(context.filesDir, "clipboard_images").apply { if (!exists()) { mkdirs() - setReadable(false, false) - setReadable(true, true) - setWritable(false, false) - setWritable(true, true) - setExecutable(false, false) - setExecutable(true, true) } + setReadable(false, false) + setReadable(true, true) + setWritable(false, false) + setWritable(true, true) + setExecutable(false, false) + setExecutable(true, true) + try { Os.chmod(absolutePath, 448) } catch (_: Exception) {} } - val timeStamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US).format(java.util.Date()) - val targetFile = File(destDir, "cortex_img_${timeStamp}${ext}") + pruneClipboardImages(destDir) - context.contentResolver.openInputStream(uri)?.use { inStream -> + val timeStamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss_SSS", java.util.Locale.US).format(java.util.Date()) + val uniqueSuffix = System.nanoTime() and 0xFFFF + val targetFile = File(destDir, "cortex_img_${timeStamp}_${uniqueSuffix}${ext}") + if (!targetFile.exists()) { + targetFile.createNewFile() + } + targetFile.setReadable(false, false) + targetFile.setReadable(true, true) + targetFile.setWritable(false, false) + targetFile.setWritable(true, true) + try { Os.chmod(targetFile.absolutePath, 384) } catch (_: Exception) {} + + val copied = context.contentResolver.openInputStream(uri)?.use { inStream -> targetFile.outputStream().use { outStream -> inStream.copyTo(outStream) } - } ?: return null + true + } ?: false + if (!copied) { + try { targetFile.delete() } catch (_: Exception) {} + return null + } targetFile.setReadable(false, false) targetFile.setReadable(true, true) targetFile.setWritable(false, false) targetFile.setWritable(true, true) + try { Os.chmod(targetFile.absolutePath, 384) } catch (_: Exception) {} return targetFile.absolutePath + " " } catch (e: Exception) { android.util.Log.e("TerminalView", "Failed to save clipboard image", e) @@ -1357,9 +1397,9 @@ class TerminalView @JvmOverloads constructor( } } info.text = visibleText.toString().trimEnd() - info.contentDescription = "Terminal Screen, ${cols} columns by ${rows} rows" + info.contentDescription = context.getString(R.string.terminal_screen_desc, cols, rows) } else { - info.contentDescription = "Terminal Screen (Inactive)" + info.contentDescription = context.getString(R.string.terminal_screen_inactive_desc) } } } diff --git a/app/src/main/res/layout/item_session.xml b/app/src/main/res/layout/item_session.xml index 43805bd..84baba4 100644 --- a/app/src/main/res/layout/item_session.xml +++ b/app/src/main/res/layout/item_session.xml @@ -52,6 +52,6 @@ android:textSize="16sp" android:textStyle="bold" android:background="@drawable/icon_circle_bg" - android:contentDescription="Close session" /> + android:contentDescription="@string/close_session" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be563dd..cedffc9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,4 +7,7 @@ Do you want to close this terminal session? Yes No + Terminal Screen, %1$d columns by %2$d rows + Terminal Screen (Inactive) + Close session From 56668f914fb82bdc776ffaf4509c87d301563839 Mon Sep 17 00:00:00 2001 From: woffluon Date: Tue, 6 Oct 2026 21:57:03 +0300 Subject: [PATCH 07/11] test: add unit, native, and shell regression test suites --- .../terminal/emulator/TerminalBufferTest.kt | 73 ++- .../terminal/emulator/TerminalEmulatorTest.kt | 45 ++ .../terminal/runtime/AptConfiguratorTest.kt | 179 +++++++ .../terminal/runtime/ElfLinkerPatcherTest.kt | 273 ++++++++++ .../runtime/ShellScriptsInstallerTest.kt | 66 +++ .../terminal/runtime/UrlOpenerServerTest.kt | 202 ++++++++ .../terminal/session/TerminalSessionTest.kt | 76 +++ .../terminal/update/UpdateManagerTest.kt | 113 +++++ .../cortex/terminal/view/ExtraKeysViewTest.kt | 28 ++ tests/native/test_cortex_native.c | 475 ++++++++++++++++++ tests/scripts/test_shell_scripts.sh | 389 ++++++++++++++ 11 files changed, 1918 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/org/cortex/terminal/emulator/TerminalEmulatorTest.kt create mode 100644 app/src/test/java/org/cortex/terminal/runtime/AptConfiguratorTest.kt create mode 100644 app/src/test/java/org/cortex/terminal/runtime/ElfLinkerPatcherTest.kt create mode 100644 app/src/test/java/org/cortex/terminal/runtime/ShellScriptsInstallerTest.kt create mode 100644 app/src/test/java/org/cortex/terminal/runtime/UrlOpenerServerTest.kt create mode 100644 app/src/test/java/org/cortex/terminal/session/TerminalSessionTest.kt create mode 100644 app/src/test/java/org/cortex/terminal/update/UpdateManagerTest.kt create mode 100644 app/src/test/java/org/cortex/terminal/view/ExtraKeysViewTest.kt create mode 100644 tests/native/test_cortex_native.c create mode 100644 tests/scripts/test_shell_scripts.sh diff --git a/app/src/test/java/org/cortex/terminal/emulator/TerminalBufferTest.kt b/app/src/test/java/org/cortex/terminal/emulator/TerminalBufferTest.kt index 1a16dbf..93aa4e4 100644 --- a/app/src/test/java/org/cortex/terminal/emulator/TerminalBufferTest.kt +++ b/app/src/test/java/org/cortex/terminal/emulator/TerminalBufferTest.kt @@ -8,7 +8,7 @@ class TerminalBufferTest { @Test fun testBufferWriteAndScroll() { val buffer = TerminalBuffer(rows = 5, cols = 10, maxHistory = 100) - + // Write simple line for (ch in "Hello") { buffer.writeChar(ch) @@ -37,4 +37,75 @@ class TerminalBufferTest { assertEquals("Hello", firstHistoryRow.getText()) assertEquals("Row5", buffer.screen[4].getText()) } + + @Test + fun testHistoryRolloverInvalidatesHistoryArrayAndRowsCache() { + val buffer = TerminalBuffer(rows = 2, cols = 10, maxHistory = 3) + + fun writeLine(text: String, newlineBefore: Boolean = true) { + if (newlineBefore) { + buffer.newLine() + buffer.carriageReturn() + } + for (ch in text) { + buffer.writeChar(ch) + } + } + + writeLine("Hist1", newlineBefore = false) + writeLine("Hist2") + writeLine("Hist3") + writeLine("Screen0") + writeLine("Screen1") + + // History is now at capacity (3: Hist1, Hist2, Hist3) + assertEquals(3, buffer.history.size) + // Prime both historyArrayCache and rowsCache at full capacity + assertEquals("Hist1", buffer.getVisibleRow(0, scrollOffset = 3).getText()) + assertEquals("Hist1", buffer.rowForIndex(-3)?.getText()) + assertEquals("Hist3", buffer.rowForIndex(-1)?.getText()) + + // Scroll one more line; history size remains 3 (Hist2, Hist3, Screen0) + writeLine("Screen2") + assertEquals(3, buffer.history.size) + + // Both caches must reflect the rolled-over history rather than stale Hist1 + assertEquals("Hist2", buffer.getVisibleRow(0, scrollOffset = 3).getText()) + assertEquals("Hist2", buffer.rowForIndex(-3)?.getText()) + assertEquals("Screen0", buffer.rowForIndex(-1)?.getText()) + } + + @Test + fun testEraseInDisplay3InvalidatesHistoryCaches() { + val buffer = TerminalBuffer(rows = 2, cols = 10, maxHistory = 3) + + fun writeLine(text: String, newlineBefore: Boolean = true) { + if (newlineBefore) { + buffer.newLine() + buffer.carriageReturn() + } + for (ch in text) { + buffer.writeChar(ch) + } + } + + writeLine("Old1", newlineBefore = false) + writeLine("Old2") + writeLine("Old3") + assertEquals(1, buffer.history.size) + assertEquals("Old1", buffer.getVisibleRow(0, scrollOffset = 1).getText()) + assertEquals("Old1", buffer.rowForIndex(-1)?.getText()) + + // Clear screen and scrollback (CSI 3 J) + buffer.eraseInDisplay(3) + buffer.cursorRow = 0 + buffer.cursorCol = 0 + + writeLine("New1", newlineBefore = false) + writeLine("New2") + writeLine("New3") + assertEquals(1, buffer.history.size) + assertEquals("New1", buffer.getVisibleRow(0, scrollOffset = 1).getText()) + assertEquals("New1", buffer.rowForIndex(-1)?.getText()) + } } diff --git a/app/src/test/java/org/cortex/terminal/emulator/TerminalEmulatorTest.kt b/app/src/test/java/org/cortex/terminal/emulator/TerminalEmulatorTest.kt new file mode 100644 index 0000000..13b9d8b --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/emulator/TerminalEmulatorTest.kt @@ -0,0 +1,45 @@ +package org.cortex.terminal.emulator + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TerminalEmulatorTest { + + @Test + fun testLargeInputExceeding16KBIsFullyConsumedWithoutDroppingBytes() { + val emulator = TerminalEmulator(rows = 5, cols = 80) + // 20,000 newlines/lines followed by a distinct marker on the last line + val prefix = "A\r\n".repeat(7000) // 21,000 bytes (> 16,384 byte buffer) + val marker = "FINAL_MARKER_OK" + val payload = (prefix + marker).toByteArray(Charsets.UTF_8) + + emulator.processInput(payload, 0, payload.size) + + val currentRowText = emulator.buffer.screen[emulator.buffer.cursorRow].getText() + assertEquals(marker, currentRowText) + } + + @Test + fun testSplitMultibyteUtf8FollowedByLargeChunkDoesNotCorruptBuffer() { + val emulator = TerminalEmulator(rows = 5, cols = 80) + // 'é' in UTF-8 is 0xC3 0xA9 + val firstByte = byteArrayOf(0xC3.toByte()) + emulator.processInput(firstByte, 0, 1) + + // Second chunk starts with 0xA9 to complete 'é', then 18,000 bytes (> 16 KB) across 250 lines (< 2000 maxHistory), ending with "TAIL_OK" + val line70 = "B".repeat(70) + val middle = "$line70\r\n".repeat(250).toByteArray(Charsets.UTF_8) + val tail = "TAIL_OK".toByteArray(Charsets.UTF_8) + val secondChunk = ByteArray(1 + middle.size + tail.size) + secondChunk[0] = 0xA9.toByte() + System.arraycopy(middle, 0, secondChunk, 1, middle.size) + System.arraycopy(tail, 0, secondChunk, 1 + middle.size, tail.size) + + emulator.processInput(secondChunk, 0, secondChunk.size) + + val firstHistoryText = emulator.buffer.history.first.getText() + assertEquals("é$line70", firstHistoryText) + val lastRowText = emulator.buffer.screen[emulator.buffer.cursorRow].getText() + assertEquals("TAIL_OK", lastRowText) + } +} diff --git a/app/src/test/java/org/cortex/terminal/runtime/AptConfiguratorTest.kt b/app/src/test/java/org/cortex/terminal/runtime/AptConfiguratorTest.kt new file mode 100644 index 0000000..0845388 --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/runtime/AptConfiguratorTest.kt @@ -0,0 +1,179 @@ +package org.cortex.terminal.runtime + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class AptConfiguratorTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun ensureUbuntuSources_writesHttpsDefaultAndRemovesLegacySourcesList() { + val root = tempFolder.newFolder("rootfs") + val legacyList = File(root, "etc/apt/sources.list").apply { + parentFile?.mkdirs() + writeText("deb http://ports.ubuntu.com/ubuntu-ports noble main\n") + } + + AptConfigurator.ensureUbuntuSources(root) + + val ubuntuSources = File(root, "etc/apt/sources.list.d/ubuntu.sources") + assertTrue("ubuntu.sources must be created", ubuntuSources.exists()) + val content = ubuntuSources.readText() + assertTrue("ubuntu.sources must use https://", content.contains("URIs: https://ports.ubuntu.com/ubuntu-ports/")) + assertFalse("ubuntu.sources must not use plaintext http://", content.contains("http://ports.ubuntu.com/ubuntu-ports/")) + assertTrue("ubuntu.sources must include Signed-By", content.contains("Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg")) + assertFalse("Legacy sources.list must be removed", legacyList.exists()) + } + + @Test + fun ensureUbuntuSources_upgradesExistingHttpToHttps() { + val root = tempFolder.newFolder("rootfs") + val ubuntuSources = File(root, "etc/apt/sources.list.d/ubuntu.sources").apply { + parentFile?.mkdirs() + writeText( + "Types: deb\n" + + "URIs: http://ports.ubuntu.com/ubuntu-ports/\n" + + "Suites: noble noble-updates\n" + + "Components: main universe\n" + + "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n" + ) + } + + AptConfigurator.ensureUbuntuSources(root) + + val updated = ubuntuSources.readText() + assertTrue(updated.contains("URIs: https://ports.ubuntu.com/ubuntu-ports/")) + assertFalse(updated.contains("http://ports.ubuntu.com/ubuntu-ports/")) + assertTrue(updated.contains("Suites: noble noble-updates")) + } + + @Test + fun ensureUbuntuSources_preservesCustomValidHttpsSources() { + val root = tempFolder.newFolder("rootfs") + val customContent = + "Types: deb\n" + + "URIs: https://mirror.example.org/ubuntu-ports/\n" + + "Suites: noble noble-security\n" + + "Components: main restricted\n" + + "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n" + val ubuntuSources = File(root, "etc/apt/sources.list.d/ubuntu.sources").apply { + parentFile?.mkdirs() + writeText(customContent) + } + + AptConfigurator.ensureUbuntuSources(root) + + assertEquals(customContent, ubuntuSources.readText()) + } + + @Test + fun hasExpectedKeyringDigest_acceptsValidSha256AndRejectsTamperedKeyIdFile() { + val root = tempFolder.newFolder("rootfs") + AptConfigurator.ensureKeyrings(root, null) + + val shareKeyring = File(root, "usr/share/keyrings/ubuntu-archive-keyring.gpg") + assertTrue("Official keyring must exist", shareKeyring.exists()) + assertTrue( + "Official keyring must pass SHA-256 verification", + AptConfigurator.hasExpectedKeyringDigest(shareKeyring) + ) + + // Forged file containing the 8-byte key ID 871920D1991BC93C followed by attacker payload + val forgedKeyring = File(root, "usr/share/keyrings/forged.gpg") + val keyIdBytes = byteArrayOf( + 0x87.toByte(), 0x19.toByte(), 0x20.toByte(), 0xd1.toByte(), + 0x99.toByte(), 0x1b.toByte(), 0xc9.toByte(), 0x3c.toByte() + ) + forgedKeyring.writeBytes(keyIdBytes + ByteArray(256) { 0x41 }) + assertFalse( + "Forged file containing 8-byte key ID must be rejected by SHA-256 check", + AptConfigurator.hasExpectedKeyringDigest(forgedKeyring) + ) + + // Truncated official keyring must also be rejected + val originalBytes = shareKeyring.readBytes() + val truncatedKeyring = File(root, "usr/share/keyrings/truncated.gpg") + truncatedKeyring.writeBytes(originalBytes.copyOf(originalBytes.size - 1)) + assertFalse( + "Truncated keyring must be rejected by SHA-256 check", + AptConfigurator.hasExpectedKeyringDigest(truncatedKeyring) + ) + } + + @Test + fun ensureKeyrings_preservesThirdPartyKeyringsAndUserGnupg() { + val root = tempFolder.newFolder("rootfs") + val trustedD = File(root, "etc/apt/trusted.gpg.d").apply { mkdirs() } + + val thirdPartyGpg = File(trustedD, "docker-archive-keyring.gpg") + val thirdPartyGpgBytes = byteArrayOf(0x99.toByte(), 0x01, 0x02, 0x03, 0x04) + thirdPartyGpg.writeBytes(thirdPartyGpgBytes) + + val thirdPartyAsc = File(trustedD, "nodesource.asc") + val thirdPartyAscText = "-----BEGIN PGP PUBLIC KEY BLOCK-----\nCustomKey\n-----END PGP PUBLIC KEY BLOCK-----\n" + thirdPartyAsc.writeText(thirdPartyAscText) + + // Corrupted managed keyring containing only the old 8-byte key ID + val corruptedManaged = File(trustedD, "ubuntu-keyring-2018-archive.gpg") + corruptedManaged.writeBytes( + byteArrayOf( + 0x87.toByte(), 0x19.toByte(), 0x20.toByte(), 0xd1.toByte(), + 0x99.toByte(), 0x1b.toByte(), 0xc9.toByte(), 0x3c.toByte() + ) + ) + + val userGnupg = File(root, "home/.gnupg/trustedkeys.gpg").apply { + parentFile?.mkdirs() + writeText("user-personal-keyring") + } + val rootGnupg = File(root, "root/.gnupg/trustedkeys.gpg").apply { + parentFile?.mkdirs() + writeText("root-personal-keyring") + } + + AptConfigurator.ensureKeyrings(root, null) + + assertTrue("Third-party .gpg file in trusted.gpg.d must be preserved", thirdPartyGpg.exists()) + assertArrayEquals(thirdPartyGpgBytes, thirdPartyGpg.readBytes()) + + assertTrue("Third-party .asc file in trusted.gpg.d must be preserved", thirdPartyAsc.exists()) + assertEquals(thirdPartyAscText, thirdPartyAsc.readText()) + + assertFalse("Corrupted managed ubuntu-keyring-2018-archive.gpg must be cleaned up", corruptedManaged.exists()) + + val managedTrusted = File(trustedD, "ubuntu-archive-keyring.gpg") + assertTrue("ubuntu-archive-keyring.gpg in trusted.gpg.d must be installed", managedTrusted.exists()) + assertTrue(AptConfigurator.hasExpectedKeyringDigest(managedTrusted)) + + assertEquals("user-personal-keyring", userGnupg.readText()) + assertEquals("root-personal-keyring", rootGnupg.readText()) + } + + @Test + fun ensureAptSandbox_writesSandboxAndSynchronizedProfileScript() { + val root = tempFolder.newFolder("rootfs") + AptConfigurator.ensureAptSandbox(root) + + val sandbox = File(root, "etc/apt/apt.conf.d/01sandbox") + assertTrue(sandbox.exists()) + val sandboxText = sandbox.readText() + assertTrue(sandboxText.contains("APT::Sandbox::Seccomp \"false\";")) + assertTrue(sandboxText.contains("Acquire::AllowInsecureRepositories \"false\";")) + assertTrue(sandboxText.contains("APT::Get::AllowUnauthenticated \"false\";")) + + val profileCortex = File(root, "etc/profile.d/01cortex.sh") + assertTrue(profileCortex.exists()) + val profileText = profileCortex.readText() + assertTrue(profileText.contains("TAR_OPTIONS")) + assertTrue(profileText.contains("GODEBUG=netdns=cgo")) + assertTrue(profileText.contains("/etc/cortex/autostart")) + } +} diff --git a/app/src/test/java/org/cortex/terminal/runtime/ElfLinkerPatcherTest.kt b/app/src/test/java/org/cortex/terminal/runtime/ElfLinkerPatcherTest.kt new file mode 100644 index 0000000..0b1db36 --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/runtime/ElfLinkerPatcherTest.kt @@ -0,0 +1,273 @@ +package org.cortex.terminal.runtime + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class ElfLinkerPatcherTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun buildElf64( + eMachine: Int = 183, // EM_AARCH64 + ePhoff: Long = 64L, + ePhentsize: Int = 56, + ePhnum: Int = 1, + segments: List> // (pFlags, fileOffset, payload) + ): ByteArray { + val maxEnd = segments.maxOfOrNull { it.second + it.third.size } ?: 256 + val totalSize = maxOf(256, maxEnd) + val buf = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN) + + // e_ident + buf.put(0, 0x7f.toByte()) + buf.put(1, 'E'.code.toByte()) + buf.put(2, 'L'.code.toByte()) + buf.put(3, 'F'.code.toByte()) + buf.put(4, 2.toByte()) // ELFCLASS64 + buf.put(5, 1.toByte()) // ELFDATA2LSB + buf.put(6, 1.toByte()) // EV_CURRENT + + buf.putShort(16, 3.toShort()) // ET_DYN + buf.putShort(18, eMachine.toShort()) + buf.putInt(20, 1) // EV_CURRENT + buf.putLong(32, ePhoff) // e_phoff + buf.putShort(52, 64.toShort()) // e_ehsize + buf.putShort(54, ePhentsize.toShort()) + buf.putShort(56, ePhnum.toShort()) + + if (ePhoff >= 0 && ePhentsize >= 56 && ePhoff + ePhnum.toLong() * ePhentsize.toLong() <= totalSize) { + segments.forEachIndexed { idx, (pFlags, offset, payload) -> + if (idx < ePhnum) { + val phPos = (ePhoff + idx * ePhentsize).toInt() + buf.putInt(phPos + 0, 1) // PT_LOAD + buf.putInt(phPos + 4, pFlags) + buf.putLong(phPos + 8, offset.toLong()) // p_offset + buf.putLong(phPos + 16, offset.toLong()) // p_vaddr + buf.putLong(phPos + 24, offset.toLong()) // p_paddr + buf.putLong(phPos + 32, payload.size.toLong()) // p_filesz + buf.putLong(phPos + 40, payload.size.toLong()) // p_memsz + buf.putLong(phPos + 48, 0x1000L) // p_align + } + val arr = buf.array() + payload.copyInto(arr, destinationOffset = offset) + } + } + return buf.array() + } + + @Test + fun patchDynamicLinker_patchesAarch64SyscallsInExecutablePtLoadSegment() { + val movX8_99 = byteArrayOf(0x68, 0x0c, 0x80.toByte(), 0xd2.toByte()) + val movX8_435 = byteArrayOf(0x68, 0x36, 0x80.toByte(), 0xd2.toByte()) + val movX8_293 = byteArrayOf(0xa8.toByte(), 0x24, 0x80.toByte(), 0xd2.toByte()) + val svc0 = byteArrayOf(0x01, 0x00, 0x00, 0xd4.toByte()) + val nop = byteArrayOf(0x1f, 0x20, 0x03, 0xd5.toByte()) + val movnX0_37 = byteArrayOf(0xa0.toByte(), 0x04, 0x80.toByte(), 0x92.toByte()) + + val codePayload = movX8_99 + svc0 + movX8_435 + svc0 + movX8_293 + svc0 + val elfBytes = buildElf64( + segments = listOf(Triple(0x5 /* PF_R | PF_X */, 0x80, codePayload)) + ) + + val file = File(tempFolder.root, "ld-2.39.so") + file.writeBytes(elfBytes) + + ElfLinkerPatcher.patchDynamicLinker(file) + + val patched = file.readBytes() + assertArrayEquals( + "set_robust_list svc #0 must be patched to nop", + nop, + patched.sliceArray(0x84 until 0x88) + ) + assertArrayEquals( + "clone3 svc #0 must be patched to movn x0, #37", + movnX0_37, + patched.sliceArray(0x8c until 0x90) + ) + assertArrayEquals( + "rseq svc #0 must be patched to movn x0, #37", + movnX0_37, + patched.sliceArray(0x94 until 0x98) + ) + } + + @Test + fun patchDynamicLinker_patchesArm32SyscallsInExecutablePtLoadSegment() { + val totalSize = 256 + val buf = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN) + buf.put(0, 0x7f.toByte()) + buf.put(1, 'E'.code.toByte()) + buf.put(2, 'L'.code.toByte()) + buf.put(3, 'F'.code.toByte()) + buf.put(4, 1.toByte()) // ELFCLASS32 + buf.put(5, 1.toByte()) // ELFDATA2LSB + buf.put(6, 1.toByte()) // EV_CURRENT + buf.putShort(16, 3.toShort()) // ET_DYN + buf.putShort(18, 40.toShort()) // EM_ARM + buf.putInt(20, 1) // EV_CURRENT + buf.putInt(28, 52) // e_phoff + buf.putShort(40, 52.toShort()) // e_ehsize + buf.putShort(42, 32.toShort()) // e_phentsize + buf.putShort(44, 1.toShort()) // e_phnum + + val movwR7_338 = byteArrayOf(0x52, 0x71, 0x00, 0xe3.toByte()) + val movwR7_435 = byteArrayOf(0xb3.toByte(), 0x71, 0x00, 0xe3.toByte()) + val svcArm = byteArrayOf(0x00, 0x00, 0x00, 0xef.toByte()) + val nopArm = byteArrayOf(0x00, 0xf0.toByte(), 0x20, 0xe3.toByte()) + val mvnR0_37 = byteArrayOf(0x25, 0x00, 0xe0.toByte(), 0xe3.toByte()) + val payload = movwR7_338 + svcArm + movwR7_435 + svcArm + + buf.putInt(52 + 0, 1) // PT_LOAD + buf.putInt(52 + 4, 0x80) // p_offset + buf.putInt(52 + 8, 0x80) // p_vaddr + buf.putInt(52 + 12, 0x80) // p_paddr + buf.putInt(52 + 16, payload.size) // p_filesz + buf.putInt(52 + 20, payload.size) // p_memsz + buf.putInt(52 + 24, 0x5) // PF_R | PF_X + buf.putInt(52 + 28, 0x1000) // p_align + payload.copyInto(buf.array(), destinationOffset = 0x80) + + val file = File(tempFolder.root, "ld-linux-armhf.so.3") + file.writeBytes(buf.array()) + + ElfLinkerPatcher.patchDynamicLinker(file) + + val patched = file.readBytes() + assertArrayEquals(nopArm, patched.sliceArray(0x84 until 0x88)) + assertArrayEquals(mvnR0_37, patched.sliceArray(0x8c until 0x90)) + } + + @Test + fun patchDynamicLinker_doesNotModifyNonExecutablePtLoadSegment() { + val movX8_435 = byteArrayOf(0x68, 0x36, 0x80.toByte(), 0xd2.toByte()) + val svc0 = byteArrayOf(0x01, 0x00, 0x00, 0xd4.toByte()) + val nop = byteArrayOf(0x1f, 0x20, 0x03, 0xd5.toByte()) + + val execPayload = nop + nop + val rodataPayload = movX8_435 + svc0 + + val elfBytes = buildElf64( + ePhnum = 2, + segments = listOf( + Triple(0x5 /* PF_R | PF_X */, 0xc0, execPayload), + Triple(0x4 /* PF_R only (non-executable) */, 0xe0, rodataPayload) + ) + ) + + val file = File(tempFolder.root, "libc.so.6") + file.writeBytes(elfBytes) + + ElfLinkerPatcher.patchDynamicLinker(file) + + val after = file.readBytes() + assertArrayEquals( + "Bytes in non-executable PT_LOAD segment must remain untouched", + rodataPayload, + after.sliceArray(0xe0 until 0xe8) + ) + assertArrayEquals(elfBytes, after) + } + + @Test + fun patchDynamicLinker_ignoresLinkerTextScriptAndWrongArchitecture() { + val ldScript = File(tempFolder.root, "libc.so") + val scriptBytes = ( + "/* GNU ld script */\n" + + "OUTPUT_FORMAT(elf64-littleaarch64)\n" + + "GROUP ( /lib/aarch64-linux-gnu/libc.so.6 /usr/lib/aarch64-linux-gnu/libc_nonshared.a )\n" + ).toByteArray(Charsets.UTF_8) + ldScript.writeBytes(scriptBytes) + + ElfLinkerPatcher.patchDynamicLinker(ldScript) + assertArrayEquals("GNU ld text script must not be modified", scriptBytes, ldScript.readBytes()) + + // EM_X86_64 = 62 + val movX8_435 = byteArrayOf(0x68, 0x36, 0x80.toByte(), 0xd2.toByte()) + val svc0 = byteArrayOf(0x01, 0x00, 0x00, 0xd4.toByte()) + val x86Elf = buildElf64( + eMachine = 62, + segments = listOf(Triple(0x5, 0x80, movX8_435 + svc0)) + ) + val x86File = File(tempFolder.root, "libc-x86_64.so") + x86File.writeBytes(x86Elf) + + ElfLinkerPatcher.patchDynamicLinker(x86File) + assertArrayEquals("Wrong e_machine ELF must not be modified", x86Elf, x86File.readBytes()) + } + + @Test + fun patchDynamicLinker_rejectsMalformedProgramHeaderTable() { + val movX8_435 = byteArrayOf(0x68, 0x36, 0x80.toByte(), 0xd2.toByte()) + val svc0 = byteArrayOf(0x01, 0x00, 0x00, 0xd4.toByte()) + + // Malformed e_phentsize < 56 + val badPhentsizeElf = buildElf64( + ePhentsize = 16, + segments = listOf(Triple(0x5, 0x80, movX8_435 + svc0)) + ) + val badPhentsizeFile = File(tempFolder.root, "ld-bad-phentsize.so") + badPhentsizeFile.writeBytes(badPhentsizeElf) + ElfLinkerPatcher.patchDynamicLinker(badPhentsizeFile) + assertArrayEquals(badPhentsizeElf, badPhentsizeFile.readBytes()) + + // Malformed e_phoff beyond file size + val badPhoffElf = buildElf64( + ePhoff = 4096L, + segments = listOf(Triple(0x5, 0x80, movX8_435 + svc0)) + ) + val badPhoffFile = File(tempFolder.root, "ld-bad-phoff.so") + badPhoffFile.writeBytes(badPhoffElf) + ElfLinkerPatcher.patchDynamicLinker(badPhoffFile) + assertArrayEquals(badPhoffElf, badPhoffFile.readBytes()) + } + + @Test + fun patchAllDynamicLinkers_enforcesCanonicalRootContainment() { + val rootDir = tempFolder.newFolder("cortex_root") + val outsideDir = tempFolder.newFolder("outside_dir") + + val movX8_435 = byteArrayOf(0x68, 0x36, 0x80.toByte(), 0xd2.toByte()) + val svc0 = byteArrayOf(0x01, 0x00, 0x00, 0xd4.toByte()) + val unpatchedElf = buildElf64( + segments = listOf(Triple(0x5, 0x80, movX8_435 + svc0)) + ) + + val outsideTarget = File(outsideDir, "libc-2.39.so") + outsideTarget.writeBytes(unpatchedElf) + + val rootCanonical = rootDir.canonicalFile.path + assertFalse( + "Outside file must not be considered contained in root", + ElfLinkerPatcher.isContainedInRoot(outsideTarget, rootCanonical) + ) + + val insideDir = File(rootDir, "usr/lib").apply { mkdirs() } + val insideFile = File(insideDir, "ld-linux-aarch64.so.1") + assertTrue( + "Inside file must be considered contained in root", + ElfLinkerPatcher.isContainedInRoot(insideFile, rootCanonical) + ) + + val symlinkCreated = ElfLinkerPatcher.createSymlink( + outsideTarget.absolutePath, + File(insideDir, "libc.so.6") + ) + if (symlinkCreated) { + ElfLinkerPatcher.patchAllDynamicLinkers(rootDir) + assertArrayEquals( + "Symlink target outside root must never be patched", + unpatchedElf, + outsideTarget.readBytes() + ) + } + } +} diff --git a/app/src/test/java/org/cortex/terminal/runtime/ShellScriptsInstallerTest.kt b/app/src/test/java/org/cortex/terminal/runtime/ShellScriptsInstallerTest.kt new file mode 100644 index 0000000..917eb7c --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/runtime/ShellScriptsInstallerTest.kt @@ -0,0 +1,66 @@ +package org.cortex.terminal.runtime + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ShellScriptsInstallerTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test(expected = IllegalStateException::class) + fun requireFunctionalScript_rejectsShebangOnlyStub() { + ShellScriptsInstaller.requireFunctionalScript("service.sh", "#!/bin/bash\n") + } + + @Test(expected = IllegalStateException::class) + fun requireFunctionalScript_rejectsBlankAndCommentOnlyScript() { + ShellScriptsInstaller.requireFunctionalScript("systemctl.sh", "#!/bin/sh\n# comment\n \n") + } + + @Test + fun ensureReloadScripts_installsHybridSourceAndExecScript() { + val root = tempFolder.newFolder("root") + val home = tempFolder.newFolder("home") + File(root, "usr/bin").mkdirs() + File(root, "bin").mkdirs() + File(home, ".local/bin").mkdirs() + + ShellScriptsInstaller.ensureReloadScripts(root, home, null) + + val usrBinReload = File(root, "usr/bin/reload") + val binReload = File(root, "bin/reload") + val localBinReload = File(home, ".local/bin/reload") + + for (f in listOf(usrBinReload, binReload, localBinReload)) { + assertTrue("Reload script must exist at ${f.path}", f.exists()) + val content = f.readText() + assertTrue("Reload script must detect sourced execution", content.contains("if (return 0 2>/dev/null); then")) + assertTrue("Reload script must source .bashrc when sourced", content.contains(". \"\$HOME/.bashrc\"")) + assertTrue("Reload script must exec login shell when executed directly", content.contains("exec \"\${SHELL:-/bin/bash}\" -l")) + } + } + + @Test + fun ensureRootTools_installsFunctionalTsuFallbackAndDoesNotWriteNoOpSuStub() { + val root = tempFolder.newFolder("root") + + ShellScriptsInstaller.ensureRootTools(root, null) + + val tsuFile = File(root, "usr/local/bin/tsu") + val sudoFile = File(root, "usr/local/bin/sudo") + val rootCmdFile = File(root, "usr/local/bin/root") + val suFile = File(root, "usr/local/bin/su") + + assertTrue("tsu must be installed with functional fallback", tsuFile.exists()) + assertTrue(tsuFile.readText().contains("exec /usr/local/bin/su \"\$@\"")) + assertTrue("sudo must be installed with functional fallback", sudoFile.exists()) + assertTrue(sudoFile.readText().contains("exec /usr/local/bin/su \"\$@\"")) + assertTrue("root wrapper must be installed with functional fallback", rootCmdFile.exists()) + assertFalse("su must not be installed as a broken 1-line shebang stub when asset is absent", suFile.exists()) + } +} diff --git a/app/src/test/java/org/cortex/terminal/runtime/UrlOpenerServerTest.kt b/app/src/test/java/org/cortex/terminal/runtime/UrlOpenerServerTest.kt new file mode 100644 index 0000000..bd17e15 --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/runtime/UrlOpenerServerTest.kt @@ -0,0 +1,202 @@ +package org.cortex.terminal.runtime + +import android.content.Context +import android.content.ContextWrapper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.file.Files + +class UrlOpenerServerTest { + + private lateinit var tempFilesDir: File + private lateinit var fakeContext: Context + + private class TestContext(private val dir: File) : ContextWrapper(null) { + override fun getApplicationContext(): Context = this + override fun getFilesDir(): File = dir + } + + @Before + fun setUp() { + UrlOpenerServer.stop() + UrlOpenerServer.authToken = null + UrlOpenerServer.clientSoTimeoutMs = 5000 + tempFilesDir = Files.createTempDirectory("cortex_url_opener_test").toFile() + fakeContext = TestContext(tempFilesDir) + } + + @After + fun tearDown() { + UrlOpenerServer.stop() + UrlOpenerServer.authToken = null + UrlOpenerServer.clientSoTimeoutMs = 5000 + tempFilesDir.deleteRecursively() + } + + @Test + fun testTokensMatchConstantTimeEqualityAndEdgeCases() { + val token = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + assertTrue(UrlOpenerServer.tokensMatch(token, token)) + + // Single-bit / single-char mismatch of identical length + val mismatchSameLen = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee" + assertFalse(UrlOpenerServer.tokensMatch(mismatchSameLen, token)) + + // Different length mismatch + assertFalse(UrlOpenerServer.tokensMatch(token.substring(0, 32), token)) + assertFalse(UrlOpenerServer.tokensMatch("$token-extra", token)) + + // Null and empty inputs must always return false + assertFalse(UrlOpenerServer.tokensMatch(null, token)) + assertFalse(UrlOpenerServer.tokensMatch("", token)) + assertFalse(UrlOpenerServer.tokensMatch(token, "")) + assertFalse(UrlOpenerServer.tokensMatch("", "")) + + // Unicode / multi-byte UTF-8 inputs + val unicodeToken = "cortex-🔐-token-ğüşiöç-2026" + assertTrue(UrlOpenerServer.tokensMatch(unicodeToken, unicodeToken)) + assertFalse(UrlOpenerServer.tokensMatch("cortex-🔓-token-ğüşiöç-2026", unicodeToken)) + } + + @Test + fun testUrlSchemeValidationAllowsHttpAndHttpsOnly() { + // Allowed schemes + assertEquals( + "https://example.com/auth?code=123", + UrlOpenerServer.normalizeAndValidateUrl("https://example.com/auth?code=123") + ) + assertEquals( + "http://127.0.0.1:8080/callback", + UrlOpenerServer.normalizeAndValidateUrl("http://127.0.0.1:8080/callback") + ) + assertEquals( + "https://github.com/login/device", + UrlOpenerServer.normalizeAndValidateUrl("github.com/login/device") + ) + assertTrue(UrlOpenerServer.openUrlInBrowser(fakeContext, "https://example.com")) + assertTrue(UrlOpenerServer.openUrlInBrowser(fakeContext, "http://example.com/test")) + + // Disallowed schemes: javascript:, file:, intent:, content:, ftp:, data: + assertNull(UrlOpenerServer.normalizeAndValidateUrl("javascript:alert(1)")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("javascript://example.com/%0Aalert(1)")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("JAVASCRIPT:alert(document.cookie)")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("file:///etc/passwd")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("file:/data/data/org.cortex.terminal/files/cortex_url_token")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("intent://scan/#Intent;scheme=zxing;package=com.example;end")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("intent:#Intent;action=android.intent.action.VIEW;end")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("content://com.android.contacts/contacts")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("ftp://ftp.example.com/pub")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("data:text/html,")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl("")) + assertNull(UrlOpenerServer.normalizeAndValidateUrl(" ")) + + assertFalse(UrlOpenerServer.openUrlInBrowser(fakeContext, "javascript:alert(1)")) + assertFalse(UrlOpenerServer.openUrlInBrowser(fakeContext, "file:///etc/passwd")) + assertFalse(UrlOpenerServer.openUrlInBrowser(fakeContext, "intent://example/#Intent;end")) + } + + @Test + fun testBoundedWorkerPoolHandlesStalledConnectionsWithoutStarvingValidRequests() { + UrlOpenerServer.clientSoTimeoutMs = 300 + val token = UrlOpenerServer.getOrCreateToken(fakeContext) + assertNotNull(token) + assertEquals(64, token.length) + + UrlOpenerServer.start(fakeContext, port = 0) + val port = UrlOpenerServer.activePort + assertTrue("Expected bound ephemeral port > 0, got $port", port > 0) + + val stalledSockets = mutableListOf() + try { + // 1. Open 2 concurrent stalled connections that send no data. + // Because workerPool scales up to 4 threads (and accept loop is on its own dedicated thread), + // a valid authenticated request must still be served immediately without waiting for timeout. + repeat(2) { + val s = Socket() + s.connect(InetSocketAddress("127.0.0.1", port), 1000) + stalledSockets.add(s) + } + + Socket().use { client -> + client.soTimeout = 2000 + client.connect(InetSocketAddress("127.0.0.1", port), 1000) + client.getOutputStream().write("OPEN $token https://example.com/active\n".toByteArray(Charsets.UTF_8)) + client.getOutputStream().flush() + val resp = client.getInputStream().bufferedReader(Charsets.UTF_8).readLine() + assertEquals("OK", resp) + } + + // 2. Flood the server with 25 additional stalled connections (exceeding 4 max threads + 16 queue capacity). + // Excess connections are rejected and closed immediately without stalling the accept thread. + repeat(25) { + try { + val s = Socket() + s.connect(InetSocketAddress("127.0.0.1", port), 1000) + stalledSockets.add(s) + } catch (_: Exception) { + // Connection refused/reset under overflow is acceptable + } + } + + // Wait briefly for the 300ms socket read timeout to evict stalled workers + Thread.sleep(750) + + // 3. Verify valid authenticated request succeeds after flood + Socket().use { client -> + client.soTimeout = 2000 + client.connect(InetSocketAddress("127.0.0.1", port), 1000) + client.getOutputStream().write("OPEN $token https://example.com/recovered\n".toByteArray(Charsets.UTF_8)) + client.getOutputStream().flush() + val resp = client.getInputStream().bufferedReader(Charsets.UTF_8).readLine() + assertEquals("OK", resp) + } + + // 4. Verify invalid token is rejected with ERR unauthorized + Socket().use { client -> + client.soTimeout = 2000 + client.connect(InetSocketAddress("127.0.0.1", port), 1000) + client.getOutputStream().write("OPEN wrong_token https://example.com\n".toByteArray(Charsets.UTF_8)) + client.getOutputStream().flush() + val resp = client.getInputStream().bufferedReader(Charsets.UTF_8).readLine() + assertTrue("Expected ERR unauthorized, got: $resp", resp != null && resp.startsWith("ERR unauthorized")) + } + + // 5. Verify disallowed scheme over authenticated socket returns ERR + Socket().use { client -> + client.soTimeout = 2000 + client.connect(InetSocketAddress("127.0.0.1", port), 1000) + client.getOutputStream().write("OPEN $token javascript:alert(1)\n".toByteArray(Charsets.UTF_8)) + client.getOutputStream().flush() + val resp = client.getInputStream().bufferedReader(Charsets.UTF_8).readLine() + assertEquals("ERR failed to open URL", resp) + } + } finally { + stalledSockets.forEach { + try { it.close() } catch (_: Exception) {} + } + } + + // 6. Verify stop() and start() can be called repeatedly + UrlOpenerServer.stop() + UrlOpenerServer.start(fakeContext, port = 0) + val secondPort = UrlOpenerServer.activePort + assertTrue(secondPort > 0) + Socket().use { client -> + client.soTimeout = 2000 + client.connect(InetSocketAddress("127.0.0.1", secondPort), 1000) + client.getOutputStream().write("OPEN $token https://example.com/restarted\n".toByteArray(Charsets.UTF_8)) + client.getOutputStream().flush() + val resp = client.getInputStream().bufferedReader(Charsets.UTF_8).readLine() + assertEquals("OK", resp) + } + } +} diff --git a/app/src/test/java/org/cortex/terminal/session/TerminalSessionTest.kt b/app/src/test/java/org/cortex/terminal/session/TerminalSessionTest.kt new file mode 100644 index 0000000..1da7b79 --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/session/TerminalSessionTest.kt @@ -0,0 +1,76 @@ +package org.cortex.terminal.session + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Modifier +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +class TerminalSessionTest { + + private fun allocateWithoutConstructor(): TerminalSession { + val unsafeClass = Class.forName("sun.misc.Unsafe") + val unsafeField = unsafeClass.getDeclaredField("theUnsafe") + unsafeField.isAccessible = true + val unsafe = unsafeField.get(null) + val allocateInstance = unsafeClass.getMethod("allocateInstance", Class::class.java) + val session = allocateInstance.invoke(unsafe, TerminalSession::class.java) as TerminalSession + + val execField = TerminalSession::class.java.getDeclaredField("writeExecutor") + execField.isAccessible = true + execField.set( + session, + Executors.newSingleThreadExecutor { r -> + Thread(r, "Test-PtyWriter").apply { isDaemon = true } + } + ) + return session + } + + @Test + fun testIsRunningFieldIsVolatile() { + val runningField = TerminalSession::class.java.getDeclaredField("isRunning") + assertTrue( + "isRunning must be @Volatile for thread-safe visibility across reader/writer/UI threads", + Modifier.isVolatile(runningField.modifiers) + ) + } + + @Test + fun testWriteAfterDestroyDoesNotThrowRejectedExecutionException() { + val session = allocateWithoutConstructor() + val runningField = TerminalSession::class.java.getDeclaredField("isRunning") + runningField.isAccessible = true + runningField.setBoolean(session, true) + + session.destroy() + assertFalse(session.isRunning) + + // Simulate race where caller checks isRunning=true right as writeExecutor shuts down + runningField.setBoolean(session, true) + session.write("echo test\n".toByteArray(Charsets.UTF_8)) + + // Also test when writeExecutor isn't reporting isShutdown yet but throws RejectedExecutionException on execute() + val execField = TerminalSession::class.java.getDeclaredField("writeExecutor") + execField.isAccessible = true + val rejectingExecutor = java.lang.reflect.Proxy.newProxyInstance( + ExecutorService::class.java.classLoader, + arrayOf(ExecutorService::class.java) + ) { _, method, _ -> + when (method.name) { + "isShutdown" -> false + "execute" -> throw RejectedExecutionException("Simulated shutdown race") + "shutdownNow" -> emptyList() + else -> null + } + } as ExecutorService + execField.set(session, rejectingExecutor) + + // Must not throw RejectedExecutionException + session.write("echo race\n".toByteArray(Charsets.UTF_8)) + session.destroy() + assertFalse(session.isRunning) + } +} diff --git a/app/src/test/java/org/cortex/terminal/update/UpdateManagerTest.kt b/app/src/test/java/org/cortex/terminal/update/UpdateManagerTest.kt new file mode 100644 index 0000000..28ac7e8 --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/update/UpdateManagerTest.kt @@ -0,0 +1,113 @@ +package org.cortex.terminal.update + +import android.content.ContextWrapper +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +class UpdateManagerTest { + + @Test + fun testIsVersionUpgradeRejectsDowngradeAndSameVersion() { + assertTrue(UpdateManager.isVersionUpgrade(12525L, 12526L)) + assertFalse(UpdateManager.isVersionUpgrade(12526L, 12526L)) + assertFalse(UpdateManager.isVersionUpgrade(12526L, 12525L)) + } + + @Test + fun testIsAuthorizedSignerSingleSignerAndKeyRotation() { + // Identical active signer + assertTrue( + UpdateManager.isAuthorizedSigner( + currentActiveSigners = setOf("CERT_V1"), + archiveActiveSigners = setOf("CERT_V1"), + archiveLineageSigners = listOf("CERT_V1"), + hasMultipleSigners = false + ) + ) + + // Forward key rotation: installed has CERT_V1, archive rotated to CERT_V2 with lineage [CERT_V1, CERT_V2] + assertTrue( + UpdateManager.isAuthorizedSigner( + currentActiveSigners = setOf("CERT_V1"), + archiveActiveSigners = setOf("CERT_V2"), + archiveLineageSigners = listOf("CERT_V1", "CERT_V2"), + hasMultipleSigners = false + ) + ) + + // Rollback attack: installed has CERT_V2, archive is signed with older rotated-out CERT_V1 + assertFalse( + UpdateManager.isAuthorizedSigner( + currentActiveSigners = setOf("CERT_V2"), + archiveActiveSigners = setOf("CERT_V1"), + archiveLineageSigners = listOf("CERT_V1", "CERT_V2"), + hasMultipleSigners = false + ) + ) + + // Unrelated signer + assertFalse( + UpdateManager.isAuthorizedSigner( + currentActiveSigners = setOf("CERT_V1"), + archiveActiveSigners = setOf("CERT_ATTACKER"), + archiveLineageSigners = listOf("CERT_ATTACKER"), + hasMultipleSigners = false + ) + ) + } + + @Test + fun testIsAuthorizedSignerMultipleSignersRequiresExactMatch() { + assertTrue( + UpdateManager.isAuthorizedSigner( + currentActiveSigners = setOf("CERT_A", "CERT_B"), + archiveActiveSigners = setOf("CERT_A", "CERT_B"), + archiveLineageSigners = emptyList(), + hasMultipleSigners = true + ) + ) + + // Subset / partial intersection must be rejected for multi-signer APKs + assertFalse( + UpdateManager.isAuthorizedSigner( + currentActiveSigners = setOf("CERT_A", "CERT_B"), + archiveActiveSigners = setOf("CERT_A"), + archiveLineageSigners = emptyList(), + hasMultipleSigners = true + ) + ) + } + + @Test + fun testCleanUpdatesSkipsDeletionWhenDownloadInProgress() { + val tempCache = File(System.getProperty("java.io.tmpdir"), "cortex_test_cache_${System.nanoTime()}") + val updatesDir = File(tempCache, "updates") + updatesDir.mkdirs() + val stagedApk = File(updatesDir, "cortex_update_123.apk") + stagedApk.writeText("dummy-apk") + + val testContext = object : ContextWrapper(null) { + override fun getCacheDir(): File = tempCache + } + + val field = UpdateManager::class.java.getDeclaredField("isDownloading") + field.isAccessible = true + val isDownloading = field.get(UpdateManager) as AtomicBoolean + + try { + isDownloading.set(true) + UpdateManager.cleanUpdates(testContext) + assertTrue("Staged APK must not be deleted while download is active", stagedApk.exists()) + + isDownloading.set(false) + UpdateManager.cleanUpdates(testContext) + assertFalse("Updates directory should be deleted when no download is active", updatesDir.exists()) + } finally { + isDownloading.set(false) + tempCache.deleteRecursively() + } + } +} diff --git a/app/src/test/java/org/cortex/terminal/view/ExtraKeysViewTest.kt b/app/src/test/java/org/cortex/terminal/view/ExtraKeysViewTest.kt new file mode 100644 index 0000000..fc9ff71 --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/view/ExtraKeysViewTest.kt @@ -0,0 +1,28 @@ +package org.cortex.terminal.view + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class ExtraKeysViewTest { + + @Test + fun testExtraKeysButtonsWireClickListenerWithTouchDeduplication() { + val candidates = listOf( + File("src/main/java/org/cortex/terminal/view/ExtraKeysView.kt"), + File("app/src/main/java/org/cortex/terminal/view/ExtraKeysView.kt") + ) + val sourceFile = candidates.firstOrNull { it.exists() } + ?: throw AssertionError("ExtraKeysView.kt not found") + val content = sourceFile.readText() + + assertTrue( + "ExtraKeysView must register setOnClickListener for accessibility/keyboard activation", + content.contains("setOnClickListener") + ) + assertTrue( + "ExtraKeysView must guard performClick with handledByTouch to avoid double-firing", + content.contains("handledByTouch") && content.contains("v.performClick()") + ) + } +} diff --git a/tests/native/test_cortex_native.c b/tests/native/test_cortex_native.c new file mode 100644 index 0000000..7d1494b --- /dev/null +++ b/tests/native/test_cortex_native.c @@ -0,0 +1,475 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CORTEX_HOST_TEST 1 +#include "../../app/src/main/cpp/cortex-pty.c" +#include "../../app/src/main/cpp/cortex-hook.c" + +static int g_tests_run = 0; +static int g_tests_failed = 0; + +#define ASSERT_TRUE(cond, msg) do { \ + g_tests_run++; \ + if (!(cond)) { \ + fprintf(stderr, "FAIL [%s:%d]: %s (%s)\n", __func__, __LINE__, msg, #cond); \ + g_tests_failed++; \ + return; \ + } \ +} while (0) + +#define ASSERT_EQ(actual, expected, msg) do { \ + g_tests_run++; \ + long long _a = (long long)(actual); \ + long long _e = (long long)(expected); \ + if (_a != _e) { \ + fprintf(stderr, "FAIL [%s:%d]: %s (expected %lld, got %lld)\n", __func__, __LINE__, msg, _e, _a); \ + g_tests_failed++; \ + return; \ + } \ +} while (0) + +#define ASSERT_STR_EQ(actual, expected, msg) do { \ + g_tests_run++; \ + const char *_a = (actual); \ + const char *_e = (expected); \ + if (!_a || !_e || strcmp(_a, _e) != 0) { \ + fprintf(stderr, "FAIL [%s:%d]: %s (expected '%s', got '%s')\n", __func__, __LINE__, msg, _e ? _e : "NULL", _a ? _a : "NULL"); \ + g_tests_failed++; \ + return; \ + } \ +} while (0) + +/* Helper: recursively remove a temporary directory */ +static void rm_rf(const char *path) { + if (!path || path[0] == '\0' || strcmp(path, "/") == 0) return; + char cmd[PATH_MAX + 32]; + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", path); + int r = system(cmd); + (void)r; +} + +/* Helper: populate a 512-byte POSIX ustar header with valid checksum */ +static void init_tar_header(char block[512], const char *name, const char *prefix, + unsigned long mode, unsigned long long size, + char typeflag, const char *linkname) { + memset(block, 0, 512); + if (name) { + strncpy(block, name, 100); + } + snprintf(block + 100, 8, "%07lo", mode & 07777UL); + snprintf(block + 108, 8, "%07o", 0); + snprintf(block + 116, 8, "%07o", 0); + snprintf(block + 124, 12, "%011llo", size); + snprintf(block + 136, 12, "%011o", 1700000000); + memset(block + 148, ' ', 8); + block[156] = typeflag; + if (linkname) { + strncpy(block + 157, linkname, 100); + } + memcpy(block + 257, "ustar", 5); + block[262] = '\0'; + memcpy(block + 263, "00", 2); + if (prefix) { + strncpy(block + 345, prefix, 155); + } + unsigned long chksum = 0; + for (int i = 0; i < 512; i++) { + chksum += (unsigned char)block[i]; + } + snprintf(block + 148, 7, "%06lo", chksum); + block[154] = '\0'; + block[155] = ' '; +} + +static void write_padded_data(int fd, const void *data, size_t len) { + if (len == 0) return; + ssize_t w = write(fd, data, len); + (void)w; + size_t rem = len % 512; + if (rem != 0) { + char pad[512]; + memset(pad, 0, sizeof(pad)); + w = write(fd, pad, 512 - rem); + (void)w; + } +} + +static void write_eof_blocks(int fd) { + char zero[1024]; + memset(zero, 0, sizeof(zero)); + ssize_t w = write(fd, zero, sizeof(zero)); + (void)w; +} + +/* 1. Test has_dotdot_component & strip_tar_prefix */ +static void test_tar_path_helpers(void) { + ASSERT_EQ(has_dotdot_component(".."), 1, "bare '..' must be rejected"); + ASSERT_EQ(has_dotdot_component("../a"), 1, "leading '../a' must be rejected"); + ASSERT_EQ(has_dotdot_component("a/../b"), 1, "middle 'a/../b' must be rejected"); + ASSERT_EQ(has_dotdot_component("a/.."), 1, "trailing 'a/..' must be rejected"); + ASSERT_EQ(has_dotdot_component("a/.../b"), 0, "triple dot 'a/.../b' must be allowed"); + ASSERT_EQ(has_dotdot_component("foo..bar"), 0, "'foo..bar' must be allowed"); + ASSERT_EQ(has_dotdot_component(".bashrc"), 0, "'.bashrc' must be allowed"); + + ASSERT_STR_EQ(strip_tar_prefix("./.bashrc"), ".bashrc", "leading './' stripped, dotfile '.bashrc' preserved"); + ASSERT_STR_EQ(strip_tar_prefix(".bashrc"), ".bashrc", "dotfile '.bashrc' preserved directly"); + ASSERT_STR_EQ(strip_tar_prefix("./.profile"), ".profile", "dotfile '.profile' preserved"); + ASSERT_STR_EQ(strip_tar_prefix("./root/.bashrc"), "root/.bashrc", "nested dotfile preserved"); + ASSERT_STR_EQ(strip_tar_prefix("///./usr/bin/env"), "usr/bin/env", "leading slashes and ./ stripped"); + ASSERT_STR_EQ(strip_tar_prefix("./"), "", "root directory entry './' becomes empty"); + ASSERT_STR_EQ(strip_tar_prefix("."), "", "root directory entry '.' becomes empty"); +} + +/* 2. Test symlink target containment */ +static void test_symlink_containment(void) { + ASSERT_EQ(is_symlink_target_safe("etc/alternatives/awk", "../../usr/bin/mawk"), 1, + "relative symlink staying within root must be allowed"); + ASSERT_EQ(is_symlink_target_safe("usr/bin/awk", "../../etc/alternatives/awk"), 1, + "usr/bin/awk -> ../../etc/alternatives/awk must be allowed"); + ASSERT_EQ(is_symlink_target_safe("etc/alternatives/awk", "../../../etc/passwd"), 0, + "relative symlink escaping root must be rejected"); + ASSERT_EQ(is_symlink_target_safe("top_link", "../outside"), 0, + "top-level symlink escaping root must be rejected"); + ASSERT_EQ(is_symlink_target_safe("etc/alternatives/awk", "/etc/passwd"), 0, + "absolute symlink target must be rejected"); +} + +/* 3. Test PAX extended header ('x') parsing for path= and linkpath= > 100 chars */ +static void test_pax_header_parsing(void) { + char long_p[256]; + memset(long_p, 'a', 140); + long_p[140] = '\0'; + memcpy(long_p, "usr/share/doc/", 14); + + char long_l[256]; + memset(long_l, 'b', 130); + long_l[130] = '\0'; + memcpy(long_l, "../lib/", 7); + + char pax_data[1024]; + char rec1[512], rec2[512]; + /* Format PAX record: " key=value\n" */ + int body1_len = snprintf(NULL, 0, " path=%s\n", long_p); + int total1 = body1_len + 3; /* 3 digits for length */ + snprintf(rec1, sizeof(rec1), "%d path=%s\n", total1, long_p); + + int body2_len = snprintf(NULL, 0, " linkpath=%s\n", long_l); + int total2 = body2_len + 3; + snprintf(rec2, sizeof(rec2), "%d linkpath=%s\n", total2, long_l); + + int offset = snprintf(pax_data, sizeof(pax_data), "%s%s11 size=42\n", rec1, rec2); + + char parsed_path[PATH_MAX] = {0}; + char parsed_link[PATH_MAX] = {0}; + unsigned long long parsed_size = 0; + int has_size = 0; + + int rc = parse_pax_headers(pax_data, (size_t)offset, + parsed_path, sizeof(parsed_path), + parsed_link, sizeof(parsed_link), + &parsed_size, &has_size); + ASSERT_EQ(rc, 0, "valid PAX header must parse cleanly"); + ASSERT_STR_EQ(parsed_path, long_p, "PAX path > 100 chars must match"); + ASSERT_STR_EQ(parsed_link, long_l, "PAX linkpath > 100 chars must match"); + ASSERT_EQ(has_size, 1, "PAX size must be flagged present"); + ASSERT_EQ(parsed_size, 42ULL, "PAX size must equal 42"); + + /* Oversized path >= PATH_MAX must be rejected */ + size_t huge_len = PATH_MAX + 64; + char *huge_pax = malloc(huge_len + 64); + ASSERT_TRUE(huge_pax != NULL, "alloc huge_pax"); + char *huge_val = malloc(huge_len + 1); + ASSERT_TRUE(huge_val != NULL, "alloc huge_val"); + memset(huge_val, 'x', huge_len); + huge_val[huge_len] = '\0'; + int body_huge = (int)huge_len + 7; /* " path=\n" */ + int digits = snprintf(NULL, 0, "%d", body_huge + 4); + int total_huge = body_huge + digits; + int written = snprintf(huge_pax, huge_len + 64, "%d path=%s\n", total_huge, huge_val); + int rc_huge = parse_pax_headers(huge_pax, (size_t)written, + parsed_path, sizeof(parsed_path), + parsed_link, sizeof(parsed_link), + &parsed_size, &has_size); + free(huge_val); + free(huge_pax); + ASSERT_EQ(rc_huge, -1, "oversized PAX path >= PATH_MAX must be rejected"); +} + +/* 4. End-to-end extract_tar_archive tests */ +static void test_extract_tar_end_to_end(void) { + char tmp_base[] = "/tmp/cortex_tar_test_XXXXXX"; + ASSERT_TRUE(mkdtemp(tmp_base) != NULL, "mkdtemp base"); + + /* 4a. Valid tar with dotfiles, regular files, and relative alternatives symlink */ + char tar_valid[PATH_MAX], dest_valid[PATH_MAX]; + snprintf(tar_valid, sizeof(tar_valid), "%s/valid.tar", tmp_base); + snprintf(dest_valid, sizeof(dest_valid), "%s/dest_valid", tmp_base); + mkdir(dest_valid, 0755); + + int fd = open(tar_valid, O_WRONLY | O_CREAT | O_TRUNC, 0644); + ASSERT_TRUE(fd >= 0, "open valid.tar"); + char blk[512]; + const char *bashrc_content = "export PATH=/usr/bin:/bin\n"; + init_tar_header(blk, "./.bashrc", NULL, 0644, strlen(bashrc_content), '0', NULL); + write(fd, blk, 512); + write_padded_data(fd, bashrc_content, strlen(bashrc_content)); + + const char *mawk_content = "mawk-binary"; + init_tar_header(blk, "./usr/bin/mawk", NULL, 0755, strlen(mawk_content), '0', NULL); + write(fd, blk, 512); + write_padded_data(fd, mawk_content, strlen(mawk_content)); + + init_tar_header(blk, "./etc/alternatives/awk", NULL, 0777, 0, '2', "../../usr/bin/mawk"); + write(fd, blk, 512); + + init_tar_header(blk, "./a/.../file.txt", NULL, 0644, 2, '0', NULL); + write(fd, blk, 512); + write_padded_data(fd, "ok", 2); + + write_eof_blocks(fd); + close(fd); + + ASSERT_EQ(extract_tar_archive(tar_valid, dest_valid), 0, "valid tar must extract with 0"); + + char check_path[PATH_MAX]; + struct stat st; + snprintf(check_path, sizeof(check_path), "%s/.bashrc", dest_valid); + ASSERT_EQ(lstat(check_path, &st), 0, ".bashrc must exist (not stripped to bashrc)"); + + snprintf(check_path, sizeof(check_path), "%s/a/.../file.txt", dest_valid); + ASSERT_EQ(lstat(check_path, &st), 0, "a/.../file.txt must exist"); + + snprintf(check_path, sizeof(check_path), "%s/etc/alternatives/awk", dest_valid); + ASSERT_EQ(lstat(check_path, &st), 0, "etc/alternatives/awk symlink must exist"); + ASSERT_TRUE(S_ISLNK(st.st_mode), "etc/alternatives/awk must be a symlink"); + + /* 4b. Escaping symlink tar must return -1 */ + char tar_escape[PATH_MAX], dest_escape[PATH_MAX]; + snprintf(tar_escape, sizeof(tar_escape), "%s/escape.tar", tmp_base); + snprintf(dest_escape, sizeof(dest_escape), "%s/dest_escape", tmp_base); + mkdir(dest_escape, 0755); + + fd = open(tar_escape, O_WRONLY | O_CREAT | O_TRUNC, 0644); + ASSERT_TRUE(fd >= 0, "open escape.tar"); + init_tar_header(blk, "etc/alternatives/awk", NULL, 0777, 0, '2', "../../../etc/passwd"); + write(fd, blk, 512); + write_eof_blocks(fd); + close(fd); + + ASSERT_EQ(extract_tar_archive(tar_escape, dest_escape), -1, "escaping symlink tar must return -1"); + + /* 4c. Symlinked-parent traversal tar must return -1 */ + char tar_symparent[PATH_MAX], dest_symparent[PATH_MAX], outside_dir[PATH_MAX]; + snprintf(tar_symparent, sizeof(tar_symparent), "%s/symparent.tar", tmp_base); + snprintf(dest_symparent, sizeof(dest_symparent), "%s/dest_symparent", tmp_base); + snprintf(outside_dir, sizeof(outside_dir), "%s/outside", tmp_base); + mkdir(dest_symparent, 0755); + mkdir(outside_dir, 0755); + + /* Create a pre-existing or in-archive symlink parent -> .. or outside_dir */ + fd = open(tar_symparent, O_WRONLY | O_CREAT | O_TRUNC, 0644); + ASSERT_TRUE(fd >= 0, "open symparent.tar"); + init_tar_header(blk, "a/sub", NULL, 0777, 0, '2', ".."); + write(fd, blk, 512); + init_tar_header(blk, "a/sub/evil.txt", NULL, 0644, 4, '0', NULL); + write(fd, blk, 512); + write_padded_data(fd, "evil", 4); + write_eof_blocks(fd); + close(fd); + + ASSERT_EQ(extract_tar_archive(tar_symparent, dest_symparent), -1, + "symlinked-parent traversal tar must return -1"); + + /* 4d. Truncated tar must return -1 */ + char tar_trunc[PATH_MAX], dest_trunc[PATH_MAX]; + snprintf(tar_trunc, sizeof(tar_trunc), "%s/trunc.tar", tmp_base); + snprintf(dest_trunc, sizeof(dest_trunc), "%s/dest_trunc", tmp_base); + mkdir(dest_trunc, 0755); + + fd = open(tar_trunc, O_WRONLY | O_CREAT | O_TRUNC, 0644); + ASSERT_TRUE(fd >= 0, "open trunc.tar"); + init_tar_header(blk, "truncated.bin", NULL, 0644, 2048, '0', NULL); + write(fd, blk, 512); + write(fd, "short", 5); /* Truncated payload! */ + close(fd); + + ASSERT_EQ(extract_tar_archive(tar_trunc, dest_trunc), -1, "truncated tar must return -1"); + + rm_rf(tmp_base); +} + +/* 5. Test check_elf_dynamic */ +static void test_check_elf_dynamic(void) { + char tmp_elf[] = "/tmp/cortex_elf_test_XXXXXX"; + int fd = mkstemp(tmp_elf); + ASSERT_TRUE(fd >= 0, "mkstemp elf"); + unlink(tmp_elf); + + /* 5a. Valid 64-bit little-endian ELF with PT_INTERP */ + unsigned char elf64[128]; + memset(elf64, 0, sizeof(elf64)); + elf64[0] = 0x7f; elf64[1] = 'E'; elf64[2] = 'L'; elf64[3] = 'F'; + elf64[4] = 2; /* ELFCLASS64 */ + elf64[5] = 1; /* ELFDATA2LSB */ + uint64_t phoff64 = 64; + uint16_t phentsize64 = 56; + uint16_t phnum64 = 1; + memcpy(elf64 + 32, &phoff64, sizeof(phoff64)); + memcpy(elf64 + 54, &phentsize64, sizeof(phentsize64)); + memcpy(elf64 + 56, &phnum64, sizeof(phnum64)); + uint32_t pt_interp = 3; /* PT_INTERP */ + memcpy(elf64 + 64, &pt_interp, sizeof(pt_interp)); + + ftruncate(fd, 0); + lseek(fd, 0, SEEK_SET); + write(fd, elf64, sizeof(elf64)); + ASSERT_EQ(check_elf_dynamic(fd, elf64, 64), 1, "valid 64-bit dynamic ELF must return 1"); + + /* 5b. Valid 32-bit little-endian ELF with PT_INTERP */ + unsigned char elf32[96]; + memset(elf32, 0, sizeof(elf32)); + elf32[0] = 0x7f; elf32[1] = 'E'; elf32[2] = 'L'; elf32[3] = 'F'; + elf32[4] = 1; /* ELFCLASS32 */ + elf32[5] = 1; /* ELFDATA2LSB */ + uint32_t phoff32 = 52; + uint16_t phentsize32 = 32; + uint16_t phnum32 = 1; + memcpy(elf32 + 28, &phoff32, sizeof(phoff32)); + memcpy(elf32 + 42, &phentsize32, sizeof(phentsize32)); + memcpy(elf32 + 44, &phnum32, sizeof(phnum32)); + memcpy(elf32 + 52, &pt_interp, sizeof(pt_interp)); + + ftruncate(fd, 0); + lseek(fd, 0, SEEK_SET); + write(fd, elf32, sizeof(elf32)); + ASSERT_EQ(check_elf_dynamic(fd, elf32, 52), 1, "valid 32-bit dynamic ELF must return 1"); + + /* 5c. Malformed e_phentsize = 1 (underflow attack) */ + uint16_t bad_phentsize = 1; + memcpy(elf64 + 54, &bad_phentsize, sizeof(bad_phentsize)); + ASSERT_EQ(check_elf_dynamic(fd, elf64, 64), 0, "malformed e_phentsize=1 must be rejected"); + + /* 5d. Malformed e_phentsize = 0 */ + bad_phentsize = 0; + memcpy(elf64 + 54, &bad_phentsize, sizeof(bad_phentsize)); + ASSERT_EQ(check_elf_dynamic(fd, elf64, 64), 0, "malformed e_phentsize=0 must be rejected"); + + /* 5e. phoff overflow (negative off_t when cast) */ + memcpy(elf64 + 54, &phentsize64, sizeof(phentsize64)); + uint64_t bad_phoff = UINT64_MAX - 10; + memcpy(elf64 + 32, &bad_phoff, sizeof(bad_phoff)); + ASSERT_EQ(check_elf_dynamic(fd, elf64, 64), 0, "overflowing phoff must be rejected"); + + close(fd); +} + +/* 6. Test find_dynamic_linker cache and cross-arch protection */ +static void test_find_dynamic_linker(void) { + char tmp_root[] = "/tmp/cortex_ld_test_XXXXXX"; + ASSERT_TRUE(mkdtemp(tmp_root) != NULL, "mkdtemp ld root"); + + char lib_dir[PATH_MAX], ld_aarch64[PATH_MAX]; + snprintf(lib_dir, sizeof(lib_dir), "%s/lib", tmp_root); + mkdir(lib_dir, 0755); + snprintf(ld_aarch64, sizeof(ld_aarch64), "%s/lib/ld-linux-aarch64.so.1", tmp_root); + int fd = open(ld_aarch64, O_WRONLY | O_CREAT | O_TRUNC, 0755); + ASSERT_TRUE(fd >= 0, "create ld-linux-aarch64.so.1"); + close(fd); + + /* Create fake arm64 binary (e_machine = 183) and arm32 binary (e_machine = 40) */ + char bin64[PATH_MAX], bin32[PATH_MAX], script[PATH_MAX]; + snprintf(bin64, sizeof(bin64), "%s/bin64", tmp_root); + snprintf(bin32, sizeof(bin32), "%s/bin32", tmp_root); + snprintf(script, sizeof(script), "%s/script", tmp_root); + + unsigned char ehdr[20] = {0}; + ehdr[0] = 0x7f; ehdr[1] = 'E'; ehdr[2] = 'L'; ehdr[3] = 'F'; + ehdr[18] = 183; ehdr[19] = 0; /* EM_AARCH64 */ + fd = open(bin64, O_WRONLY | O_CREAT | O_TRUNC, 0755); + write(fd, ehdr, sizeof(ehdr)); + close(fd); + + ehdr[18] = 40; ehdr[19] = 0; /* EM_ARM */ + fd = open(bin32, O_WRONLY | O_CREAT | O_TRUNC, 0755); + write(fd, ehdr, sizeof(ehdr)); + close(fd); + + fd = open(script, O_WRONLY | O_CREAT | O_TRUNC, 0755); + write(fd, "#!/bin/sh\n", 10); + close(fd); + + char out_ld[PATH_MAX] = {0}; + ASSERT_EQ(hook_find_dynamic_linker(tmp_root, bin64, out_ld, sizeof(out_ld)), 1, + "arm64 binary must find arm64 dynamic linker"); + ASSERT_STR_EQ(out_ld, ld_aarch64, "matched ld-linux-aarch64.so.1"); + + /* Must NOT fall back to 64-bit linker or hit 64-bit cache for 32-bit EM_ARM binary! */ + memset(out_ld, 0, sizeof(out_ld)); + ASSERT_EQ(hook_find_dynamic_linker(tmp_root, bin32, out_ld, sizeof(out_ld)), 0, + "32-bit EM_ARM binary must NOT poison/hit 64-bit linker cache or fall back to opposite bitness"); + + rm_rf(tmp_root); +} + +/* 7. Test Issue #7 NSS passwd/group fallback semantics */ +static void test_nss_fallback_semantics(void) { + struct passwd *pw = hook_getpwnam("root"); + ASSERT_TRUE(pw != NULL, "getpwnam('root') must return fallback root entry"); + ASSERT_EQ(pw->pw_uid, 0, "root uid must be 0"); + ASSERT_STR_EQ(pw->pw_name, "root", "root pw_name must be 'root'"); + + ASSERT_TRUE(hook_getpwnam("messagebus") == NULL, + "getpwnam('messagebus') must return NULL when absent from /etc/passwd"); + ASSERT_TRUE(hook_getpwnam("_apt") == NULL, + "getpwnam('_apt') must return NULL when absent from /etc/passwd"); + + struct passwd pwd_buf; + char str_buf[256]; + struct passwd *pw_res = &pwd_buf; + ASSERT_EQ(hook_getpwnam_r("messagebus", &pwd_buf, str_buf, sizeof(str_buf), &pw_res), 0, + "getpwnam_r('messagebus') must return 0 per POSIX when not found"); + ASSERT_TRUE(pw_res == NULL, "getpwnam_r('messagebus') *result must be NULL"); + + ASSERT_TRUE(hook_getpwuid(0) != NULL, "getpwuid(0) must return root fallback"); + ASSERT_TRUE(hook_getpwuid(100) == NULL, + "getpwuid(100) must return NULL so adduser first_avail_uid succeeds"); + ASSERT_EQ(hook_getpwuid_r(100, &pwd_buf, str_buf, sizeof(str_buf), &pw_res), 0, + "getpwuid_r(100) must return 0"); + ASSERT_TRUE(pw_res == NULL, "getpwuid_r(100) *result must be NULL"); + + struct group *gr = hook_getgrnam("root"); + ASSERT_TRUE(gr != NULL, "getgrnam('root') must return fallback root group"); + ASSERT_TRUE(hook_getgrnam("messagebus") == NULL, + "getgrnam('messagebus') must return NULL when absent from /etc/group"); + ASSERT_TRUE(hook_getgrgid(100) == NULL, + "getgrgid(100) must return NULL when absent from /etc/group"); +} + +int main(void) { + test_tar_path_helpers(); + test_symlink_containment(); + test_pax_header_parsing(); + test_extract_tar_end_to_end(); + test_check_elf_dynamic(); + test_find_dynamic_linker(); + test_nss_fallback_semantics(); + + if (g_tests_failed > 0) { + fprintf(stderr, "FAILED: %d/%d assertions failed\n", g_tests_failed, g_tests_run); + return 1; + } + printf("PASSED: all %d assertions succeeded\n", g_tests_run); + return 0; +} diff --git a/tests/scripts/test_shell_scripts.sh b/tests/scripts/test_shell_scripts.sh new file mode 100644 index 0000000..5fb8211 --- /dev/null +++ b/tests/scripts/test_shell_scripts.sh @@ -0,0 +1,389 @@ +#!/usr/bin/env bash +# Regression test suite for Cortex shell scripts: +# reload.sh, service.sh, systemctl.sh, su.sh, xdg-open.sh +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ASSETS_SCRIPTS="$REPO_ROOT/app/src/main/assets/scripts" + +PASS_COUNT=0 +FAIL_COUNT=0 + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf " [PASS] %s\n" "$1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + printf " [FAIL] %s\n" "$1" >&2 +} + +TEST_TMP="$(mktemp -d 2>/dev/null || mktemp -d -t cortex_script_tests)" +cleanup() { + rm -rf "$TEST_TMP" 2>/dev/null || true +} +trap cleanup EXIT + +echo "=== 0. Checking LF line endings and syntax ===" +for script_name in reload.sh service.sh systemctl.sh su.sh xdg-open.sh; do + script_path="$ASSETS_SCRIPTS/$script_name" + if LC_ALL=C grep -q $'\r' "$script_path"; then + fail "$script_name contains CRLF line endings" + else + pass "$script_name uses clean LF line endings" + fi + if bash -n "$script_path" 2>/dev/null; then + pass "$script_name passes bash -n syntax check" + else + fail "$script_name failed bash -n syntax check" + fi +done + +echo "=== 1. Testing reload.sh ===" +RELOAD_SH="$ASSETS_SCRIPTS/reload.sh" + +# 1a. Sourced mode: should source .bashrc in current shell +HOME_SOURCED="$TEST_TMP/home_sourced" +mkdir -p "$HOME_SOURCED" +cat > "$HOME_SOURCED/.bashrc" << 'EOF' +export RELOAD_SOURCED_VAR="sourced_ok" +EOF + +SOURCED_OUT="$(HOME="$HOME_SOURCED" bash -c '. "$1"; printf "%s" "${RELOAD_SOURCED_VAR:-missing}"' _ "$RELOAD_SH")" +if [[ "$SOURCED_OUT" == *"Environment reloaded."* ]] && [[ "$SOURCED_OUT" == *"sourced_ok"* ]]; then + pass "reload.sh sourced mode updates current shell environment" +else + fail "reload.sh sourced mode failed: output='$SOURCED_OUT'" +fi + +# 1b. Executed mode: should exec $SHELL -l +MOCK_SHELL="$TEST_TMP/mock_shell" +cat > "$MOCK_SHELL" << 'EOF' +#!/usr/bin/env bash +printf "MOCK_SHELL_ARGS:%s\n" "$*" +EOF +chmod +x "$MOCK_SHELL" + +EXEC_OUT="$(HOME="$HOME_SOURCED" SHELL="$MOCK_SHELL" bash "$RELOAD_SH" 2>&1)" +if [[ "$EXEC_OUT" == *"Environment reloaded."* ]] && [[ "$EXEC_OUT" == *"MOCK_SHELL_ARGS:-l"* ]]; then + pass "reload.sh executed mode execs \$SHELL -l" +else + fail "reload.sh executed mode did not exec \$SHELL -l: output='$EXEC_OUT'" +fi + +echo "=== 2. Testing service.sh & systemctl.sh ===" +SERVICE_SH="$ASSETS_SCRIPTS/service.sh" +SYSTEMCTL_SH="$ASSETS_SCRIPTS/systemctl.sh" + +SVC_ROOT="$TEST_TMP/svc_env" +mkdir -p "$SVC_ROOT/run" "$SVC_ROOT/init.d" "$SVC_ROOT/bin" "$SVC_ROOT/autostart" + +export CORTEX_RUN_DIR="$SVC_ROOT/run" +export CORTEX_INIT_DIR="$SVC_ROOT/init.d" +export CORTEX_SERVICE_PATH="$SVC_ROOT/bin" +export CORTEX_AUTOSTART_DIR="$SVC_ROOT/autostart" + +# 2a. Service name validation (prevent path traversal) +if bash "$SERVICE_SH" "../etc/passwd" status >/dev/null 2>&1; then + fail "service.sh allowed path traversal '../etc/passwd'" +else + pass "service.sh rejects path traversal '../etc/passwd'" +fi + +if bash "$SERVICE_SH" ".." status >/dev/null 2>&1; then + fail "service.sh allowed '..'" +else + pass "service.sh rejects '..'" +fi + +if bash "$SYSTEMCTL_SH" enable "../traversal" >/dev/null 2>&1 || [ -e "$SVC_ROOT/traversal" ]; then + fail "systemctl.sh enable allowed path traversal '../traversal'" +else + pass "systemctl.sh enable rejects path traversal '../traversal'" +fi + +if bash "$SYSTEMCTL_SH" disable "../traversal" >/dev/null 2>&1; then + fail "systemctl.sh disable allowed path traversal '../traversal'" +else + pass "systemctl.sh disable rejects path traversal '../traversal'" +fi + +if bash "$SYSTEMCTL_SH" is-enabled "../traversal" >/dev/null 2>&1; then + fail "systemctl.sh is-enabled allowed path traversal '../traversal'" +else + pass "systemctl.sh is-enabled rejects path traversal '../traversal'" +fi + +# 2b. Immediate crash detection on start +cat > "$SVC_ROOT/bin/crashsvc" << 'EOF' +#!/usr/bin/env bash +exit 1 +EOF +chmod +x "$SVC_ROOT/bin/crashsvc" + +if bash "$SERVICE_SH" crashsvc start >/dev/null 2>&1; then + fail "service.sh start succeeded for immediately crashing daemon" +elif [ -f "$SVC_ROOT/run/crashsvc.pid" ]; then + fail "service.sh start wrote PIDFILE for immediately crashing daemon" +else + pass "service.sh start detects immediate daemon exit and does not write PIDFILE" +fi + +# 2c. Substring process isolation (unrelated process with service name in args must not match or be killed) +bash -c 'sleep 30' _ " fake_argument_containing_mytestsvc " & +BYSTANDER_PID=$! + +cat > "$SVC_ROOT/bin/mytestsvc" << 'EOF' +#!/usr/bin/env bash +sleep 30 +EOF +chmod +x "$SVC_ROOT/bin/mytestsvc" + +if bash "$SERVICE_SH" mytestsvc status >/dev/null 2>&1; then + fail "service.sh status falsely matched bystander process with substring in cmdline" +else + pass "service.sh status ignores bystander process with substring in cmdline" +fi + +# 2d. Stale PID file pointing to bystander process must be ignored and cleaned up on status/start +echo "$BYSTANDER_PID" > "$SVC_ROOT/run/mytestsvc.pid" +if bash "$SERVICE_SH" mytestsvc status >/dev/null 2>&1; then + fail "service.sh status trusted PIDFILE pointing to unrelated bystander process" +else + pass "service.sh status rejects PIDFILE pointing to unrelated process" +fi + +# Start real service, verify status and stop without killing bystander +if bash "$SERVICE_SH" mytestsvc start >/dev/null 2>&1 && bash "$SERVICE_SH" mytestsvc status >/dev/null 2>&1; then + pass "service.sh starts and detects real mytestsvc daemon" +else + fail "service.sh failed to start or detect real mytestsvc daemon" +fi + +bash "$SERVICE_SH" mytestsvc stop >/dev/null 2>&1 +if kill -0 "$BYSTANDER_PID" 2>/dev/null; then + pass "service.sh stop did not kill unrelated bystander process" +else + fail "service.sh stop killed unrelated bystander process!" +fi +kill "$BYSTANDER_PID" 2>/dev/null || true +wait "$BYSTANDER_PID" 2>/dev/null || true + +echo "=== 3. Testing su.sh ===" +SU_SH="$ASSETS_SCRIPTS/su.sh" + +# Check static structure of su.sh for sq() helper and RUN_SU array +if grep -q 'sq()' "$SU_SH" && grep -q 'RUN_SU=(' "$SU_SH"; then + pass "su.sh defines sq() helper and uses RUN_SU array" +else + fail "su.sh is missing sq() helper or RUN_SU array" +fi + +# Functional test of su.sh with a mock HOST_SU +SU_TEST_DIR="$TEST_TMP/su dir with spaces & 'quotes'" +mkdir -p "$SU_TEST_DIR/bin" "$SU_TEST_DIR/root" +cp "$(command -v bash)" "$SU_TEST_DIR/bin/bash" 2>/dev/null || ln -s "$(command -v bash)" "$SU_TEST_DIR/bin/bash" + +MOCK_SU="$TEST_TMP/mock_su" +cat > "$MOCK_SU" << 'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = "-c" ]; then + shift + # Verify TERM does not contain literal single quotes + case "${TERM:-}" in + \'*\') + echo "LITERAL_QUOTES_IN_TERM:$TERM" >&2 + exit 2 + ;; + esac + exec bash -c "$1" +fi +exec bash "$@" +EOF +chmod +x "$MOCK_SU" + +PWN_SU_FILE="$TEST_TMP/su_injected" +SU_OUT="$( + CORTEX_HOST_SU="$MOCK_SU" \ + CORTEX_ROOT="$SU_TEST_DIR" \ + CORTEX_URL_PORT="4715" \ + CORTEX_URL_TOKEN="tok_123" \ + bash "$SU_SH" printf '%s|%s|%s' "arg with spaces" "arg'quote" "\$(touch \"$PWN_SU_FILE\")" 2>&1 +)" +if [ -e "$PWN_SU_FILE" ]; then + fail "su.sh executed command injection payload in user arguments" +elif [ "$SU_OUT" = "arg with spaces|arg'quote|\$(touch \"$PWN_SU_FILE\")" ]; then + pass "su.sh preserves complex arguments and prevents command injection" +else + fail "su.sh argument preservation failed: got '$SU_OUT'" +fi + +SU_C_OUT="$( + CORTEX_HOST_SU="$MOCK_SU" \ + CORTEX_ROOT="$SU_TEST_DIR" \ + CORTEX_URL_PORT="4715" \ + CORTEX_URL_TOKEN="tok'456" \ + bash "$SU_SH" -c 'printf "%s|%s" "$CORTEX_URL_TOKEN" "$1"' _ "val'with'quote" 2>&1 +)" +if [ "$SU_C_OUT" = "tok'456|val'with'quote" ]; then + pass "su.sh -c mode preserves CORTEX_URL_TOKEN and positional arguments with single quotes" +else + fail "su.sh -c mode failed: got '$SU_C_OUT'" +fi + +echo "=== 4. Testing xdg-open.sh ===" +XDG_OPEN_SH="$ASSETS_SCRIPTS/xdg-open.sh" + +# 4a. Test URL command injection & auth token verification over local TCP server using python3 +if command -v python3 >/dev/null 2>&1; then + PWN_XDG_FILE="$TEST_TMP/xdg_pwned" + REQ_LOG="$TEST_TMP/xdg_req.log" + PORT_FILE="$TEST_TMP/xdg_port" + + python3 -c ' +import socket, sys + +port_file, req_log, mode = sys.argv[1], sys.argv[2], sys.argv[3] +srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(("127.0.0.1", 0)) +srv.listen(5) +with open(port_file, "w") as f: + f.write(str(srv.getsockname()[1])) +srv.settimeout(5.0) +conn, _ = srv.accept() +data = conn.recv(4096).decode("utf-8", "replace") +with open(req_log, "w") as f: + f.write(data) +if mode == "ok": + conn.sendall(b"OK\n") +else: + conn.sendall(b"ERR unauthorized\n") +conn.close() +srv.close() +' "$PORT_FILE" "$REQ_LOG" "ok" & + SRV_PID=$! + + for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$PORT_FILE" ] && break + sleep 0.05 + done + TEST_PORT="$(cat "$PORT_FILE" 2>/dev/null || echo 4715)" + + INJECT_URL="https://example.com/\$(touch \"$PWN_XDG_FILE\")\"'\$(id)'" + if CORTEX_URL_PORT="$TEST_PORT" CORTEX_URL_TOKEN="secret_token_abc" sh "$XDG_OPEN_SH" "$INJECT_URL" >/dev/null 2>&1; then + pass "xdg-open.sh succeeded on OK response from server" + else + fail "xdg-open.sh failed on OK response from server" + fi + wait "$SRV_PID" 2>/dev/null || true + + if [ -e "$PWN_XDG_FILE" ]; then + fail "xdg-open.sh vulnerable to command injection in URL!" + else + pass "xdg-open.sh prevented command injection in URL" + fi + + RECEIVED_LINE="$(tr -d '\r\n' < "$REQ_LOG" 2>/dev/null || true)" + EXPECTED_LINE="OPEN secret_token_abc $INJECT_URL" + if [ "$RECEIVED_LINE" = "$EXPECTED_LINE" ]; then + pass "xdg-open.sh sent exact 'OPEN ' payload" + else + fail "xdg-open.sh payload mismatch: got '$RECEIVED_LINE', expected '$EXPECTED_LINE'" + fi + + # 4b. Test reading token fallback from $CORTEX_ROOT/../cortex_url_token + rm -f "$PORT_FILE" "$REQ_LOG" + FAKE_FILES_DIR="$TEST_TMP/app_files" + mkdir -p "$FAKE_FILES_DIR/cortex" + printf "file_fallback_token_999\n" > "$FAKE_FILES_DIR/cortex_url_token" + + python3 -c ' +import socket, sys +port_file, req_log = sys.argv[1], sys.argv[2] +srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(("127.0.0.1", 0)) +srv.listen(5) +with open(port_file, "w") as f: + f.write(str(srv.getsockname()[1])) +srv.settimeout(5.0) +conn, _ = srv.accept() +data = conn.recv(4096).decode("utf-8", "replace") +with open(req_log, "w") as f: + f.write(data) +conn.sendall(b"OK\n") +conn.close() +srv.close() +' "$PORT_FILE" "$REQ_LOG" & + SRV_PID=$! + for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$PORT_FILE" ] && break + sleep 0.05 + done + TEST_PORT="$(cat "$PORT_FILE" 2>/dev/null || echo 4715)" + + if CORTEX_URL_PORT="$TEST_PORT" CORTEX_URL_TOKEN="" CORTEX_ROOT="$FAKE_FILES_DIR/cortex" sh "$XDG_OPEN_SH" "https://example.com/fallback" >/dev/null 2>&1; then + RECEIVED_FALLBACK="$(tr -d '\r\n' < "$REQ_LOG" 2>/dev/null || true)" + if [ "$RECEIVED_FALLBACK" = "OPEN file_fallback_token_999 https://example.com/fallback" ]; then + pass "xdg-open.sh reads fallback token from \$CORTEX_ROOT/../cortex_url_token" + else + fail "xdg-open.sh fallback token mismatch: got '$RECEIVED_FALLBACK'" + fi + else + fail "xdg-open.sh failed when using fallback token file" + fi + wait "$SRV_PID" 2>/dev/null || true + + # 4c. Test ERR / HTTP 401 rejection across all transports + rm -f "$PORT_FILE" "$REQ_LOG" + python3 -c ' +import socket, sys + +port_file = sys.argv[1] +srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(("127.0.0.1", 0)) +srv.listen(10) +with open(port_file, "w") as f: + f.write(str(srv.getsockname()[1])) +srv.settimeout(3.0) +for _ in range(6): + try: + conn, _ = srv.accept() + req = conn.recv(4096).decode("utf-8", "replace") + if req.startswith("GET "): + body = b"ERR unauthorized\n" + conn.sendall(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: " + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + else: + conn.sendall(b"ERR unauthorized\n") + conn.close() + except Exception: + break +srv.close() +' "$PORT_FILE" & + SRV_PID=$! + + for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$PORT_FILE" ] && break + sleep 0.05 + done + TEST_PORT="$(cat "$PORT_FILE" 2>/dev/null || echo 4715)" + + if CORTEX_URL_PORT="$TEST_PORT" CORTEX_URL_TOKEN="bad_token" sh "$XDG_OPEN_SH" "https://example.com" >/dev/null 2>&1; then + fail "xdg-open.sh exited 0 despite ERR / 401 Unauthorized from server" + else + pass "xdg-open.sh exits non-zero on ERR / 401 Unauthorized from server" + fi + kill "$SRV_PID" 2>/dev/null || true + wait "$SRV_PID" 2>/dev/null || true +fi + +echo "" +echo "Summary: $PASS_COUNT passed, $FAIL_COUNT failed" +if [ "$FAIL_COUNT" -ne 0 ]; then + exit 1 +fi +exit 0 From 2143e34c53fc9f6a7c53388fdd757615f9f57f9d Mon Sep 17 00:00:00 2001 From: woffluon Date: Wed, 7 Oct 2026 00:20:12 +0300 Subject: [PATCH 08/11] fix(runtime): patch ARM Thumb-2 syscalls and link dbus machine-id - Add halfword-aligned Thumb-2 instruction scanning for inline movw r7, #imm + svc #0 and mov.w r12, #imm + bl __libc_do_syscall sequences in ElfLinkerPatcher and CI patch_elf_bytes - Replace 0-byte regular file in setup-extra-rootfs.sh with relative symlink from /var/lib/dbus/machine-id to /etc/machine-id - Update BootstrapManager.ensureMachineId to replace undersized regular files with symlink or populated identifier - Add ElfLinkerPatcherTest coverage for Thumb-2 inline and bl __libc_do_syscall patching --- .github/workflows/build-release.yml | 73 ++++++++- .../terminal/runtime/BootstrapManager.kt | 10 +- .../terminal/runtime/ElfLinkerPatcher.kt | 131 +++++++++++++++- .../terminal/runtime/ElfLinkerPatcherTest.kt | 141 ++++++++++++++++++ scripts/setup-extra-rootfs.sh | 3 +- 5 files changed, 340 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index d8faf37..07fccd1 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -357,21 +357,86 @@ jobs: ("clone3", b"\xb3\x71\x00\xe3", mov_enosys_arm), # movw r7, #0x1b3 (435) ("rseq", b"\x8e\x71\x00\xe3", mov_enosys_arm), # movw r7, #0x18e (398) ] + svc_thumb = b"\x00\xdf" # svc #0 in Thumb mode + nop_thumb16 = b"\x00\xbf" # 16-bit nop in Thumb mode + mov_r0_zero_thumb = b"\x4f\xf0\x00\x00" # mov.w r0, #0 + mvn_enosys_thumb = b"\x6f\xf0\x25\x00" # mvn.w r0, #37 (-38) + patches_thumb_r7 = [ + ("set_robust_list", b"\x40\xf2\x52\x17", True), # movw r7, #0x152 (338) + ("clone3", b"\x40\xf2\xb3\x17", False), # movw r7, #0x1b3 (435) + ("rseq", b"\x40\xf2\x8e\x17", False), # movw r7, #0x18e (398) + ] + patches_thumb_r12 = [ + ("set_robust_list", b"\x4f\xf4\xa9\x7c", mov_r0_zero_thumb), # mov.w r12, #0x152 (338) + ("rseq", b"\x4f\xf4\xc7\x7c", mvn_enosys_thumb), # mov.w r12, #0x18e (398) + ("clone3", b"\x40\xf2\xb3\x1c", mvn_enosys_thumb), # movw r12, #0x1b3 (435) + ] + libc_do_syscall_stub = b"\x80\xb5\x67\x46\x00\xdf" # push {r7, lr}; mov r7, r12; svc #0 + + def decode_thumb_bl(buf, off): + if off < 0 or off + 4 > len(buf): + return None + hw1, hw2 = struct.unpack_from("> 10) & 1 + imm10 = hw1 & 0x3ff + j1 = (hw2 >> 13) & 1 + j2 = (hw2 >> 11) & 1 + imm11 = hw2 & 0x7ff + i1 = (j1 ^ s) ^ 1 + i2 = (j2 ^ s) ^ 1 + imm25 = (s << 24) | (i1 << 23) | (i2 << 22) | (imm10 << 12) | (imm11 << 1) + if s: + imm25 -= (1 << 25) + return off + 4 + imm25 + for seg_start, seg_end in segments: seg_start = max(0, seg_start) seg_end = min(len(data), seg_end) - start_aligned = (seg_start + 3) & ~3 - end_aligned = seg_end & ~3 - for pos in range(start_aligned, end_aligned - 4, 4): + start_aligned4 = (seg_start + 3) & ~3 + end_aligned4 = seg_end & ~3 + for pos in range(start_aligned4, end_aligned4 - 4, 4): for sc_name, pat, repl in patches_arm: if data[pos:pos+4] == pat: - for s_pos in range(pos + 4, min(end_aligned, pos + 68), 4): + for s_pos in range(pos + 4, min(end_aligned4, pos + 68), 4): if data[s_pos:s_pos+4] == svc_arm: data[s_pos:s_pos+4] = repl patched_count += 1 print(f"Patched ARM {sc_name} at 0x{s_pos:x} in {filename}") break + start_aligned2 = (seg_start + 1) & ~1 + end_aligned2 = seg_end & ~1 + for t_pos in range(start_aligned2, end_aligned2 - 4, 2): + matched_r7 = False + for sc_name, pat, is_nop_only in patches_thumb_r7: + if data[t_pos:t_pos+4] == pat: + matched_r7 = True + for s_pos in range(t_pos + 4, min(end_aligned2 - 2, t_pos + 64) + 2, 2): + if data[s_pos:s_pos+2] == svc_thumb: + if is_nop_only: + data[s_pos:s_pos+2] = nop_thumb16 + else: + data[t_pos:t_pos+4] = mvn_enosys_thumb + data[s_pos:s_pos+2] = nop_thumb16 + patched_count += 1 + print(f"Patched Thumb-2 {sc_name} svc #0 at 0x{s_pos:x} in {filename}") + break + break + if not matched_r7: + for sc_name, pat, repl in patches_thumb_r12: + if data[t_pos:t_pos+4] == pat: + for s_pos in range(t_pos + 4, min(end_aligned2 - 4, t_pos + 48) + 2, 2): + bl_target = decode_thumb_bl(data, s_pos) + if bl_target is not None and seg_start <= bl_target <= seg_end - 6: + if data[bl_target:bl_target+6] == libc_do_syscall_stub: + data[s_pos:s_pos+4] = repl + patched_count += 1 + print(f"Patched Thumb-2 {sc_name} bl __libc_do_syscall at 0x{s_pos:x} in {filename}") + break + break + return data, patched_count def patch_tar_ld(tar_path): diff --git a/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt b/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt index 8149a6d..a31b19c 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt @@ -1319,10 +1319,12 @@ object BootstrapManager { val dbusDir = File(root, "var/lib/dbus") dbusDir.mkdirs() val dbusMachineId = File(dbusDir, "machine-id") - if (!dbusMachineId.exists()) { - try { - android.system.Os.symlink("/etc/machine-id", dbusMachineId.absolutePath) - } catch (e: Exception) { + val isDbusSymlink = ElfLinkerPatcher.isSymlink(dbusMachineId) + if (!dbusMachineId.exists() || (!isDbusSymlink && dbusMachineId.length() < 32)) { + if (dbusMachineId.exists() || isDbusSymlink) { + ElfLinkerPatcher.deleteIfExists(dbusMachineId) + } + if (!ElfLinkerPatcher.createSymlink("/etc/machine-id", dbusMachineId)) { machineId.copyTo(dbusMachineId, overwrite = true) } } diff --git a/app/src/main/java/org/cortex/terminal/runtime/ElfLinkerPatcher.kt b/app/src/main/java/org/cortex/terminal/runtime/ElfLinkerPatcher.kt index ccd82a0..f87375d 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/ElfLinkerPatcher.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/ElfLinkerPatcher.kt @@ -396,6 +396,46 @@ object ElfLinkerPatcher { bytes[offset + 3] == pattern[3] } + private fun matchesHalfword(bytes: ByteArray, offset: Int, pattern: ByteArray): Boolean { + return bytes[offset] == pattern[0] && + bytes[offset + 1] == pattern[1] + } + + private fun decodeThumbBlTarget(bytes: ByteArray, offset: Int): Int? { + if (offset < 0 || offset + 4 > bytes.size) return null + val hw1 = readU16Le(bytes, offset) + val hw2 = readU16Le(bytes, offset + 2) + if ((hw1 and 0xf800) != 0xf000 || (hw2 and 0xd000) != 0xd000) return null + val s = (hw1 ushr 10) and 1 + val imm10 = hw1 and 0x3ff + val j1 = (hw2 ushr 13) and 1 + val j2 = (hw2 ushr 11) and 1 + val imm11 = hw2 and 0x7ff + val i1 = (j1 xor s) xor 1 + val i2 = (j2 xor s) xor 1 + var imm25 = (s shl 24) or (i1 shl 23) or (i2 shl 22) or (imm10 shl 12) or (imm11 shl 1) + if (s != 0) { + imm25 -= (1 shl 25) + } + return offset + 4 + imm25 + } + + private fun isThumbLibcDoSyscallStub( + bytes: ByteArray, + targetOffset: Int, + segStart: Int, + segEnd: Int + ): Boolean { + if (targetOffset < segStart || targetOffset + 6 > segEnd) return false + // push {r7, lr} (80 b5); mov r7, r12 (67 46); svc #0 (00 df) + return bytes[targetOffset] == 0x80.toByte() && + bytes[targetOffset + 1] == 0xb5.toByte() && + bytes[targetOffset + 2] == 0x67.toByte() && + bytes[targetOffset + 3] == 0x46.toByte() && + bytes[targetOffset + 4] == 0x00.toByte() && + bytes[targetOffset + 5] == 0xdf.toByte() + } + fun patchDynamicLinker(file: File) { if (!file.exists() || !file.isFile || isSymlink(file)) { return @@ -527,21 +567,39 @@ object ElfLinkerPatcher { val nopArm = byteArrayOf(0x00.toByte(), 0xf0.toByte(), 0x20.toByte(), 0xe3.toByte()) val mvnEnosysArm = byteArrayOf(0x25.toByte(), 0x00.toByte(), 0xe0.toByte(), 0xe3.toByte()) // mvn r0, #37 (-38) - // movw r7, #0x152 (syscall 338 set_robust_list) + // A32: movw r7, #0x152 (syscall 338 set_robust_list) val movR7Syscall338 = byteArrayOf(0x52.toByte(), 0x71.toByte(), 0x00.toByte(), 0xe3.toByte()) - // movw r7, #0x1b3 (syscall 435 clone3) + // A32: movw r7, #0x1b3 (syscall 435 clone3) val movR7Syscall435 = byteArrayOf(0xb3.toByte(), 0x71.toByte(), 0x00.toByte(), 0xe3.toByte()) - // movw r7, #0x18e (syscall 398 rseq) + // A32: movw r7, #0x18e (syscall 398 rseq) val movR7Syscall398 = byteArrayOf(0x8e.toByte(), 0x71.toByte(), 0x00.toByte(), 0xe3.toByte()) + // Thumb-2: svc #0 (00 df), 16-bit nop (00 bf) + val svcThumb = byteArrayOf(0x00.toByte(), 0xdf.toByte()) + val nopThumb16 = byteArrayOf(0x00.toByte(), 0xbf.toByte()) + // Thumb-2: mov.w r0, #0 (4f f0 00 00) and mvn.w r0, #37 (-38 = -ENOSYS: 6f f0 25 00) + val movR0ZeroThumb = byteArrayOf(0x4f.toByte(), 0xf0.toByte(), 0x00.toByte(), 0x00.toByte()) + val mvnEnosysThumb = byteArrayOf(0x6f.toByte(), 0xf0.toByte(), 0x25.toByte(), 0x00.toByte()) + + // Thumb-2: movw r7, #338 (40 f2 52 17), movw r7, #435 (40 f2 b3 17), movw r7, #398 (40 f2 8e 17) + val thumbMovwR7Syscall338 = byteArrayOf(0x40.toByte(), 0xf2.toByte(), 0x52.toByte(), 0x17.toByte()) + val thumbMovwR7Syscall435 = byteArrayOf(0x40.toByte(), 0xf2.toByte(), 0xb3.toByte(), 0x17.toByte()) + val thumbMovwR7Syscall398 = byteArrayOf(0x40.toByte(), 0xf2.toByte(), 0x8e.toByte(), 0x17.toByte()) + + // Thumb-2: mov.w r12, #338 (4f f4 a9 7c), mov.w r12, #398 (4f f4 c7 7c), movw r12, #435 (40 f2 b3 1c) + val thumbMovR12Syscall338 = byteArrayOf(0x4f.toByte(), 0xf4.toByte(), 0xa9.toByte(), 0x7c.toByte()) + val thumbMovR12Syscall398 = byteArrayOf(0x4f.toByte(), 0xf4.toByte(), 0xc7.toByte(), 0x7c.toByte()) + val thumbMovR12Syscall435 = byteArrayOf(0x40.toByte(), 0xf2.toByte(), 0xb3.toByte(), 0x1c.toByte()) + for ((segStartRaw, segEndRaw) in execSegments) { val segStart = maxOf(0, segStartRaw) val segEnd = minOf(bytes.size, segEndRaw) - val startAligned = (segStart + 3) and 3.inv() - val endAligned = segEnd and 3.inv() + val startAligned4 = (segStart + 3) and 3.inv() + val endAligned4 = segEnd and 3.inv() - var pos = startAligned - while (pos <= endAligned - 4) { + // 1. A32 4-byte aligned scan + var pos = startAligned4 + while (pos <= endAligned4 - 4) { val isSyscall338 = matchesWord(bytes, pos, movR7Syscall338) val isSyscall435 = matchesWord(bytes, pos, movR7Syscall435) val isSyscall398 = matchesWord(bytes, pos, movR7Syscall398) @@ -553,7 +611,7 @@ object ElfLinkerPatcher { isSyscall435 -> "435 clone3" else -> "398 rseq" } - val searchEnd = minOf(endAligned - 4, pos + 64) + val searchEnd = minOf(endAligned4 - 4, pos + 64) for (i in (pos + 4)..searchEnd step 4) { if (matchesWord(bytes, i, svcArm)) { replacement.copyInto(bytes, destinationOffset = i) @@ -565,6 +623,63 @@ object ElfLinkerPatcher { } pos += 4 } + + // 2. Thumb-2 2-byte aligned scan + val startAligned2 = (segStart + 1) and 1.inv() + val endAligned2 = segEnd and 1.inv() + var tPos = startAligned2 + while (tPos <= endAligned2 - 4) { + val isThumbR7_338 = matchesWord(bytes, tPos, thumbMovwR7Syscall338) + val isThumbR7_435 = matchesWord(bytes, tPos, thumbMovwR7Syscall435) + val isThumbR7_398 = matchesWord(bytes, tPos, thumbMovwR7Syscall398) + + if (isThumbR7_338 || isThumbR7_435 || isThumbR7_398) { + val scName = when { + isThumbR7_338 -> "338 set_robust_list" + isThumbR7_435 -> "435 clone3" + else -> "398 rseq" + } + val searchEnd = minOf(endAligned2 - 2, tPos + 64) + for (i in (tPos + 4)..searchEnd step 2) { + if (matchesHalfword(bytes, i, svcThumb)) { + if (isThumbR7_338) { + nopThumb16.copyInto(bytes, destinationOffset = i) + } else { + // movw r7, #imm (4B) + svc #0 (2B) -> mvn.w r0, #37 (4B) at movw, nop (2B) at svc + mvnEnosysThumb.copyInto(bytes, destinationOffset = tPos) + nopThumb16.copyInto(bytes, destinationOffset = i) + } + modified = true + Log.i(TAG, "Patched Thumb-2 syscall $scName svc #0 at 0x${Integer.toHexString(i)} in ${file.name}") + break + } + } + } else { + val isThumbR12_338 = matchesWord(bytes, tPos, thumbMovR12Syscall338) + val isThumbR12_398 = matchesWord(bytes, tPos, thumbMovR12Syscall398) + val isThumbR12_435 = matchesWord(bytes, tPos, thumbMovR12Syscall435) + + if (isThumbR12_338 || isThumbR12_398 || isThumbR12_435) { + val replacement = if (isThumbR12_338) movR0ZeroThumb else mvnEnosysThumb + val scName = when { + isThumbR12_338 -> "338 set_robust_list" + isThumbR12_435 -> "435 clone3" + else -> "398 rseq" + } + val searchEnd = minOf(endAligned2 - 4, tPos + 48) + for (i in (tPos + 4)..searchEnd step 2) { + val blTarget = decodeThumbBlTarget(bytes, i) + if (blTarget != null && isThumbLibcDoSyscallStub(bytes, blTarget, segStart, segEnd)) { + replacement.copyInto(bytes, destinationOffset = i) + modified = true + Log.i(TAG, "Patched Thumb-2 syscall $scName bl __libc_do_syscall at 0x${Integer.toHexString(i)} in ${file.name}") + break + } + } + } + } + tPos += 2 + } } } diff --git a/app/src/test/java/org/cortex/terminal/runtime/ElfLinkerPatcherTest.kt b/app/src/test/java/org/cortex/terminal/runtime/ElfLinkerPatcherTest.kt index 0b1db36..7cbdbc9 100644 --- a/app/src/test/java/org/cortex/terminal/runtime/ElfLinkerPatcherTest.kt +++ b/app/src/test/java/org/cortex/terminal/runtime/ElfLinkerPatcherTest.kt @@ -146,6 +146,147 @@ class ElfLinkerPatcherTest { assertArrayEquals(mvnR0_37, patched.sliceArray(0x8c until 0x90)) } + @Test + fun patchDynamicLinker_patchesArmThumb2InlineAndBlDoSyscallSequences() { + val totalSize = 512 + val buf = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN) + buf.put(0, 0x7f.toByte()) + buf.put(1, 'E'.code.toByte()) + buf.put(2, 'L'.code.toByte()) + buf.put(3, 'F'.code.toByte()) + buf.put(4, 1.toByte()) // ELFCLASS32 + buf.put(5, 1.toByte()) // ELFDATA2LSB + buf.put(6, 1.toByte()) // EV_CURRENT + buf.putShort(16, 3.toShort()) // ET_DYN + buf.putShort(18, 40.toShort()) // EM_ARM + buf.putInt(20, 1) // EV_CURRENT + buf.putInt(28, 52) // e_phoff + buf.putShort(40, 52.toShort()) // e_ehsize + buf.putShort(42, 32.toShort()) // e_phentsize + buf.putShort(44, 1.toShort()) // e_phnum + + // Place executable PT_LOAD segment at 0x80..0x180 + buf.putInt(52 + 0, 1) // PT_LOAD + buf.putInt(52 + 4, 0x80) // p_offset + buf.putInt(52 + 8, 0x80) // p_vaddr + buf.putInt(52 + 12, 0x80) // p_paddr + buf.putInt(52 + 16, 0x100) // p_filesz + buf.putInt(52 + 20, 0x100) // p_memsz + buf.putInt(52 + 24, 0x5) // PF_R | PF_X + buf.putInt(52 + 28, 0x1000) // p_align + + val arr = buf.array() + + // 1. At odd halfword offset 0x82: Thumb-2 movw r7, #338 (40 f2 52 17) + svc #0 (00 df) + byteArrayOf(0x40, 0xf2.toByte(), 0x52, 0x17, 0x00, 0xdf.toByte()).copyInto(arr, 0x82) + + // 2. At offset 0x90: Thumb-2 movw r7, #435 (40 f2 b3 17) + svc #0 (00 df) (exact Ubuntu 24.04 armhf clone3 sequence) + byteArrayOf(0x40, 0xf2.toByte(), 0xb3.toByte(), 0x17, 0x00, 0xdf.toByte()).copyInto(arr, 0x90) + + // 3. At offset 0xa0: Thumb-2 movw r7, #398 (40 f2 8e 17) + svc #0 (00 df) + byteArrayOf(0x40, 0xf2.toByte(), 0x8e.toByte(), 0x17, 0x00, 0xdf.toByte()).copyInto(arr, 0xa0) + + // 4. At offset 0x100: glibc __libc_do_syscall Thumb stub: push {r7, lr}; mov r7, r12; svc #0; pop {r7, pc} + val libcDoSyscallStub = byteArrayOf( + 0x80.toByte(), 0xb5.toByte(), + 0x67, 0x46, + 0x00, 0xdf.toByte(), + 0x80.toByte(), 0xbd.toByte() + ) + libcDoSyscallStub.copyInto(arr, 0x100) + + // Helper to encode 32-bit Thumb BL from `fromOffset` to `targetOffset` + fun encodeThumbBl(fromOffset: Int, targetOffset: Int): ByteArray { + val rel = targetOffset - (fromOffset + 4) + val s = (rel ushr 24) and 1 + val i1 = (rel ushr 23) and 1 + val i2 = (rel ushr 22) and 1 + val j1 = (i1 xor 1) xor s + val j2 = (i2 xor 1) xor s + val imm10 = (rel ushr 12) and 0x3ff + val imm11 = (rel ushr 1) and 0x7ff + val hw1 = 0xf000 or (s shl 10) or imm10 + val hw2 = 0xd000 or (j1 shl 13) or (j2 shl 11) or imm11 + return byteArrayOf( + (hw1 and 0xff).toByte(), + ((hw1 ushr 8) and 0xff).toByte(), + (hw2 and 0xff).toByte(), + ((hw2 ushr 8) and 0xff).toByte() + ) + } + + // 5. At offset 0xb0: mov.w r12, #338 (4f f4 a9 7c) followed at 0xb4 by BL 0x100 (__libc_do_syscall) + byteArrayOf(0x4f, 0xf4.toByte(), 0xa9.toByte(), 0x7c).copyInto(arr, 0xb0) + encodeThumbBl(0xb4, 0x100).copyInto(arr, 0xb4) + + // 6. At offset 0xc0: mov.w r12, #398 (4f f4 c7 7c) followed at 0xc4 by BL 0x100 (__libc_do_syscall) + byteArrayOf(0x4f, 0xf4.toByte(), 0xc7.toByte(), 0x7c).copyInto(arr, 0xc0) + encodeThumbBl(0xc4, 0x100).copyInto(arr, 0xc4) + + // 7. At offset 0xd0: mov.w r12, #338 followed by BL to non-stub function at 0x120 (must NOT be patched) + byteArrayOf(0x00, 0xbf.toByte(), 0x70, 0x47).copyInto(arr, 0x120) // nop; bx lr + byteArrayOf(0x4f, 0xf4.toByte(), 0xa9.toByte(), 0x7c).copyInto(arr, 0xd0) + val blToNonStub = encodeThumbBl(0xd4, 0x120) + blToNonStub.copyInto(arr, 0xd4) + + val file = File(tempFolder.root, "libc.so.6") + file.writeBytes(arr) + + ElfLinkerPatcher.patchDynamicLinker(file) + + val patched = file.readBytes() + + // 1. set_robust_list (338) inline: movw r7, #338 preserved, svc #0 (00 df) -> nop (00 bf) + assertArrayEquals( + "Thumb-2 set_robust_list svc #0 must become Thumb nop (00 bf)", + byteArrayOf(0x40, 0xf2.toByte(), 0x52, 0x17, 0x00, 0xbf.toByte()), + patched.sliceArray(0x82 until 0x88) + ) + + // 2. clone3 (435) inline: movw r7, #435 + svc #0 (6 bytes) -> mvn.w r0, #37; nop (6f f0 25 00 00 bf) + val thumbEnosys6 = byteArrayOf(0x6f, 0xf0.toByte(), 0x25, 0x00, 0x00, 0xbf.toByte()) + assertArrayEquals( + "Thumb-2 clone3 movw+svc must become mvn.w r0, #37; nop", + thumbEnosys6, + patched.sliceArray(0x90 until 0x96) + ) + + // 3. rseq (398) inline: movw r7, #398 + svc #0 (6 bytes) -> mvn.w r0, #37; nop + assertArrayEquals( + "Thumb-2 rseq movw+svc must become mvn.w r0, #37; nop", + thumbEnosys6, + patched.sliceArray(0xa0 until 0xa6) + ) + + // 4. set_robust_list (338) via BL __libc_do_syscall -> BL replaced with mov.w r0, #0 (4f f0 00 00) + assertArrayEquals( + "Thumb-2 BL __libc_do_syscall for set_robust_list must become mov.w r0, #0", + byteArrayOf(0x4f, 0xf0.toByte(), 0x00, 0x00), + patched.sliceArray(0xb4 until 0xb8) + ) + + // 5. rseq (398) via BL __libc_do_syscall -> BL replaced with mvn.w r0, #37 (6f f0 25 00) + assertArrayEquals( + "Thumb-2 BL __libc_do_syscall for rseq must become mvn.w r0, #37", + byteArrayOf(0x6f, 0xf0.toByte(), 0x25, 0x00), + patched.sliceArray(0xc4 until 0xc8) + ) + + // 6. Shared __libc_do_syscall stub itself at 0x100 must NOT be corrupted (used by other syscalls!) + assertArrayEquals( + "Shared __libc_do_syscall stub must remain intact", + libcDoSyscallStub, + patched.sliceArray(0x100 until 0x108) + ) + + // 7. BL to non-stub function must remain untouched + assertArrayEquals( + "BL to non-stub target must remain untouched", + blToNonStub, + patched.sliceArray(0xd4 until 0xd8) + ) + } + @Test fun patchDynamicLinker_doesNotModifyNonExecutablePtLoadSegment() { val movX8_435 = byteArrayOf(0x68, 0x36, 0x80.toByte(), 0xd2.toByte()) diff --git a/scripts/setup-extra-rootfs.sh b/scripts/setup-extra-rootfs.sh index 3847366..0c7da24 100755 --- a/scripts/setup-extra-rootfs.sh +++ b/scripts/setup-extra-rootfs.sh @@ -532,8 +532,7 @@ chmod 0644 extra-rootfs/etc/profile.d/00-env.sh mkdir -p extra-rootfs/etc extra-rootfs/var/lib/dbus : > extra-rootfs/etc/machine-id chmod 0644 extra-rootfs/etc/machine-id -: > extra-rootfs/var/lib/dbus/machine-id -chmod 0644 extra-rootfs/var/lib/dbus/machine-id +ln -sf /etc/machine-id extra-rootfs/var/lib/dbus/machine-id rm -rf extra-rootfs/bin extra-rootfs/sbin 2>/dev/null || true MAINT_TOOLS="systemd-machine-id-setup systemd-sysusers systemd-tmpfiles mandb update-mime-database update-desktop-database install-info install-sgmlcatalog" From c613be6e71b5e2271198fa62dc74ba1959a7f908 Mon Sep 17 00:00:00 2001 From: woffluon Date: Wed, 7 Oct 2026 00:20:18 +0300 Subject: [PATCH 09/11] fix(native): propagate NSS errors, broaden SIGSYS range, and drop close_range syscall - Propagate real libc errors (such as ERANGE, EIO) in getgrgid_r, getgrnam_r, getpwuid_r, and getpwnam_r without masking as fake records or 0 - Handle Linux 5.1+ syscall range 424..511 in cortex_sigsys_handler returning -ENOSYS while preserving SIG_DFL rethrow for unexpected traps - Remove raw syscall(__NR_close_range) in cortex-pty.c to avoid Android seccomp SIGSYS terminations in child process - Add host regression tests for NSS _r error codes, SIGSYS syscall ranges, and close_all_inherited_fds --- app/src/main/cpp/cortex-hook.c | 111 +++++++++++++++++------------- app/src/main/cpp/cortex-pty.c | 7 -- tests/native/test_cortex_native.c | 64 +++++++++++++++++ 3 files changed, 129 insertions(+), 53 deletions(-) diff --git a/app/src/main/cpp/cortex-hook.c b/app/src/main/cpp/cortex-hook.c index 95fe96f..67b2593 100644 --- a/app/src/main/cpp/cortex-hook.c +++ b/app/src/main/cpp/cortex-hook.c @@ -31,37 +31,10 @@ #include #include -#ifndef CORTEX_HOST_TEST - -#ifndef SYS_SECCOMP -#define SYS_SECCOMP 1 -#endif - -static int (*get_real_sigaction(void))(int, const struct sigaction *, struct sigaction *); - -// Intercept SECCOMP blocked syscalls (SIGSYS), resolve sandbox/landlock gracefully, and advance PC -static void cortex_sigsys_handler(int sig, siginfo_t *info, void *ctx) { - if (!ctx || !info) return; - if (info->si_code != SYS_SECCOMP) return; - ucontext_t *uctx = (ucontext_t *)ctx; - - int sys_nr = info->si_syscall; - long ret_val = -ENOSYS; - int handled = 0; - +static int is_handled_sigsys_syscall(int sys_nr) { // Unified Linux 5.1+ syscall numbers across architectures - if (sys_nr == 424 /* __NR_pidfd_send_signal */ || - sys_nr == 425 /* __NR_io_uring_setup */ || - sys_nr == 426 /* __NR_io_uring_enter */ || - sys_nr == 427 /* __NR_io_uring_register */ || - sys_nr == 434 /* __NR_pidfd_open */ || - sys_nr == 435 /* __NR_clone3 */ || - sys_nr == 438 /* __NR_pidfd_getfd */ || - sys_nr == 444 /* __NR_landlock_create_ruleset */ || - sys_nr == 445 /* __NR_landlock_add_rule */ || - sys_nr == 446 /* __NR_landlock_restrict_self */) { - ret_val = -ENOSYS; - handled = 1; + if (sys_nr >= 424 && sys_nr < 512) { + return 1; } // Architecture-specific syscall numbers guarded per target ABI @@ -73,8 +46,7 @@ static void cortex_sigsys_handler(int sig, siginfo_t *info, void *ctx) { sys_nr == 268 /* __NR_setns */ || sys_nr == 277 /* __NR_seccomp */ || sys_nr == 293 /* __NR_rseq */) { - ret_val = -ENOSYS; - handled = 1; + return 1; } #elif defined(__arm__) if (sys_nr == 309 /* __NR_add_key */ || @@ -84,8 +56,7 @@ static void cortex_sigsys_handler(int sig, siginfo_t *info, void *ctx) { sys_nr == 375 /* __NR_setns */ || sys_nr == 383 /* __NR_seccomp */ || sys_nr == 398 /* __NR_rseq */) { - ret_val = -ENOSYS; - handled = 1; + return 1; } #elif defined(__x86_64__) if (sys_nr == 248 /* __NR_add_key */ || @@ -95,8 +66,7 @@ static void cortex_sigsys_handler(int sig, siginfo_t *info, void *ctx) { sys_nr == 308 /* __NR_setns */ || sys_nr == 317 /* __NR_seccomp */ || sys_nr == 334 /* __NR_rseq */) { - ret_val = -ENOSYS; - handled = 1; + return 1; } #elif defined(__i386__) if (sys_nr == 286 /* __NR_add_key */ || @@ -106,10 +76,29 @@ static void cortex_sigsys_handler(int sig, siginfo_t *info, void *ctx) { sys_nr == 346 /* __NR_setns */ || sys_nr == 354 /* __NR_seccomp */ || sys_nr == 386 /* __NR_rseq */) { - ret_val = -ENOSYS; - handled = 1; + return 1; } #endif + return 0; +} + +#ifndef CORTEX_HOST_TEST + +#ifndef SYS_SECCOMP +#define SYS_SECCOMP 1 +#endif + +static int (*get_real_sigaction(void))(int, const struct sigaction *, struct sigaction *); + +// Intercept SECCOMP blocked syscalls (SIGSYS), resolve sandbox/landlock gracefully, and advance PC +static void cortex_sigsys_handler(int sig, siginfo_t *info, void *ctx) { + if (!ctx || !info) return; + if (info->si_code != SYS_SECCOMP) return; + ucontext_t *uctx = (ucontext_t *)ctx; + + int sys_nr = info->si_syscall; + long ret_val = -ENOSYS; + int handled = is_handled_sigsys_syscall(sys_nr); if (!handled) { int (*real_sig)(int, const struct sigaction *, struct sigaction *) = get_real_sigaction(); @@ -1873,6 +1862,8 @@ static gid_t real_gid(void) { #ifdef CORTEX_HOST_TEST #define CORTEX_NSS_SYM(name) hook_##name +static int g_test_orig_nss_r_ret = 0; +static int g_test_orig_nss_r_found = 0; #else #define CORTEX_NSS_SYM(name) name #endif @@ -1923,9 +1914,16 @@ int CORTEX_NSS_SYM(getgrgid_r)(gid_t gid, struct group *grp, char *buf, size_t b #ifndef CORTEX_HOST_TEST static int (*orig_getgrgid_r)(gid_t, struct group *, char *, size_t, struct group **) = NULL; if (!orig_getgrgid_r) orig_getgrgid_r = (int (*)(gid_t, struct group *, char *, size_t, struct group **))dlsym(RTLD_NEXT, "getgrgid_r"); - int ret = orig_getgrgid_r ? orig_getgrgid_r(gid, grp, buf, buflen, result) : -1; - if (ret == 0 && result && *result != NULL) return 0; + int ret = orig_getgrgid_r ? orig_getgrgid_r(gid, grp, buf, buflen, result) : ENOENT; +#else + int ret = g_test_orig_nss_r_ret; + if (result) *result = (ret == 0 && g_test_orig_nss_r_found) ? grp : NULL; #endif + if (ret == 0 && result && *result != NULL) return 0; + if (ret != 0 && ret != ENOENT) { + if (result) *result = NULL; + return ret; + } if (gid != 0 && gid != real_gid()) { if (result) *result = NULL; @@ -1950,9 +1948,16 @@ int CORTEX_NSS_SYM(getgrnam_r)(const char *name, struct group *grp, char *buf, s #ifndef CORTEX_HOST_TEST static int (*orig_getgrnam_r)(const char *, struct group *, char *, size_t, struct group **) = NULL; if (!orig_getgrnam_r) orig_getgrnam_r = (int (*)(const char *, struct group *, char *, size_t, struct group **))dlsym(RTLD_NEXT, "getgrnam_r"); - int ret = orig_getgrnam_r ? orig_getgrnam_r(name, grp, buf, buflen, result) : -1; - if (ret == 0 && result && *result != NULL) return 0; + int ret = orig_getgrnam_r ? orig_getgrnam_r(name, grp, buf, buflen, result) : ENOENT; +#else + int ret = g_test_orig_nss_r_ret; + if (result) *result = (ret == 0 && g_test_orig_nss_r_found) ? grp : NULL; #endif + if (ret == 0 && result && *result != NULL) return 0; + if (ret != 0 && ret != ENOENT) { + if (result) *result = NULL; + return ret; + } if (!name || strcmp(name, "root") != 0) { if (result) *result = NULL; @@ -2023,9 +2028,16 @@ int CORTEX_NSS_SYM(getpwuid_r)(uid_t uid, struct passwd *pwd, char *buf, size_t #ifndef CORTEX_HOST_TEST static int (*orig_getpwuid_r)(uid_t, struct passwd *, char *, size_t, struct passwd **) = NULL; if (!orig_getpwuid_r) orig_getpwuid_r = (int (*)(uid_t, struct passwd *, char *, size_t, struct passwd **))dlsym(RTLD_NEXT, "getpwuid_r"); - int ret = orig_getpwuid_r ? orig_getpwuid_r(uid, pwd, buf, buflen, result) : -1; - if (ret == 0 && result && *result != NULL) return 0; + int ret = orig_getpwuid_r ? orig_getpwuid_r(uid, pwd, buf, buflen, result) : ENOENT; +#else + int ret = g_test_orig_nss_r_ret; + if (result) *result = (ret == 0 && g_test_orig_nss_r_found) ? pwd : NULL; #endif + if (ret == 0 && result && *result != NULL) return 0; + if (ret != 0 && ret != ENOENT) { + if (result) *result = NULL; + return ret; + } if (uid != 0 && uid != real_uid()) { if (result) *result = NULL; @@ -2052,9 +2064,16 @@ int CORTEX_NSS_SYM(getpwnam_r)(const char *name, struct passwd *pwd, char *buf, #ifndef CORTEX_HOST_TEST static int (*orig_getpwnam_r)(const char *, struct passwd *, char *, size_t, struct passwd **) = NULL; if (!orig_getpwnam_r) orig_getpwnam_r = (int (*)(const char *, struct passwd *, char *, size_t, struct passwd **))dlsym(RTLD_NEXT, "getpwnam_r"); - int ret = orig_getpwnam_r ? orig_getpwnam_r(name, pwd, buf, buflen, result) : -1; - if (ret == 0 && result && *result != NULL) return 0; + int ret = orig_getpwnam_r ? orig_getpwnam_r(name, pwd, buf, buflen, result) : ENOENT; +#else + int ret = g_test_orig_nss_r_ret; + if (result) *result = (ret == 0 && g_test_orig_nss_r_found) ? pwd : NULL; #endif + if (ret == 0 && result && *result != NULL) return 0; + if (ret != 0 && ret != ENOENT) { + if (result) *result = NULL; + return ret; + } if (!name || strcmp(name, "root") != 0) { if (result) *result = NULL; diff --git a/app/src/main/cpp/cortex-pty.c b/app/src/main/cpp/cortex-pty.c index 7a23477..d320030 100644 --- a/app/src/main/cpp/cortex-pty.c +++ b/app/src/main/cpp/cortex-pty.c @@ -27,10 +27,6 @@ #include #include -#ifndef __NR_close_range -#define __NR_close_range 436 -#endif - static char **clean_env_for_system(char *const envp[], int envCount) { char **new_env = malloc(sizeof(char *) * (envCount + 1)); if (!new_env) return (char **)envp; @@ -228,9 +224,6 @@ static int has_pt_interp(const char *path) { } static void close_all_inherited_fds(int max_fd) { - if (syscall(__NR_close_range, 3U, ~0U, 0) >= 0) { - return; - } if (max_fd < 3) max_fd = 1024; for (int fd = 3; fd < max_fd; fd++) { close(fd); diff --git a/tests/native/test_cortex_native.c b/tests/native/test_cortex_native.c index 7d1494b..3fa6955 100644 --- a/tests/native/test_cortex_native.c +++ b/tests/native/test_cortex_native.c @@ -455,6 +455,69 @@ static void test_nss_fallback_semantics(void) { "getgrnam('messagebus') must return NULL when absent from /etc/group"); ASSERT_TRUE(hook_getgrgid(100) == NULL, "getgrgid(100) must return NULL when absent from /etc/group"); + + /* 7b. Real errors (ERANGE, EIO) from underlying libc *_r must propagate and NOT be masked by synthetic root or 0 */ + struct group grp_buf; + struct group *gr_res = &grp_buf; + g_test_orig_nss_r_ret = ERANGE; + g_test_orig_nss_r_found = 0; + + pw_res = &pwd_buf; + ASSERT_EQ(hook_getpwnam_r("root", &pwd_buf, str_buf, sizeof(str_buf), &pw_res), ERANGE, + "getpwnam_r('root') must propagate ERANGE from orig_getpwnam_r instead of returning synthetic root"); + ASSERT_TRUE(pw_res == NULL, "getpwnam_r('root') *result must be NULL on ERANGE"); + + pw_res = &pwd_buf; + ASSERT_EQ(hook_getpwnam_r("messagebus", &pwd_buf, str_buf, sizeof(str_buf), &pw_res), ERANGE, + "getpwnam_r('messagebus') must propagate ERANGE instead of masking as not-found 0"); + ASSERT_TRUE(pw_res == NULL, "getpwnam_r('messagebus') *result must be NULL on ERANGE"); + + pw_res = &pwd_buf; + ASSERT_EQ(hook_getpwuid_r(0, &pwd_buf, str_buf, sizeof(str_buf), &pw_res), ERANGE, + "getpwuid_r(0) must propagate ERANGE from orig_getpwuid_r"); + ASSERT_TRUE(pw_res == NULL, "getpwuid_r(0) *result must be NULL on ERANGE"); + + gr_res = &grp_buf; + ASSERT_EQ(hook_getgrnam_r("root", &grp_buf, str_buf, sizeof(str_buf), &gr_res), ERANGE, + "getgrnam_r('root') must propagate ERANGE from orig_getgrnam_r"); + ASSERT_TRUE(gr_res == NULL, "getgrnam_r('root') *result must be NULL on ERANGE"); + + gr_res = &grp_buf; + ASSERT_EQ(hook_getgrgid_r(0, &grp_buf, str_buf, sizeof(str_buf), &gr_res), ERANGE, + "getgrgid_r(0) must propagate ERANGE from orig_getgrgid_r"); + ASSERT_TRUE(gr_res == NULL, "getgrgid_r(0) *result must be NULL on ERANGE"); + + g_test_orig_nss_r_ret = EIO; + pw_res = &pwd_buf; + ASSERT_EQ(hook_getpwnam_r("root", &pwd_buf, str_buf, sizeof(str_buf), &pw_res), EIO, + "getpwnam_r('root') must propagate EIO from orig_getpwnam_r"); + ASSERT_TRUE(pw_res == NULL, "getpwnam_r('root') *result must be NULL on EIO"); + + /* Restore ENOENT (not-found) and verify root fallback still works */ + g_test_orig_nss_r_ret = ENOENT; + ASSERT_EQ(hook_getpwnam_r("root", &pwd_buf, str_buf, sizeof(str_buf), &pw_res), 0, + "getpwnam_r('root') must fall back to synthetic root on ENOENT"); + ASSERT_TRUE(pw_res == &pwd_buf, "getpwnam_r('root') *result must point to pwd_buf on ENOENT"); + g_test_orig_nss_r_ret = 0; +} + +/* 8. Test SIGSYS Linux 5.1+ range (424..511) and close_all_inherited_fds */ +static void test_sigsys_and_close_fds(void) { + ASSERT_EQ(is_handled_sigsys_syscall(424), 1, "pidfd_send_signal (424) must be handled"); + ASSERT_EQ(is_handled_sigsys_syscall(435), 1, "clone3 (435) must be handled"); + ASSERT_EQ(is_handled_sigsys_syscall(439), 1, "faccessat2 (439) must be handled"); + ASSERT_EQ(is_handled_sigsys_syscall(449), 1, "futex_waitv (449) must be handled"); + ASSERT_EQ(is_handled_sigsys_syscall(462), 1, "mseal (462) must be handled"); + ASSERT_EQ(is_handled_sigsys_syscall(511), 1, "upper bound 511 must be handled"); + ASSERT_EQ(is_handled_sigsys_syscall(512), 0, "512 must not be handled"); + ASSERT_EQ(is_handled_sigsys_syscall(423), 0, "423 must not be handled"); + ASSERT_EQ(is_handled_sigsys_syscall(1), 0, "syscall 1 must not be handled"); + + int dup_fd = dup(2); + ASSERT_TRUE(dup_fd >= 3, "dup(2) must return fd >= 3"); + close_all_inherited_fds(dup_fd + 1); + ASSERT_EQ(fcntl(dup_fd, F_GETFD), -1, "dup_fd must be closed by close_all_inherited_fds"); + ASSERT_TRUE(fcntl(2, F_GETFD) != -1, "stderr (fd 2) must remain open"); } int main(void) { @@ -465,6 +528,7 @@ int main(void) { test_check_elf_dynamic(); test_find_dynamic_linker(); test_nss_fallback_semantics(); + test_sigsys_and_close_fds(); if (g_tests_failed > 0) { fprintf(stderr, "FAILED: %d/%d assertions failed\n", g_tests_failed, g_tests_run); From a19b154788795c4fec825acaae67ab256ea25b0e Mon Sep 17 00:00:00 2001 From: woffluon Date: Wed, 7 Oct 2026 00:20:26 +0300 Subject: [PATCH 10/11] fix(runtime): guard root wrappers, harden server concurrency, and sequence update cleanup - Guard tsu, sudo, and root wrapper creation on usr/local/bin/su existing, cleaning up stale wrappers when su is absent - Equalize UrlOpenerServer worker pool core/max to 4 with allowCoreThreadTimeOut(true) and enforce acceptThread ownership across start/stop/bind failures - Sequence UpdateManager download lock release after failed or cancelled APK cleanup completes - Add unit tests for su wrapper lifecycle, UrlOpenerServer concurrent client handling and bind retry, and UpdateManager cleanup ordering --- .../terminal/runtime/ShellScriptsInstaller.kt | 48 +++++++++------ .../terminal/runtime/UrlOpenerServer.kt | 16 +++-- .../cortex/terminal/update/UpdateManager.kt | 24 ++++++-- .../runtime/ShellScriptsInstallerTest.kt | 61 ++++++++++++++++--- .../terminal/runtime/UrlOpenerServerTest.kt | 53 ++++++++++++++-- .../terminal/update/UpdateManagerTest.kt | 38 ++++++++++++ 6 files changed, 197 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/org/cortex/terminal/runtime/ShellScriptsInstaller.kt b/app/src/main/java/org/cortex/terminal/runtime/ShellScriptsInstaller.kt index c35300a..73bbd2e 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/ShellScriptsInstaller.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/ShellScriptsInstaller.kt @@ -198,9 +198,9 @@ object ShellScriptsInstaller { val localBin = File(root, "usr/local/bin") if (!localBin.exists()) localBin.mkdirs() + val suFile = File(localBin, "su") try { val suScript = loadAssetScript(context, "su.sh", "#!/bin/bash\n") - val suFile = File(localBin, "su") suFile.writeText(suScript) suFile.setReadable(true, true) suFile.setWritable(true, true) @@ -210,28 +210,36 @@ object ShellScriptsInstaller { Log.e(TAG, "Skipping su script install: ${e.message}") } - val tsuFallback = "#!/bin/sh\nexec /usr/local/bin/su \"\$@\"\n" - val tsuScript = loadAssetScript(context, "tsu.sh", tsuFallback) val tsuFile = File(localBin, "tsu") - tsuFile.writeText(tsuScript) - tsuFile.setReadable(true, true) - tsuFile.setWritable(true, true) - tsuFile.setExecutable(true, true) - try { Os.chmod(tsuFile.absolutePath, 448) } catch (_: Exception) {} - val sudoFile = File(localBin, "sudo") - sudoFile.writeText(tsuScript) - sudoFile.setReadable(true, true) - sudoFile.setWritable(true, true) - sudoFile.setExecutable(true, true) - try { Os.chmod(sudoFile.absolutePath, 448) } catch (_: Exception) {} - val rootFile = File(localBin, "root") - rootFile.writeText(tsuScript) - rootFile.setReadable(true, true) - rootFile.setWritable(true, true) - rootFile.setExecutable(true, true) - try { Os.chmod(rootFile.absolutePath, 448) } catch (_: Exception) {} + if (suFile.exists()) { + val tsuFallback = "#!/bin/sh\nexec /usr/local/bin/su \"\$@\"\n" + val tsuScript = loadAssetScript(context, "tsu.sh", tsuFallback) + tsuFile.writeText(tsuScript) + tsuFile.setReadable(true, true) + tsuFile.setWritable(true, true) + tsuFile.setExecutable(true, true) + try { Os.chmod(tsuFile.absolutePath, 448) } catch (_: Exception) {} + + sudoFile.writeText(tsuScript) + sudoFile.setReadable(true, true) + sudoFile.setWritable(true, true) + sudoFile.setExecutable(true, true) + try { Os.chmod(sudoFile.absolutePath, 448) } catch (_: Exception) {} + + rootFile.writeText(tsuScript) + rootFile.setReadable(true, true) + rootFile.setWritable(true, true) + rootFile.setExecutable(true, true) + try { Os.chmod(rootFile.absolutePath, 448) } catch (_: Exception) {} + } else { + for (wrapper in listOf(tsuFile, sudoFile, rootFile)) { + if (wrapper.exists()) { + try { wrapper.delete() } catch (_: Exception) {} + } + } + } val nanoDir = File(root, "usr/share/nano") if (!nanoDir.exists()) nanoDir.mkdirs() diff --git a/app/src/main/java/org/cortex/terminal/runtime/UrlOpenerServer.kt b/app/src/main/java/org/cortex/terminal/runtime/UrlOpenerServer.kt index ed154f4..5dabf58 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/UrlOpenerServer.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/UrlOpenerServer.kt @@ -132,7 +132,7 @@ object UrlOpenerServer { getOrCreateToken(appContext) val pool = ThreadPoolExecutor( - 2, + 4, 4, 30L, TimeUnit.SECONDS, @@ -140,7 +140,9 @@ object UrlOpenerServer { ThreadFactory { r -> Thread(r, "Cortex-UrlOpenerWorker").apply { isDaemon = true } } - ) + ).apply { + allowCoreThreadTimeOut(true) + } workerPool = pool isRunning = true @@ -151,7 +153,7 @@ object UrlOpenerServer { server.reuseAddress = true server.bind(InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), 50) synchronized(this@UrlOpenerServer) { - if (!isRunning) { + if (!isRunning || acceptThread !== Thread.currentThread()) { try { server.close() } catch (_: Exception) {} return@Thread } @@ -185,10 +187,15 @@ object UrlOpenerServer { try { server?.close() } catch (_: Exception) {} + pool.shutdownNow() synchronized(this@UrlOpenerServer) { - if (serverSocket === server) { + if (acceptThread === Thread.currentThread()) { serverSocket = null + acceptThread = null isRunning = false + activePort = -1 + workerPool?.shutdownNow() + workerPool = null } } } @@ -208,6 +215,7 @@ object UrlOpenerServer { @Synchronized fun stop() { isRunning = false + activePort = -1 try { serverSocket?.close() } catch (_: Exception) {} diff --git a/app/src/main/java/org/cortex/terminal/update/UpdateManager.kt b/app/src/main/java/org/cortex/terminal/update/UpdateManager.kt index b3ea082..6fe1f7f 100644 --- a/app/src/main/java/org/cortex/terminal/update/UpdateManager.kt +++ b/app/src/main/java/org/cortex/terminal/update/UpdateManager.kt @@ -531,15 +531,29 @@ object UpdateManager { try { outputStream?.close() } catch (_: Exception) {} try { inputStream?.close() } catch (_: Exception) {} try { activeConnection.get()?.disconnect() } catch (_: Exception) {} - isDownloading.set(false) - if (!downloadSucceeded || isCancelled.get()) { - try { targetApk.delete() } catch (_: Exception) {} - cleanUpdatesForce(activity) - } + finalizeDownloadAttempt(activity, targetApk, downloadSucceeded, isCancelled.get()) } } } + internal fun finalizeDownloadAttempt( + context: Context, + targetApk: File, + downloadSucceeded: Boolean, + cancelled: Boolean, + onBeforeUnlock: (() -> Unit)? = null + ) { + try { + if (!downloadSucceeded || cancelled) { + try { targetApk.delete() } catch (_: Exception) {} + cleanUpdatesForce(context) + } + onBeforeUnlock?.invoke() + } finally { + isDownloading.set(false) + } + } + internal fun isVersionUpgrade(installedVersionCode: Long, archiveVersionCode: Long): Boolean = archiveVersionCode > installedVersionCode diff --git a/app/src/test/java/org/cortex/terminal/runtime/ShellScriptsInstallerTest.kt b/app/src/test/java/org/cortex/terminal/runtime/ShellScriptsInstallerTest.kt index 917eb7c..39d1015 100644 --- a/app/src/test/java/org/cortex/terminal/runtime/ShellScriptsInstallerTest.kt +++ b/app/src/test/java/org/cortex/terminal/runtime/ShellScriptsInstallerTest.kt @@ -46,21 +46,64 @@ class ShellScriptsInstallerTest { } @Test - fun ensureRootTools_installsFunctionalTsuFallbackAndDoesNotWriteNoOpSuStub() { + fun ensureRootTools_doesNotInstallOrKeepBrokenWrappersWhenSuIsAbsent() { val root = tempFolder.newFolder("root") + val localBin = File(root, "usr/local/bin").apply { mkdirs() } + File(localBin, "tsu").writeText("stale") + File(localBin, "sudo").writeText("stale") + File(localBin, "root").writeText("stale") ShellScriptsInstaller.ensureRootTools(root, null) - val tsuFile = File(root, "usr/local/bin/tsu") - val sudoFile = File(root, "usr/local/bin/sudo") - val rootCmdFile = File(root, "usr/local/bin/root") - val suFile = File(root, "usr/local/bin/su") + val tsuFile = File(localBin, "tsu") + val sudoFile = File(localBin, "sudo") + val rootCmdFile = File(localBin, "root") + val suFile = File(localBin, "su") - assertTrue("tsu must be installed with functional fallback", tsuFile.exists()) + assertFalse("su must not be installed as a broken 1-line shebang stub when asset is absent", suFile.exists()) + assertFalse("tsu must not exist when su is absent", tsuFile.exists()) + assertFalse("sudo must not exist when su is absent", sudoFile.exists()) + assertFalse("root wrapper must not exist when su is absent", rootCmdFile.exists()) + } + + @Test + fun ensureRootTools_installsFunctionalWrappersWhenSuExists() { + val root = tempFolder.newFolder("root") + val localBin = File(root, "usr/local/bin").apply { mkdirs() } + val suFile = File(localBin, "su").apply { + writeText("#!/bin/sh\nexec /bin/bash \"\$@\"\n") + setExecutable(true, true) + } + + ShellScriptsInstaller.ensureRootTools(root, null) + + val tsuFile = File(localBin, "tsu") + val sudoFile = File(localBin, "sudo") + val rootCmdFile = File(localBin, "root") + + assertTrue("su must still exist", suFile.exists()) + assertTrue("tsu must be installed when su exists", tsuFile.exists()) assertTrue(tsuFile.readText().contains("exec /usr/local/bin/su \"\$@\"")) - assertTrue("sudo must be installed with functional fallback", sudoFile.exists()) + assertTrue("sudo must be installed when su exists", sudoFile.exists()) assertTrue(sudoFile.readText().contains("exec /usr/local/bin/su \"\$@\"")) - assertTrue("root wrapper must be installed with functional fallback", rootCmdFile.exists()) - assertFalse("su must not be installed as a broken 1-line shebang stub when asset is absent", suFile.exists()) + assertTrue("root wrapper must be installed when su exists", rootCmdFile.exists()) + assertTrue(rootCmdFile.readText().contains("exec /usr/local/bin/su \"\$@\"")) + } + + @Test + fun ensureMachineId_replacesEmptyDbusMachineIdFile() { + val root = tempFolder.newFolder("root") + val dbusDir = File(root, "var/lib/dbus").apply { mkdirs() } + val emptyDbusMachineId = File(dbusDir, "machine-id").apply { writeText("") } + + BootstrapManager.ensureMachineId(root) + + val etcMachineId = File(root, "etc/machine-id") + assertTrue("etc/machine-id must be created with >= 32 chars", etcMachineId.exists() && etcMachineId.length() >= 32L) + val isSymlink = ElfLinkerPatcher.isSymlink(emptyDbusMachineId) + assertTrue( + "var/lib/dbus/machine-id must be replaced with symlink to /etc/machine-id or populated with valid 32-char ID", + isSymlink || emptyDbusMachineId.length() >= 32L + ) } } diff --git a/app/src/test/java/org/cortex/terminal/runtime/UrlOpenerServerTest.kt b/app/src/test/java/org/cortex/terminal/runtime/UrlOpenerServerTest.kt index bd17e15..2b75fd2 100644 --- a/app/src/test/java/org/cortex/terminal/runtime/UrlOpenerServerTest.kt +++ b/app/src/test/java/org/cortex/terminal/runtime/UrlOpenerServerTest.kt @@ -117,17 +117,20 @@ class UrlOpenerServerTest { val stalledSockets = mutableListOf() try { - // 1. Open 2 concurrent stalled connections that send no data. - // Because workerPool scales up to 4 threads (and accept loop is on its own dedicated thread), - // a valid authenticated request must still be served immediately without waiting for timeout. - repeat(2) { + // 1. Open 3 concurrent stalled connections that send no data (exceeding old corePoolSize=2). + // With corePoolSize=4, maximumPoolSize=4 and allowCoreThreadTimeOut(true), the 4th worker + // serves a valid authenticated request immediately without waiting for clientSoTimeoutMs. + UrlOpenerServer.clientSoTimeoutMs = 2500 + repeat(3) { val s = Socket() s.connect(InetSocketAddress("127.0.0.1", port), 1000) stalledSockets.add(s) } + // Give the accept loop a moment to dispatch the 3 stalled connections to workers + Thread.sleep(50) Socket().use { client -> - client.soTimeout = 2000 + client.soTimeout = 600 client.connect(InetSocketAddress("127.0.0.1", port), 1000) client.getOutputStream().write("OPEN $token https://example.com/active\n".toByteArray(Charsets.UTF_8)) client.getOutputStream().flush() @@ -135,6 +138,12 @@ class UrlOpenerServerTest { assertEquals("OK", resp) } + UrlOpenerServer.clientSoTimeoutMs = 300 + stalledSockets.forEach { + try { it.close() } catch (_: Exception) {} + } + stalledSockets.clear() + // 2. Flood the server with 25 additional stalled connections (exceeding 4 max threads + 16 queue capacity). // Excess connections are rejected and closed immediately without stalling the accept thread. repeat(25) { @@ -199,4 +208,38 @@ class UrlOpenerServerTest { assertEquals("OK", resp) } } + + @Test + fun testStartRecoversCleanlyAfterBindFailureAndLifecycleRaces() { + val token = UrlOpenerServer.getOrCreateToken(fakeContext) + // Occupy an ephemeral port on 127.0.0.1 so UrlOpenerServer.start() fails to bind + java.net.ServerSocket().use { blocker -> + blocker.reuseAddress = true + blocker.bind(InetSocketAddress("127.0.0.1", 0)) + val occupiedPort = blocker.localPort + + UrlOpenerServer.start(fakeContext, port = occupiedPort) + // Wait briefly for the failed accept thread's finally block to clean up + Thread.sleep(50) + assertEquals("activePort must be -1 after bind failure", -1, UrlOpenerServer.activePort) + } + + // Subsequent start() must NOT be wedged by stale isRunning=true + UrlOpenerServer.start(fakeContext, port = 0) + val boundPort = UrlOpenerServer.activePort + assertTrue("Expected retry start() after bind failure to bind valid port, got $boundPort", boundPort > 0) + + // Calling start() again while already running must be a safe no-op keeping the same port + UrlOpenerServer.start(fakeContext, port = 0) + assertEquals(boundPort, UrlOpenerServer.activePort) + + Socket().use { client -> + client.soTimeout = 2000 + client.connect(InetSocketAddress("127.0.0.1", boundPort), 1000) + client.getOutputStream().write("OPEN $token https://example.com/after-bind-retry\n".toByteArray(Charsets.UTF_8)) + client.getOutputStream().flush() + val resp = client.getInputStream().bufferedReader(Charsets.UTF_8).readLine() + assertEquals("OK", resp) + } + } } diff --git a/app/src/test/java/org/cortex/terminal/update/UpdateManagerTest.kt b/app/src/test/java/org/cortex/terminal/update/UpdateManagerTest.kt index 28ac7e8..44b22f7 100644 --- a/app/src/test/java/org/cortex/terminal/update/UpdateManagerTest.kt +++ b/app/src/test/java/org/cortex/terminal/update/UpdateManagerTest.kt @@ -110,4 +110,42 @@ class UpdateManagerTest { tempCache.deleteRecursively() } } + + @Test + fun testFinalizeDownloadAttemptHoldsLockUntilCleanupCompletes() { + val tempCache = File(System.getProperty("java.io.tmpdir"), "cortex_test_finalize_${System.nanoTime()}") + val updatesDir = File(tempCache, "updates") + updatesDir.mkdirs() + val stagedApk = File(updatesDir, "cortex_update_failed.apk") + stagedApk.writeText("partial-apk") + + val testContext = object : ContextWrapper(null) { + override fun getCacheDir(): File = tempCache + } + + val field = UpdateManager::class.java.getDeclaredField("isDownloading") + field.isAccessible = true + val isDownloading = field.get(UpdateManager) as AtomicBoolean + + try { + isDownloading.set(true) + var lockHeldAfterCleanup = false + var dirDeletedBeforeUnlock = false + UpdateManager.finalizeDownloadAttempt( + context = testContext, + targetApk = stagedApk, + downloadSucceeded = false, + cancelled = false + ) { + lockHeldAfterCleanup = isDownloading.get() + dirDeletedBeforeUnlock = !updatesDir.exists() && !stagedApk.exists() + } + assertTrue("isDownloading must remain true until cleanup finishes", lockHeldAfterCleanup) + assertTrue("targetApk and updates directory must be deleted before isDownloading is released", dirDeletedBeforeUnlock) + assertFalse("isDownloading must be false after finalizeDownloadAttempt returns", isDownloading.get()) + } finally { + isDownloading.set(false) + tempCache.deleteRecursively() + } + } } From d3950a152e070aa5475fb3dea1942b07e007b9c9 Mon Sep 17 00:00:00 2001 From: woffluon Date: Wed, 7 Oct 2026 00:20:32 +0300 Subject: [PATCH 11/11] fix(shell): restrict service fork adoption and harden test execution - Record process launch start time ticks and exclude pre-existing PIDs during daemon fork adoption in service.sh start - Use compound command in test_shell_scripts.sh bystander background process to prevent bash exec optimization dropping cmdline arguments - Add regression test verifying service.sh start does not adopt pre-existing background process on daemon launch failure --- app/src/main/assets/scripts/service.sh | 24 +++++++++++++++++- tests/scripts/test_shell_scripts.sh | 35 +++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/app/src/main/assets/scripts/service.sh b/app/src/main/assets/scripts/service.sh index 7acae46..9f2ff87 100644 --- a/app/src/main/assets/scripts/service.sh +++ b/app/src/main/assets/scripts/service.sh @@ -149,6 +149,16 @@ find_service_pids() { done } +proc_start_ticks() { + local pid="$1" + local stat_line="" rest="" + [ -r "/proc/$pid/stat" ] || return 1 + IFS= read -r stat_line < "/proc/$pid/stat" 2>/dev/null || return 1 + rest="${stat_line##*) }" + set -- $rest + printf '%s\n' "${20:-}" +} + case "$ACTION" in start) if [ -f "$PIDFILE" ]; then @@ -161,11 +171,23 @@ case "$ACTION" in fi if [ -n "$DAEMON" ]; then echo "Starting $SERVICE..." + PRE_PIDS=" $(find_service_pids "$SERVICE" "$DAEMON" | tr '\n' ' ') " + LAUNCH_TICKS="$(proc_start_ticks "${BASHPID:-$$}" 2>/dev/null || true)" "$DAEMON" "$@" & DAEMON_PID=$! sleep 0.1 if ! is_service_process "$DAEMON_PID" "$SERVICE" "$DAEMON"; then - FORKED_PID="$(find_service_pids "$SERVICE" "$DAEMON" | head -n 1)" + FORKED_PID="" + for cand_pid in $(find_service_pids "$SERVICE" "$DAEMON"); do + case "$PRE_PIDS" in + *" $cand_pid "*) continue ;; + esac + cand_ticks="$(proc_start_ticks "$cand_pid" 2>/dev/null || true)" + if [ -z "$LAUNCH_TICKS" ] || [ -z "$cand_ticks" ] || [ "$cand_ticks" -ge "$LAUNCH_TICKS" ] 2>/dev/null; then + FORKED_PID="$cand_pid" + break + fi + done if [ -n "$FORKED_PID" ]; then DAEMON_PID="$FORKED_PID" else diff --git a/tests/scripts/test_shell_scripts.sh b/tests/scripts/test_shell_scripts.sh index 5fb8211..6d67cae 100644 --- a/tests/scripts/test_shell_scripts.sh +++ b/tests/scripts/test_shell_scripts.sh @@ -132,9 +132,19 @@ else fi # 2c. Substring process isolation (unrelated process with service name in args must not match or be killed) -bash -c 'sleep 30' _ " fake_argument_containing_mytestsvc " & +# Use a compound command ('sleep 30; :') so bash does not exec-optimize into sleep and drop the positional argument from /proc/$pid/cmdline. +bash -c 'sleep 30; :' _ " fake_argument_containing_mytestsvc " & BYSTANDER_PID=$! +if [ -r "/proc/$BYSTANDER_PID/cmdline" ]; then + BYSTANDER_CMDLINE="$(tr '\0' ' ' < "/proc/$BYSTANDER_PID/cmdline" 2>/dev/null || true)" + if [[ "$BYSTANDER_CMDLINE" == *"mytestsvc"* ]]; then + pass "bystander process retains 'mytestsvc' substring in /proc/\$pid/cmdline" + else + fail "bystander process cmdline did not retain 'mytestsvc' substring: '$BYSTANDER_CMDLINE'" + fi +fi + cat > "$SVC_ROOT/bin/mytestsvc" << 'EOF' #!/usr/bin/env bash sleep 30 @@ -171,6 +181,29 @@ fi kill "$BYSTANDER_PID" 2>/dev/null || true wait "$BYSTANDER_PID" 2>/dev/null || true +# 2e. Fork adoption ownership: start must NOT adopt a pre-existing instance when the new launch exits immediately +cat > "$SVC_ROOT/bin/forkguardsvc" << 'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = "--hold" ]; then + sleep 30 + exit 0 +fi +exit 1 +EOF +chmod +x "$SVC_ROOT/bin/forkguardsvc" + +"$SVC_ROOT/bin/forkguardsvc" --hold & +PRE_SVC_PID=$! +sleep 0.05 + +if bash "$SERVICE_SH" forkguardsvc start >/dev/null 2>&1 || [ -f "$SVC_ROOT/run/forkguardsvc.pid" ]; then + fail "service.sh start falsely adopted pre-existing process after new daemon exited immediately" +else + pass "service.sh start does not adopt pre-existing process when new launch fails" +fi +kill "$PRE_SVC_PID" 2>/dev/null || true +wait "$PRE_SVC_PID" 2>/dev/null || true + echo "=== 3. Testing su.sh ===" SU_SH="$ASSETS_SCRIPTS/su.sh"