JuiceSSH – 恢复我的专业功能
JuiceSSH – Give me my pro features back

原始链接: https://nproject.io/blog/juicessh-give-me-back-my-pro-features/

## JuiceSSH 激活问题及解决方法 JuiceSSH 是一款流行的 Android SSH 客户端,由于购买验证问题,许多用户在 2025 年 12 月后无法使用。 以前有效的购买不再被识别,应用程序现在需要新的、更高的付款。 用户报告支持无响应,引发了对潜在“退出诈骗”的担忧。 然而,存在一种解决方法,需要技术专长。 它涉及使用 `jadx`、`ApkTool` 和 `jarsigner`(来自 OpenJDK – 可通过 `choco install openjdk` 在 Windows 上安装)等工具反编译应用程序的代码。 该过程修改了三个特定的 `.smali` 文件以绕过购买验证:`User.smali`、`oi0.smali` 和 `pi0.smali`。 这些修改基本上会强制应用程序始终将用户识别为拥有有效许可证。 修改代码后,需要重新编译并对应用程序进行自签名。 虽然这可以恢复专业功能,但云同步和插件将不再起作用。 此解决方案提供了对应用程序功能的访问,但牺牲了这些功能。

## JuiceSSH 问题与潜在“跑路”事件 用户报告称,流行的Android SSH客户端JuiceSSH出现问题,开发者似乎已经放弃了该项目,尽管此前曾出售专业版功能。许多购买了专业版的用户现在发现他们的功能无法正常使用,并且尝试重新购买也失败了。开发者目前在微软和AWS担任管理职位,对支持请求不予回应。 人们担心这构成了一次“跑路”行为,因为该应用实际上已经损坏并且没有支持,近期购买的用户也无法获得退款(Google的退款窗口为120天)。虽然有人推测开发者可能只是负担过重,但缺乏沟通和持续的盈利行为引发了怀疑。 Termux(Android Linux终端模拟器)和Termius等替代方案正在被推荐。如果用户使用了JuiceSSH的云密钥存储,建议更换SSH密钥,因为他们的安全性可能受到威胁。这一情况凸显了依赖维护不良的应用的风险以及可能失去已购买功能的可能性。
相关文章

原文

JuiceSSH used to be the best SSH client available on Android until December 2025.

Since then, the purchase made in 2019 is not recognized anymore, and the price went up by 20$. Some users complain in review that after buying it again, the application doesn't get activated. Support is unresponsive, this looks like an exit scam.

Below is a way to make the application work again. This required jadx to understand smali, and will require you ApkTool and jarsigner, which is part of OpenJDK, and you that can install on Windows using choco install openjdk.

You'll also need a JuiceSSH apk, I downloaded one from PureAPK, but feel free to dump your own from your device using adb if you cannot find it. Make sure to verify the hash using virus total/sha256sum if downloading from internet, which should be d1ee811bcd82f25aea0bdc568896d82017ee174d9c4631c123a9d9173c748232 for the last version available, version 3.2.2.

Below are powershell version of the command lines, but you get the idea.

Decompile

The first step is to decompile the dex packed code from the apk.

& "C:\Program Files\OpenJDK\jdk-25\bin\java.exe" -jar d juicessh.apk

Modify smali

You then need to modify the smali of three files, which are detailed below.

smali/com/sonelli/juicessh/models/User.smali

In this file, we'll patch the purchase validation and signature validation, done by the public boolean H() function.

Here is the original version.

public boolean H() {
    try {
        String str = "";
        ArrayList arrayList = new ArrayList();
        for (Purchase purchase : this.purchases) {
            if (!arrayList.contains(purchase.order)) {
                str = str + purchase.product + purchase.state;
                arrayList.add(purchase.order);
            }
        }
        return vg0.b(this.signature, this.sessionIdentifier + this.name + this.email + str + this.disabled.toString());
    } catch (IllegalStateException e) {
        e.printStackTrace();
        return false;
    }
}

Which we'll simply change into

public boolean H() {
    return true;
}
# virtual methods
.method public H()Z
    .locals 1

    const/4 v0, 0x1
    return v0
.end method

smali/com/sonelli/oi0.smali

In this one, we'll patch the public static boolean d(Object obj) function, who calls the H() function we modified above, which now returns true, filters product matching JuiceSSH in purchases list, and check if it the purchase is valid. We'll simply make it return true in any case.

Here is the original version:

public static boolean d(Object obj) {
    if (!obj.getClass().getName().equals(User.class.getName())) {
        return false;
    }
    try {
        if (!((User) obj).H()) {
            return false;
        }
        ArrayList arrayList = new ArrayList();
        for (Purchase purchase : ((User) obj).purchases) {
            if (purchase.product.equals(a())) {
                arrayList.add(purchase);
            }
        }
        Collections.sort(arrayList, new a());
        if (arrayList.size() > 0) {
            if (((Purchase) arrayList.get(arrayList.size() - 1)).state.intValue() == 0) {
                return true;
            }
        }
        return false;
    } catch (NullPointerException e) {
        e.printStackTrace();
        return false;
    }
}

Here is the patched one:

public static boolean d(Object obj) {
    return obj.getClass().getName().equals(User.class.getName());
}
.method public static d(Ljava/lang/Object;)Z
    .locals 3

    # obj.getClass()
    invoke-virtual {p0}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
    move-result-object v0

    # obj.getClass().getName()
    invoke-virtual {v0}, Ljava/lang/Class;->getName()Ljava/lang/String;
    move-result-object v0

    # User.class
    const-class v1, Lcom/sonelli/juicessh/models/User;

    # User.class.getName()
    invoke-virtual {v1}, Ljava/lang/Class;->getName()Ljava/lang/String;
    move-result-object v1

    # compare strings
    invoke-virtual {v0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
    move-result v2

    if-nez v2, :cond_true

    const/4 v0, 0x0
    return v0

    :cond_true
    const/4 v0, 0x1
    return v0
.end method

smali/com/sonelli/pi0.smali

Finally, we'll patch the central part of the authentication, which is called each time a pro-feature is triggered to ensure user has valid license, the public static void j(Context context, p pVar) function.

Here is the original version:

public static void j(Context context, p pVar) {
    User user;
    User user2;
    String strS = User.s(context);
    if (strS == null) {
        pVar.a(context.getString(R$string.authentication_failure));
        return;
    }
    if (strS.equals("New User")) {
        pVar.a("New User");
        return;
    }
    User user3 = b;
    if (user3 != null && !user3.disabled.booleanValue()) {
        long jCurrentTimeMillis = System.currentTimeMillis() - b.modified;
        DateUtils.getRelativeTimeSpanString(System.currentTimeMillis() + (b.w() * 1000), System.currentTimeMillis(), 0L, 0);
        DateUtils.getRelativeTimeSpanString(System.currentTimeMillis() + (3600000 - jCurrentTimeMillis), System.currentTimeMillis(), 0L, 0);
        if (b.w() <= 0) {
            gj0.b("API", "Cached user's API session has expired - refreshing session...");
            e(context, null, b.sessionIdentifier, pVar);
            return;
        }
        pVar.b(b);
        if (jCurrentTimeMillis <= 3600000 || context == null || (user2 = b) == null) {
            return;
        }
        e(context, null, user2.sessionIdentifier, null);
        return;
    }
    User userA = User.A(context);
    if (userA == null || userA.disabled.booleanValue() || !userA.H()) {
        e(context, null, null, pVar);
        return;
    }
    b = userA;
    if (userA.w() <= 0) {
        e(context, null, b.sessionIdentifier, pVar);
        return;
    }
    pVar.b(b);
    if (context == null || (user = b) == null) {
        return;
    }
    e(context, null, user.sessionIdentifier, null);
}

pVar.b() is the success callback we'll call while e() is called in case of error. b is the globally stored user we'll have to set. To patch this, we'll simply craft a User with meaningless data, a session expire always in future, save the user in b, and call the success callback every time.

public static void j(Context context, p pVar) {
    User user = new User();
    user.email = "[email protected]";
    user.name = "hello";
    user.given_name = "hello";
    user.sessionExpires = System.currentTimeMillis() + (86400000 * 365);
    user.sessionIdentifier = "";
    b = user;
    pVar.b(user);
}
.method public static j(Landroid/content/Context;Lcom/sonelli/pi0$p;)V
    .locals 8

    # User u = new User();
    new-instance v0, Lcom/sonelli/juicessh/models/User;
    invoke-direct {v0}, Lcom/sonelli/juicessh/models/User;-><init>()V

    # u.email = "[email protected]";
    const-string v1, "[email protected]"
    iput-object v1, v0, Lcom/sonelli/juicessh/models/User;->email:Ljava/lang/String;

    # u.name = "hello";
    const-string v1, "hello"
    iput-object v1, v0, Lcom/sonelli/juicessh/models/User;->name:Ljava/lang/String;

    # u.given_name = "hello";
    iput-object v1, v0, Lcom/sonelli/juicessh/models/User;->given_name:Ljava/lang/String;

    # long now = System.currentTimeMillis();
    invoke-static {}, Ljava/lang/System;->currentTimeMillis()J
    move-result-wide v2

    # yearMillis = 86400000L * 365L
    const-wide/32 v4, 0x05265c00      # 86400000
    const-wide/16 v6, 0x016d          # 365
    mul-long/2addr v4, v6

    # u.sessionExpires = now + yearMillis;
    add-long/2addr v2, v4
    iput-wide v2, v0, Lcom/sonelli/juicessh/models/User;->sessionExpires:J

    # u.sessionIdentifier = ""
    const-string v1, ""
    iput-object v1, v0, Lcom/sonelli/juicessh/models/User;->sessionIdentifier:Ljava/lang/String;

    # pi0.b = u;
    sput-object v0, Lcom/sonelli/pi0;->b:Lcom/sonelli/juicessh/models/User;

    # pVar.b(b);
    invoke-virtual {p1, v0}, Lcom/sonelli/pi0$p;->b(Lcom/sonelli/juicessh/models/User;)V

    return-void

Recompile

& "C:\Program Files\OpenJDK\jdk-25\bin\java.exe" -jar .\apktool_2.12.1.jar juicessh

Sign the apk

# Create a keystore if needed to self sign the APK
keytool -genkey -v -keystore k.keystore -alias a -keyalg RSA -keysize 2048 -validity 50000

# Sign the APK
jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore k.keystore ./juicessh/dist/juicessh.apk a

Done

You can install this apk, ignore the security warning because it is self signed, and enjoy JuiceSSH with its pro features again.

I don't think the cloud sync will work anymore, but that's a minor inconvenience. The plugins don't work anymore too, which is really a joke.

联系我们 contact @ memedata.com