377 lines
18 KiB
PHP
377 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* Raw text editor for /etc/crontab.
|
|
*
|
|
* Drops the parse_ini_file dance — the file is a plain text crontab,
|
|
* not an INI. POST data lands in a textarea, gets staged to
|
|
* /tmp/<obfuscated>.tmp, then sudo-copied back into /etc/crontab.
|
|
* No daemon restart (cron rereads its config automatically).
|
|
*
|
|
* Operator can edit any cron line, including the pistar-* timer hooks
|
|
* — read carefully before saving.
|
|
*/
|
|
require_once($_SERVER['DOCUMENT_ROOT'].'/config/security_headers.php');
|
|
require_once($_SERVER['DOCUMENT_ROOT'].'/config/csrf.php');
|
|
require_once($_SERVER['DOCUMENT_ROOT'].'/config/banner_warnings.inc');
|
|
setSecurityHeaders();
|
|
|
|
// CSRF protection — see config/csrf.php for the full rationale.
|
|
// Must run BEFORE any output: bootstraps the session on GET (so
|
|
// Set-Cookie ships) and rejects forged POSTs cleanly with 403
|
|
// before any state change (sed-i, fopen+fwrite, sudo cp, etc.).
|
|
csrf_verify();
|
|
|
|
// Layer 2 of the default-password protection — see config/banner_warnings.inc.
|
|
// MUST run BEFORE any output so header('Location: ...') works.
|
|
pistar_warnings_enforce_redirect();
|
|
|
|
// Load the language support
|
|
require_once('../config/language.php');
|
|
//Load the Pi-Star Release file
|
|
$pistarReleaseConfig = '/etc/pistar-release';
|
|
$configPistarRelease = array();
|
|
$configPistarRelease = parse_ini_file($pistarReleaseConfig, true);
|
|
//Load the Version Info
|
|
require_once('../config/version.php');
|
|
?>
|
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" lang="en">
|
|
<head>
|
|
<meta name="robots" content="index" />
|
|
<meta name="robots" content="follow" />
|
|
<meta name="language" content="English" />
|
|
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
|
|
<meta name="Author" content="Andrew Taylor (MW0MWZ)" />
|
|
<meta name="Description" content="Pi-Star Expert Editor" />
|
|
<meta name="KeyWords" content="Pi-Star" />
|
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
|
<meta http-equiv="pragma" content="no-cache" />
|
|
<link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon">
|
|
<meta http-equiv="Expires" content="0" />
|
|
<title>Pi-Star - Digital Voice Dashboard - Expert Editor</title>
|
|
<link rel="stylesheet" type="text/css" href="../css/pistar-css.php" />
|
|
</head>
|
|
<body>
|
|
<?php pistar_warnings_render(); ?>
|
|
<div class="container">
|
|
<?php include './header-menu.inc'; ?>
|
|
<div class="contentwide">
|
|
<?php
|
|
/**
|
|
* Cron-content denylist scanner.
|
|
*
|
|
* The dashboard sits behind HTTP basic auth. An operator (or anyone
|
|
* with the basic-auth credential) can edit /etc/crontab through this
|
|
* page. Cron runs lines as root, so a malicious cron entry is a
|
|
* direct privilege escalation off whatever access the dashboard
|
|
* already grants.
|
|
*
|
|
* This is BASIC HARDENING — not airtight. A determined attacker can
|
|
* still bypass via:
|
|
*
|
|
* - Variable indirection: `* * * * * root $X user` where $X is
|
|
* defined in a sourced file.
|
|
* - External script paths: a benign-looking `/opt/myapp/run.sh`
|
|
* can do anything; we scan the cron line, not the script.
|
|
* - Brace expansion: `/usr/bin/{passwd,sh}` produces `passwd`
|
|
* after shell expansion but our `\b` boundary doesn't trip on
|
|
* the literal `passwd,` substring.
|
|
*
|
|
* Obfuscation patterns are NOT an accepted bypass — there's no
|
|
* legitimate cron use for inline base64 / hex / eval / etc., so
|
|
* the second rule block below blocks every common decoder
|
|
* (base64 / b64decode / fromhex / xxd / openssl -d / shell hex
|
|
* escapes / shell unicode escapes / eval). Operators with a
|
|
* legitimate need to encode/decode data should put that logic in
|
|
* a script file, where this denylist doesn't apply.
|
|
*
|
|
* The goal is to catch the obvious-bad patterns described in the
|
|
* threat brief: password mutation, direct writes to /etc/shadow /
|
|
* /etc/passwd / .htpasswd, and the standard shell-listener /
|
|
* reverse-shell idioms (nc, socat exec, /dev/tcp redirects,
|
|
* download-pipe-shell, python/perl one-liner shells), plus the
|
|
* obfuscation patterns commonly used to wrap them.
|
|
*
|
|
* Comment lines (starting with `#`) and blank lines are exempt.
|
|
* Cron variable assignments (PATH=, MAILTO=, SHELL=) are scanned
|
|
* the same as commands — the rules below tolerate the false-positive
|
|
* surface for the safety win. Known accepted false positive in the
|
|
* wild: `MAILTO=passwd@example.com` triggers the password-command
|
|
* rule. Operators with that exact email can use a different alias
|
|
* or edit /etc/crontab via SSH to bypass the dashboard guard.
|
|
*
|
|
* Returns an array of human-readable diagnostics, one per problem
|
|
* line. Empty array means OK to save.
|
|
*
|
|
* @param string $content Raw POSTed cron content (already \r-stripped
|
|
* and decoded back to bytes by the browser).
|
|
* @return array<int,string>
|
|
*/
|
|
function pistar_cron_validate($content)
|
|
{
|
|
$rules = array(
|
|
// Password modification commands. The leading character class
|
|
// matches start-of-line OR a typical token boundary (whitespace,
|
|
// path separator, shell metas, or `=` so we catch X=passwd).
|
|
// \b at the end stops "passwords" / "chpasswdtest" matching.
|
|
'#(?:^|[\s/;|&`$()=])(passwd|chpasswd|htpasswd)\b#i'
|
|
=> 'invokes a password-modification command (passwd / chpasswd / htpasswd)',
|
|
// usermod -p sets a password hash directly, no PAM, no audit.
|
|
'#\busermod\b[^\n]*\s-p\b#i'
|
|
=> 'usermod -p sets a password hash directly',
|
|
// Reference to any auth file. Even reading these from cron is
|
|
// unusual; writing is highly suspicious. Catches /etc/shadow,
|
|
// /etc/passwd, /etc/passwd-, and any .htpasswd path including
|
|
// /var/www/.htpasswd.
|
|
'#(/etc/shadow|/etc/passwd|\.htpasswd)\b#i'
|
|
=> 'references an authentication file path',
|
|
// Netcat — block any invocation. Operators with a legitimate
|
|
// use should script it outside cron. Blocking outright is
|
|
// simpler than disambiguating safe vs. unsafe nc flags.
|
|
'#(?:^|[\s/;|&`$()=])(nc|ncat|netcat)\b#i'
|
|
=> 'invokes nc / ncat / netcat',
|
|
// socat with EXEC: or SYSTEM: targets — process-spawning.
|
|
'#\bsocat\b[^\n]*\b(EXEC|SYSTEM):#i'
|
|
=> 'socat with EXEC/SYSTEM token (spawns a process)',
|
|
// Bash builtin /dev/tcp / /dev/udp redirect — classic reverse-
|
|
// shell idiom (`bash -i >& /dev/tcp/h/p 0>&1`). No legitimate
|
|
// cron use.
|
|
'#/dev/(?:tcp|udp)/#'
|
|
=> 'reads or writes /dev/tcp/* or /dev/udp/* (reverse-shell pattern)',
|
|
// Download-pipe-shell. Catches `curl ... | sh`, `wget ... | bash`,
|
|
// etc. The `[^|\n]*` between the fetch tool and the pipe stops
|
|
// a stray pipe later in a long line giving false positives.
|
|
'#\b(curl|wget|fetch)\b[^|\n]*\|\s*(?:bash|sh|zsh|ksh|dash|exec)\b#i'
|
|
=> 'pipes downloaded content directly into a shell',
|
|
// python reverse-shell one-liners — heuristic on imports/calls
|
|
// commonly used in the published payloads.
|
|
'#\bpython[23]?\b[^\n]*-c[^\n]*(?:socket\.socket|os\.dup2)#i'
|
|
=> 'python reverse-shell pattern',
|
|
// perl reverse-shell one-liner — `perl -e \'use Socket; ...\'`
|
|
'#\bperl\b[^\n]*-e[^\n]*\bSocket\b#i'
|
|
=> 'perl reverse-shell pattern',
|
|
|
|
// ====== Obfuscation / decoder patterns ======
|
|
// No legitimate cron use for inline encoded payloads. Operators
|
|
// who need to encode/decode something should do it in a script,
|
|
// not in a cron line. Catches the common payload-wrapper idioms.
|
|
|
|
// base64 in any form — the command, the python module, perl's
|
|
// decode_base64, etc. Substring match (no \b) so it also
|
|
// catches "decode_base64", "MIME::Base64", "base64.b64decode".
|
|
'#base64#i'
|
|
=> 'references base64 (no legitimate cron use; payload-obfuscation idiom)',
|
|
// python's base64 module exposes b64decode / b64encode aliases
|
|
// that don't contain the literal "base64" substring.
|
|
'#\bb64(?:decode|encode)\b#i'
|
|
=> 'python b64decode/b64encode (payload decoder)',
|
|
// python's bytes.fromhex(...) — turns a hex string into bytes
|
|
// for runtime execution.
|
|
'#\bfromhex\b#i'
|
|
=> 'fromhex (hex payload decoder)',
|
|
// xxd dumps / reverses hex. Reverse mode (-r) is a payload
|
|
// decoder; forward mode is hex-dumping which has no cron use.
|
|
// Block the binary outright.
|
|
'#\bxxd\b#i'
|
|
=> 'invokes xxd (hex dump / reverse-mode payload decoder)',
|
|
// Shell hex / unicode escape literals. `printf \'\x70\x61\x73\x73\'`
|
|
// spells "pass" at runtime — pure obfuscation in cron context.
|
|
'#\\\\x[0-9a-fA-F]{2}#'
|
|
=> 'shell hex-escape literal (\\xNN — payload obfuscation)',
|
|
'#\\\\u[0-9a-fA-F]{4}#'
|
|
=> 'shell unicode-escape literal (\\uNNNN — payload obfuscation)',
|
|
// eval interprets its argument as shell — opaque to anyone
|
|
// reading the cron line.
|
|
'#(?:^|[\s/;|&`$()=])eval\b#i'
|
|
=> 'invokes eval (opaque shell-string execution)',
|
|
// openssl -d in any context (enc -d, base64 -d, ...) is a
|
|
// generic decoder.
|
|
'#\bopenssl\b[^\n]*\s-d\b#i'
|
|
=> 'openssl with -d (decoder)',
|
|
);
|
|
|
|
$blockers = array();
|
|
$lines = explode("\n", $content);
|
|
foreach ($lines as $i => $line) {
|
|
$trimmed = ltrim($line);
|
|
if ($trimmed === '' || $trimmed[0] === '#') {
|
|
continue;
|
|
}
|
|
foreach ($rules as $pattern => $reason) {
|
|
if (preg_match($pattern, $line)) {
|
|
$blockers[] = sprintf(
|
|
'line %d (%s): %s',
|
|
$i + 1,
|
|
$reason,
|
|
substr(trim($line), 0, 100)
|
|
);
|
|
break; // one diagnostic per line is enough
|
|
}
|
|
}
|
|
}
|
|
return $blockers;
|
|
}
|
|
|
|
$cronBlockers = array();
|
|
$saveError = false;
|
|
$saveOk = false;
|
|
$readError = false;
|
|
if(isset($_POST['data'])) {
|
|
// Normalise CRLF → LF before validation so a Windows browser's
|
|
// submission scans byte-identical to the on-disk form.
|
|
$rawData = str_replace("\r", "", (string)$_POST['data']);
|
|
$cronBlockers = pistar_cron_validate($rawData);
|
|
|
|
if (!empty($cronBlockers)) {
|
|
// Validation failed — DO NOT touch /etc/crontab. Surface
|
|
// the diagnostics in the page and re-render the form
|
|
// with the operator's submitted content so they can
|
|
// fix in place without retyping.
|
|
$theData = $rawData;
|
|
error_log('Pi-Star fulledit_cron.php: rejected save with '
|
|
. count($cronBlockers) . ' blocked line(s)');
|
|
} else {
|
|
// A3-3 — see edit_ircddbgateway.php for the full TOCTOU
|
|
// rationale. Per-request random staging path defeats
|
|
// the predictable-name pre-create / symlink class.
|
|
$filepath = tempnam('/tmp', 'pistar-edit-');
|
|
register_shutdown_function(function() use ($filepath) { @unlink($filepath); });
|
|
$fh = fopen($filepath, 'w');
|
|
if ($fh !== false) {
|
|
fwrite($fh, $rawData);
|
|
fclose($fh);
|
|
}
|
|
|
|
// Write through the one sudoers-allowlisted primitive for
|
|
// this file: `sudo sed -i … /etc/crontab`. (The previous
|
|
// `install … /etc/crontab` had no matching sudoers rule and
|
|
// was silently rejected — saves looked successful but never
|
|
// touched disk.) The operator's bytes are poured over the
|
|
// file with sed's `1r <file>` + `d` idiom: read the staged
|
|
// temp file in on the first cycle, delete every original
|
|
// line, leaving an exact byte-for-byte copy. The operator's
|
|
// content never enters the sed script — only the temp file
|
|
// PATH does — so there is no sed/shell-injection surface.
|
|
// sed -i preserves the existing root:root 644 (verified on a
|
|
// live host), so no follow-up chown/chmod is needed.
|
|
// $sedOut is an unused placeholder (sed -i is silent on
|
|
// success); the save status we act on is the exit code $rc
|
|
// plus the read-back comparison below.
|
|
$rc = 0;
|
|
$sedOut = array();
|
|
exec('sudo mount -o remount,rw /');
|
|
exec('sudo sed -i -e ' . escapeshellarg('1r ' . $filepath) . ' -e d /etc/crontab', $sedOut, $rc);
|
|
exec('sudo mount -o remount,ro /'); // always re-protect the rootfs
|
|
|
|
// Loud-failure check. The old code unconditionally rendered
|
|
// "as saved", which is exactly how the broken write hid for
|
|
// weeks. Read /etc/crontab back (world-readable) and compare
|
|
// byte-for-byte: a non-zero sed exit OR any mismatch means
|
|
// the save did not land. This also covers the degenerate
|
|
// empty-crontab corner, where `1r` never fires and the file
|
|
// would be left empty.
|
|
$onDisk = @file_get_contents('/etc/crontab');
|
|
if ($rc !== 0 || $onDisk !== $rawData) {
|
|
$saveError = true;
|
|
error_log('Pi-Star fulledit_cron.php: crontab save FAILED (sed rc='
|
|
. $rc . ', readback '
|
|
. ($onDisk === $rawData ? 'matched' : 'MISMATCHED') . ')');
|
|
} else {
|
|
$saveOk = true;
|
|
}
|
|
|
|
// Re-render the submitted content either way so a failed
|
|
// save never discards the operator's edits.
|
|
$theData = $rawData;
|
|
}
|
|
} else {
|
|
// A3-3 — see edit_ircddbgateway.php for the full TOCTOU rationale.
|
|
$filepath = tempnam('/tmp', 'pistar-edit-');
|
|
register_shutdown_function(function() use ($filepath) { @unlink($filepath); });
|
|
exec('sudo cp /etc/crontab ' . escapeshellarg($filepath));
|
|
exec('sudo chown www-data:www-data ' . escapeshellarg($filepath));
|
|
exec('sudo chmod 600 ' . escapeshellarg($filepath));
|
|
$fh = fopen($filepath, 'r');
|
|
if ($fh === false) {
|
|
// The privileged copy of /etc/crontab could not be staged
|
|
// (sudo cp/chown/chmod failed). Surface a read error and show
|
|
// an empty editor rather than a blank-but-editable textarea —
|
|
// saving from the latter would overwrite /etc/crontab with
|
|
// nothing.
|
|
$theData = '';
|
|
$readError = true;
|
|
error_log('Pi-Star fulledit_cron.php: could not stage /etc/crontab for reading');
|
|
} else {
|
|
$sz = filesize($filepath);
|
|
$theData = $sz > 0 ? fread($fh, $sz) : '';
|
|
fclose($fh);
|
|
}
|
|
}
|
|
|
|
?>
|
|
<?php if (!empty($cronBlockers)) { ?>
|
|
<div style="background-color: #ff9090; color: #f01010; padding: 10px; margin: 0 0 10px 0;">
|
|
<b>Save rejected — the cron editor blocks lines that match common privilege-escalation patterns.</b>
|
|
<ul style="margin: 8px 0 0 16px;">
|
|
<?php foreach ($cronBlockers as $b) {
|
|
echo '<li>' . htmlspecialchars($b, ENT_QUOTES, 'UTF-8') . "</li>\n";
|
|
} ?>
|
|
</ul>
|
|
<p style="margin: 8px 0 0 0;">Edit the offending line(s) and re-submit, or revert your changes.
|
|
If you have a legitimate need to schedule one of these patterns, use SSH and edit
|
|
<code>/etc/crontab</code> directly — the dashboard editor enforces these checks
|
|
to limit the damage of stolen dashboard credentials.</p>
|
|
</div>
|
|
<?php } ?>
|
|
<?php if ($saveError) { ?>
|
|
<div style="background-color: #ff9090; color: #f01010; padding: 10px; margin: 0 0 10px 0;">
|
|
<b>Save failed — /etc/crontab was not updated.</b>
|
|
<p style="margin: 8px 0 0 0;">The write was rejected or the file did not match after saving. Your edits are
|
|
preserved below — try again, or edit <code>/etc/crontab</code> directly over SSH.</p>
|
|
</div>
|
|
<?php } elseif ($saveOk) { ?>
|
|
<div id="cronSaveOk" style="background-color: #c0f0c0; color: #106010; padding: 10px; margin: 0 0 10px 0;">
|
|
<b>Crontab saved.</b>
|
|
</div>
|
|
<script>
|
|
// Auto-dismiss the success banner after 5s. The error banners are left in
|
|
// place on purpose so a failed save can never scroll away unnoticed.
|
|
setTimeout(function () {
|
|
var el = document.getElementById('cronSaveOk');
|
|
if (!el) { return; }
|
|
// Honour reduced-motion preferences: snap rather than fade.
|
|
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
el.style.transition = reduce ? 'none' : 'opacity 0.6s ease';
|
|
el.style.opacity = '0';
|
|
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 650);
|
|
}, 5000);
|
|
</script>
|
|
<?php } ?>
|
|
<?php if ($readError) { ?>
|
|
<div style="background-color: #ff9090; color: #f01010; padding: 10px; margin: 0 0 10px 0;">
|
|
<b>Could not read /etc/crontab.</b>
|
|
<p style="margin: 8px 0 0 0;">The current crontab could not be loaded, so the editor below is empty.
|
|
<b>Do not save</b> — doing so would overwrite /etc/crontab with nothing. Reload the page, or edit
|
|
<code>/etc/crontab</code> directly over SSH.</p>
|
|
</div>
|
|
<?php } ?>
|
|
<form name="test" method="post" action="">
|
|
<?php csrf_field(); ?>
|
|
<textarea name="data" cols="80" rows="45"><?php echo htmlspecialchars((string)$theData, ENT_QUOTES, 'UTF-8'); ?></textarea><br />
|
|
<input type="submit" name="submit" value="<?php echo $lang['apply']; ?>" />
|
|
</form>
|
|
|
|
</div>
|
|
|
|
<div class="footer">
|
|
Pi-Star / Pi-Star Dashboard, © Andy Taylor (MW0MWZ) 2014-<?php echo date("Y"); ?>.<br />
|
|
Need help? Click <a style="color: #ffffff;" href="https://www.facebook.com/groups/pistarusergroup/" target="_new">here for the Support Group</a><br />
|
|
Get your copy of Pi-Star from <a style="color: #ffffff;" href="http://www.pistar.uk/downloads/" target="_new">here</a>.<br />
|
|
</div>
|
|
|
|
</div>
|
|
</body>
|
|
</html>
|
|
|