#include "Message.h" #include "IsolationManager.h" #include #include // for umask #include #include #include #include #include #include #include #include #include #include #include namespace { QByteArray computeFileHash(QFile &file) { QCryptographicHash hasher(QCryptographicHash::Md5); constexpr qint64 CHUNK_SIZE = 64 * 1024; while (!file.atEnd()) { const QByteArray chunk = file.read(CHUNK_SIZE); if (chunk.isEmpty()) break; hasher.addData(chunk); } return hasher.result(); }; bool copyIfDifferent(const QString &source, const QString &target) { QFile in(source); if (!in.open(QIODevice::ReadOnly)) return false; QFile out(target); if (out.open(QIODevice::ReadOnly)) { const QByteArray sourceHash = computeFileHash(in); assert(!sourceHash.isEmpty()); // crypto failure.. const QByteArray targetHash = computeFileHash(out); if (!sourceHash.isEmpty() && sourceHash == targetHash) return true; } in.close(); QFile::remove(target); bool ok = QFile::copy(source, target); if (ok) out.setPermissions(QFileDevice::ReadOwner); out.close(); return ok; } } class RulesError : public std::runtime_error { public: RulesError(const char *message, const QString &filename_ = QString()) : std::runtime_error(message), filename(filename_) { } const QString filename; }; IsolationManager::IsolationManager(int inputId, int outputId) : m_runner(inputId, outputId), m_listener(this), m_rulesDir("/etc/security/iso-rules/"), m_dbdir("1/db/") { setObjectName(QLatin1String("IsolationManager")); // For the DBus RPC m_basedir = QDir::currentPath() % "/"; umask(077); // All we create will only be readable by the owner. QDir basedir(m_basedir); // Qt bases this on $HOME, so lets make sure we are not being played here. if (!basedir.isAbsolute()) throw std::runtime_error("Config error: datadir has to be absolute"); QDirIterator jailChecker("."); while (jailChecker.hasNext()) { auto entry = jailChecker.next().mid(2); // snip off the "./" bool ok; int jid = entry.toInt(&ok); if (ok && jid >= m_nextJailId) m_nextJailId = jid + 1; } connect (&m_runner, SIGNAL(receivedMessage(QByteArray)), this, SLOT(receivedMessageFromRunner(QByteArray))); } QString IsolationManager::startApplicationRequest(AppEntry &dbEntry, const QStringList &arguments) { assert(dbEntry.appId > 1); /* * Each app gets its own subdir under m_basedir using its appid as name. */ QDir base(m_basedir); QString homedir = QString::number(dbEntry.appId); if (!base.mkpath(homedir)) return QString("Internal error: failed to create environment"); if (dbEntry.jailPassword.isEmpty() && QFile::exists(m_basedir % "/." % homedir % "/.encfs6.xml")) { auto *da = new DelayedApp(dbEntry, arguments, this); da->askPassword(); return QString(); } // then ask the priviledged task to take it from here. Message message(Message::MAX_SIZE); message.setJailId(dbEntry.appId); try { if (!dbEntry.jailPassword.isEmpty()) message.setJailPassword(dbEntry.jailPassword.toStdString()); QString exe = dbEntry.pathToExe; // resolve symlinks and actually find the real executable. for (int i = 0; i < 10; ++i) { // avoid endless loops QFileInfo info(exe); if (info.isSymbolicLink()) { exe = info.symLinkTarget(); } else { break; } } // The user should be able to start executables that are stored in the // homedir, except that the path to that is going to be different in // his little jail. Lets special case that... if (exe.startsWith(QDir::homePath())) { QString newPath = QDir::homePath(); const auto length = newPath.size(); exe = newPath + QString("/shared%1").arg(exe.mid(length)); } message.setPath(exe.toStdString()); for (const auto &s : arguments) { message.addArgument(s.toUtf8().constData()); } // VPN crap if (!dbEntry.vpnConf.isEmpty()) { const QString vpnHomeBase = expandVars(dbEntry, QString("$APPHOME/config/vpn")); QDir(vpnHomeBase).mkpath("."); const QString appHomeDir = vpnHomeBase + "/.vpn"; if (!copyIfDifferent(dbEntry.vpnConf, appHomeDir + ".ovpn")) return "vpn config file not found"; message.setVpnConfig(expandVars(dbEntry, QString("$HOME/config/vpn/.vpn")).toStdString()); if (!dbEntry.vpnAc.isEmpty()) { message.setHasVpnPassFile(true); QFileInfo orig(dbEntry.vpnAc); if (!orig.exists()) return "vpn access controls config file not found"; QFileInfo copy(appHomeDir + "ac"); if (!copy.exists() || orig.lastModified() > copy.lastModified()) { // we still call copyIfDifferent because lazyness. But as its made only // readable by root, when we get here, we will do the copy. if (!copyIfDifferent(dbEntry.vpnAc, appHomeDir + "ac")) return "vpn AC file can't be copied"; } } QBuffer script; script.open(QIODevice::WriteOnly); const auto netNsName = QString("iso-jail%1").arg(dbEntry.appId); script.write("#!/bin/sh\n" "# OpenVPN passes: $1 = dev, $2 = mtu, $4 = local IP, etc.\n\n"); script.write("myNetNsName=\""); script.write(netNsName.toLatin1()); script.write("\"\n"); script.write("case \"$script_type\" in\n" " up)\n" " if ip netns exec $myNetNsName ip link show $1 >/dev/null 2>&1\n" " then\n" // for when it comes up again after being down " ip netns exec $myNetNsName ip link up $1\n" " else\n" " dev=$1; mtu=$2\n" /* The script is called by openvpn on 'up', which seems to be very much not when it's up. * So in the script we create a new script that copies all the passed in parameters * but starts with a while loop checking if the device is up yet. Which may take some seconds. * Only when it's up do we then we run our networking code to move to the namespace, and * configure the new interface. */ " echo \"i=0; while [ \\$i -lt 10 ]; do ip link show $dev >/dev/null 2>&1" " && { break; }" " || { i=\\$((i+1)); sleep 0.5; }; done; [ \\$i -eq 10 ] && exit\" >>/tmp/vpnconf.sh\n" " echo ip link set dev $dev up netns $myNetNsName mtu $mtu >> /tmp/vpnconf.sh\n" " echo ip netns exec $myNetNsName ip addr add dev $dev \"$4/${ifconfig_netmask:-30}\" " "${ifconfig_broadcast:+broadcast \"$ifconfig_broadcast\"} >> /tmp/vpnconf.sh\n" " echo ip netns exec $myNetNsName ip route add default via \"$route_vpn_gateway\" " "dev $dev >> /tmp/vpnconf.sh\n" " chmod 700 /tmp/vpnconf.sh\n" " fi\n" " ;;\n" " down)\n" " ip netns exec $myNetNsName ip link set dev \"$dev\" down 2>/dev/null || true\n" " ;;\n" "esac\n"); script.close(); QFile target(appHomeDir + ".sh"); bool same = false; const auto &scriptBytes = script.buffer(); if (target.open(QIODevice::ReadOnly)) { QCryptographicHash hasher(QCryptographicHash::Md5); hasher.addData(scriptBytes); const QByteArray sourceHash = hasher.result(); assert(!sourceHash.isEmpty()); // crypto failure.. const QByteArray targetHash = computeFileHash(target); same = targetHash == sourceHash; target.close(); } if (!same) { QFile::remove(appHomeDir + ".sh"); bool ok = target.open(QIODevice::WriteOnly); assert(ok); target.write(scriptBytes); target.close(); target.setPermissions(QFileDevice::ReadOwner | QFileDevice::ExeOwner); } } applyRules(dbEntry, message, m_rulesDir + "global.rules"); QFileInfo proxy("/bin/xdg-dbus-proxy"); if (proxy.isFile() && proxy.isExecutable()) { if (dbEntry.isAllowed("dbus")) { QString target = expandVars(dbEntry, QLatin1String("/run/user/$USERID/bus")); message.addDBusProxy(Message::UserSessionBus, "unix:path=/run/dbus/user-global", target.toStdString()); } if (dbEntry.isAllowed("dbus-system")) message.addDBusProxy(Message::SystemBus, "unix:path=/run/dbus/system-global", "/run/dbus/system_bus_socket"); } else { qWarning() << "Missing tool 'xdg-dbus-proxy', please install to provide dbus to jailed apps"; } if (!dbEntry.initScript.isEmpty()) message.addInitSript(dbEntry.initScript.toStdString()); } catch (const RulesError &e) { if (e.filename.isEmpty()) return QString(e.what()); return QString("%1 '%2'").arg(e.what(), e.filename); } catch (const std::exception &e) { return QString("Limits reached"); } const QString stateInfo = m_basedir % "/1/state-" % QString::number(dbEntry.appId) % ".info"; QSettings runInfo(stateInfo, QSettings::IniFormat); runInfo.setValue("start", QDateTime::currentDateTimeUtc()); runInfo.remove("error"); // in case we are re-using an old state file. if (dbEntry.autoDelete) { runInfo.setValue("autodelete", true); new AutoDeleter(dbEntry, this); } m_runner.runRemote(message); // Because it is useful, lets point from the profile name to the jail directory // (a boring int), if it doesn't exist yet. if (!dbEntry.autoDelete) { QFileInfo symlink(m_basedir + dbEntry.profileName); if (!symlink.isSymLink() && !symlink.exists()) { QFile jail(m_basedir + homedir); assert(jail.exists()); jail.link(dbEntry.profileName); } } return QString("ok"); } IsolationManager::AppEntry IsolationManager::lookupApp(const QString &profileName, LookupBehavior behavior) { IsolationManager::AppEntry rc; auto entry = startEditApp(profileName, behavior); rc.profileName = entry->property("profileName").toString(); rc.appId = entry->value("app-id", -1).toInt(); if (rc.appId == -1) { assert(behavior == OnlyExisting); return rc; } auto denied = entry->value("denied").toString(); rc.denied = denied.split(' ', Qt::SkipEmptyParts); auto allowed = entry->value("allowed").toString(); rc.allowed = allowed.split(' ', Qt::SkipEmptyParts); rc.pathToExe = entry->value("path-to-exe", rc.profileName).toString(); rc.initScript = entry->value("init-script", QString()).toString(); rc.vpnAc = entry->value("vpnAc", QString()).toString(); rc.vpnConf = entry->value("vpnConf", QString()).toString(); return rc; } std::unique_ptr IsolationManager::startEditApp(const QString &profileName, LookupBehavior behavior) { // this removes some stuff, it makes 'firefox' and '/bin/firefox' point to the same database entry. QString shortName(profileName); if (shortName.startsWith("/bin/")) shortName = shortName.mid(5); else if (shortName.startsWith("/usr/bin/")) shortName = shortName.mid(9); else if (shortName.startsWith("/")) shortName = shortName.mid(1); shortName.replace('%', "%25"); shortName.replace('/', "%2f"); auto entry = std::make_unique(m_basedir % m_dbdir % "/" % shortName % ".info", QSettings::IniFormat); entry->setProperty("profileName", shortName); if (entry->value("app-id", -1).toInt() == -1) { // did not exist if (behavior == OnlyExisting) return entry; // ok, then create a new one with a fresh id. entry->setValue("app-id", m_nextJailId++); } return entry; } QList IsolationManager::listProfiles() const { QList answer; QDirIterator iter(m_basedir % m_dbdir); while (iter.hasNext()) { if (!iter.next().endsWith(".info")) continue; const auto infoFile = iter.fileName(); ProfileInfo pi; pi.name = infoFile.left(infoFile.size() - 5); // filename, but chop off the extension pi.name.replace("%2f", "/"); // reverse internal encoding pi.name.replace("%%", "%"); QSettings entry(iter.filePath(), QSettings::IniFormat); pi.jailId = entry.value("app-id", 0).toInt(); if (pi.jailId > 1) { // ignore invalid profile entries pi.exe = entry.value("path-to-exe").toString(); QString appStateFile = stateFile(pi.jailId); if (QFile::exists(appStateFile)) { QSettings appState(appStateFile, QSettings::IniFormat); pi.lastRun = appState.value("start").toDateTime(); int pid = appState.value("pid").toInt(); if (pid) pi.active = QFile::exists(QString("/proc/%1").arg(pid)); } answer.append(pi); } } return answer; } QDir IsolationManager::dbDir() const { return QDir(m_basedir % m_dbdir); } QString IsolationManager::stateFile(int jailId) const { return QString (m_basedir % "/1/state-" % QString::number(jailId) % ".info"); } QString IsolationManager::pipeFilePath(int jailId) const { return QString(m_basedir % QString::number(jailId) % "/.iso-pipe"); } QString IsolationManager::jailDir(int jailId) const { return QString(m_basedir % QString::number(jailId)); } void IsolationManager::applyRules(AppEntry &context, Message &message, const QString &ruleFile) const { QFile in(ruleFile); if (!in.open(QIODevice::ReadOnly)) throw RulesError("Rules could not be read", ruleFile); bool inIfBlock = false; bool ifWasTrue = false; int lineNum = 0; for (const QString line_ : in.readAll().split('\n')) { auto line = line_.trimmed(); ++lineNum; if (line.isEmpty() || line.startsWith('#')) continue; auto items = line.split(' ', Qt::SkipEmptyParts); assert(!items.isEmpty()); // the try concept means that commands failing are seen as a problem // and we stop the processing and fail to start the application if the // command didn't succeed. bool isATry = false; if (items.at(0) == "try") { items.takeFirst(); isATry = true; } if (inIfBlock) { if (items.size() >= 2 && items.at(0) == "if") throw RulesError("nested if detected", ruleFile); if (items.size() == 1 && items.at(0) == "endif") { inIfBlock = false; continue; } if (!ifWasTrue) continue; } else if (items.at(0) == "endif") { throw RulesError("endif without if found"); } // bind takes 2 arguments. if (items.size() == 3 && items.at(0) == "bind") { message.setTry(isATry); message.addRemount( expandVars(context, items.at(1)).toStdString(), expandVars(context, items.at(2)).toStdString()); } else if (items.size() == 2 && items.at(0) == "umount") { message.setTry(isATry); message.addUmountPoint(expandVars(context, items.at(1)).toStdString()); } else if (items.size() == 2 && items.at(0) == "tmpfs") { message.setTry(isATry); message.addMountTmpDir(expandVars(context, items.at(1)).toStdString()); } else if (items.size() == 3 && items.at(0) == "copy") { message.setTry(isATry); message.addCopy( expandVars(context, items.at(1)).toStdString(), expandVars(context, items.at(2)).toStdString()); } else if (items.size() == 2 && items.at(0) == "setEnv") { if (isATry) throw RulesError("try not allowed in front of setEnv"); auto var = expandVars(context, items.at(1)); if (var.indexOf('=') == -1) throw RulesError("setEnv arg needs to be of name=value"); message.addEnvToSet(var.toStdString()); } else if (items.size() == 3 && items.at(0) == "setPermissionDefault") { if (!context.isKnownPermission(items.at(1))) throw RulesError("unknown permission named"); if (items.at(2) != "allowed" && items.at(2) != "denied") throw RulesError("default permission unknown. Use allowed or denied."); context.defaults[items.at(1)] = (items.at(2) == "allowed"); } else if (items.size() == 2 && items.at(0) == "unsetEnv") { if (isATry) throw RulesError("try not allowed in front of setEnv"); auto var = expandVars(context, items.at(1)); if (var.indexOf('=') != -1) throw RulesError("unsetEnv arg can not have a '=' sign"); message.addEnvToUnset(var.toStdString()); } else if (items.size() >= 2 && items.at(0) == "if") { bool negative = false; int index = 2; if (items.at(1) == "denied") { negative = true; } else if (items.at(1) != "allowed") { index = 1; } if (items.size() != index + 1) throw RulesError("malformed if statement", ruleFile); inIfBlock = true; ifWasTrue = context.isAllowed(items.at(index)); if (negative) // invert result ifWasTrue = !ifWasTrue; } else if (items.size() == 1 && items.at(0) == "execute-apprules") { QString rules = m_rulesDir + context.profileName + ".rules"; // also check we're not recursing. // first check if we have an app-specific one. if (rules == ruleFile || !QFileInfo::exists(rules)) rules = m_rulesDir + "default.rules"; if (rules == ruleFile) throw RulesError("Recursive execute-apprules found"); applyRules(context, message, rules); } else { QString a = QString("Rule-parsing failure on line %1").arg(lineNum); std::string latin1(a.toStdString()); throw RulesError(latin1.c_str(), ruleFile); } } } QString IsolationManager::expandVars(const AppEntry &context, const QString &path_) const { QString path(path_); int index = path.indexOf("$APPHOME"); if (index != -1 && (index <= 0 || path.at(index - 1) != '\\')) { path = path.left(index) + QDir::homePath() + QString("/.local/jails/%1").arg(context.appId) + path.mid(index + 8); } index = path.indexOf("$HOME"); if (index != -1 && (index <= 0 || path.at(index - 1) != '\\')) { path = path.left(index) + QDir::homePath() + path.mid(index + 5); } index = path.indexOf("$USERID"); if (index != -1 && (index <= 0 || path.at(index - 1) != '\\')) { path = path.left(index) + QString::number(getuid()) + path.mid(index + 7); } index = path.indexOf("$JAILID"); if (index != -1 && (index <= 0 || path.at(index - 1) != '\\')) { path = path.left(index) + QString::number(context.appId) + path.mid(index + 7); } return path; } QString IsolationManager::rulesDir() const { return m_rulesDir; } void IsolationManager::setRulesDir(const QString &dir) { m_rulesDir = dir; if (!m_rulesDir.endsWith('/')) m_rulesDir += "/"; } void IsolationManager::receivedMessageFromRunner(const QByteArray &data) { QString line_ = QString::fromLatin1(data); QStringView line(line_); int index = line.indexOf(' '); if (index == -1) { qWarning() << "Malformed message from runner"; return; } bool ok; const int appId = line.left(index).toInt(&ok); if (!ok || appId <= 1) { qWarning() << "Malformed message from runner"; return; } const QString stateInfoFilename = stateFile(appId); QSettings runInfo(stateInfoFilename, QSettings::IniFormat); auto rest = line.mid(index + 1); const auto pid = rest.toLong(); if (pid) { runInfo.setValue("pid", rest.toString()); } else { runInfo.setValue("error", rest.toString()); } } bool IsolationManager::AppEntry::isAllowed(const QString &tag) const { auto iter = defaults.find(tag); bool defaultAnswer = true; if (iter != defaults.end()) defaultAnswer = *iter; if (defaultAnswer && denied.contains(tag)) return false; if (!defaultAnswer && allowed.contains(tag)) return true; return defaultAnswer; } void IsolationManager::AppEntry::setDenied(const QStringList &entries) { denied.clear(); for (const auto &perm : entries) { if (!isKnownPermission(perm)) continue; denied.append(perm); } } void IsolationManager::AppEntry::setAllowed(const QStringList &entries) { allowed.clear(); for (const auto &perm : entries) { if (!isKnownPermission(perm)) continue; allowed.append(perm); } } bool IsolationManager::AppEntry::isKnownPermission(const QString &perm) const { if (perm == "ssh" || perm == "git" || perm == "homedir" || perm == "media" || perm == "dbus" || perm == "dbus-system" || perm == "audio" || perm == "docker") return true; return false; } AutoDeleter::AutoDeleter(IsolationManager::AppEntry appEntry, IsolationManager *parent) : QObject(parent), m_parent(parent), m_jail(appEntry) { assert(parent); assert(m_jail.appId > 1); // wait a little while, as it may take a couple more milliseconds // to actually create the pipe. QTimer::singleShot(1, this, SLOT(startMonitor())); connect (&m_watcher, SIGNAL(fileChanged(QString)), this, SLOT(jailClosed(QString))); } void AutoDeleter::startMonitor() { ++m_try; bool ok = m_watcher.addPath(m_parent->pipeFilePath(m_jail.appId)); if (!ok) { // it fails if the file isn't there. Likely due to us trying // too fast and the child process hasn't created the pipe yet. if (m_try > 10) { deleteLater(); return; } // try again soon. QTimer::singleShot(5 * m_try, this, SLOT(startMonitor())); } } void AutoDeleter::jailClosed(const QString &pipeFile) { bool ok = QFile::remove(m_parent->dbDir().absoluteFilePath(m_jail.profileName + ".info")); if (!ok) qWarning() << "Auto-remove: Failed to remove db file"; ok = QFile::remove(m_parent->stateFile(m_jail.appId)); if (!ok) qWarning() << "Auto-remove: Failed to remove state"; QDir jailDir(m_parent->jailDir(m_jail.appId)); ok = jailDir.removeRecursively(); if (!ok) qWarning() << "Auto-remove: Failed to remove jail-dir"; deleteLater(); } DelayedApp::DelayedApp(IsolationManager::AppEntry appEntry, const QStringList &arguments, IsolationManager *parent) : m_parent(parent), m_jail(appEntry), m_arguments(arguments) { assert(parent); } void DelayedApp::askPassword() { auto id = new QInputDialog(); m_win = id; id->setInputMode(QInputDialog::TextInput); id->setTextEchoMode(QLineEdit::Password); id->setLabelText("ISO password:"); id->setWindowTitle("Jail Requires Password"); id->open(this, SLOT(passwordEntered(QString))); connect (id, SIGNAL(rejected()), this, SLOT(cancelPressed())); } void DelayedApp::cancelPressed() { qWarning() << "canceled"; m_win->deleteLater(); deleteLater(); } void DelayedApp::passwordEntered(const QString &text) { m_win->deleteLater(); m_jail.jailPassword = text; m_parent->startApplicationRequest(m_jail, m_arguments); deleteLater(); }