Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for killing sessions based on last checkin time #18364

Merged
merged 1 commit into from
Oct 19, 2023

Conversation

zgoldman-r7
Copy link
Contributor

@zgoldman-r7 zgoldman-r7 commented Sep 13, 2023

Implements existing --search (or -S) flag to address #5664
closes #5664
Uses:
list sessions that have checked in within 10 seconds:
sessions --search "last_checkin:less_than:10s"

list stale sessions that havent within 10 seconds:
sessions --search "last_checkin:greater_than:10s"

kill all stale sessions older than 20 seconds:
sessions --search "last_checkin:greater_than:10s" -K
sessions -K --search "last_checkin:greater_than:10s"

search for a session with specific ids
sessions --search "session_id:1 session_id:2"

search for sessions of a particular type
sessions --search "session_type:meterpreter"

search for sessions with multiple query parameters
sessions --search "session_id:1 session_type:meterpreter last_checkin:after:10s"

kill sessions that match a range of query parameters
sessions --search "session_id:1 session_type:meterpreter last_checkin:after:10s" -K

run a command on sessions that match a range of query parameters
sessions -c 'whoami' --search "session_id:1 session_type:meterpreter last_checkin:after:10s"

Verification

List the steps needed to make sure this thing works

  • Start msfconsole
  • launch a module
  • start any number of active sessions
  • list active sessions with sessions -v to see what metadata exists
  • try filtering the sessions in accordance with the types of search terms listed above and make sure the results are expected

@adfoster-r7
Copy link
Contributor

I don't think this will be a scalable approach adding a one off flag for check-in functionality. It's not immediately clear how a user will filter/search for sessions that are checked in before or after a point in time - i.e. only run this command on sessions checked in less than 1 hour ago, or kill sessions greater than 1 day old

Maybe we need to consider adding additional keyword search terms to the sessions command, similar to the prior art of the search command:

search cve:2009 type:exploit

Taking inspiration from Docker's filter options - it has similar support for filtering:

images

  • before ([:], or image@digest) - filter images created before given id or references
    since ([:], or image@digest) - filter images created since
    given id or references

prune

  • until () - only remove images created before given timestamp
    The until filter can be Unix timestamps, date formatted timestamps, or Go duration strings (e.g. 10m, 1h30m) computed relative to the daemon machine’s time. Supported formats for date formatted time stamps include RFC3339Nano, RFC3339, 2006-01-02T15:04:05, 2006-01-02T15:04:05.999999999,

A similar DSL in msfconsole might be:

sessions --kill --search last_checkin:after:12h
sessions -c 'whoami' --search last_checkin:before:2h

It would be out of scope, but we should do some pen and paper exercises first to ensure that the semantics would work for the search table:

search disclosure_date:after:2020-01-01 type:exploit

@zgoldman-r7 zgoldman-r7 marked this pull request as draft September 26, 2023 17:22
@zgoldman-r7 zgoldman-r7 force-pushed the ms9056-list-stale-sessions branch 5 times, most recently from 8cd5fb5 to 6938597 Compare September 26, 2023 17:56
@adfoster-r7 adfoster-r7 changed the title add -e flag for stale sessions Add support for killing sessions based on last checkin time Sep 29, 2023
@zgoldman-r7 zgoldman-r7 force-pushed the ms9056-list-stale-sessions branch 7 times, most recently from ca6ebd7 to 9683743 Compare October 4, 2023 20:11
@zgoldman-r7 zgoldman-r7 marked this pull request as ready for review October 4, 2023 20:12
Comment on lines 863 to 869

framework.sessions.each { |k|
next unless session_ids.nil? || session_ids.include?(k[0])
session = k[1]
row = create_msf_session_row(session, show_extended)
tbl << row
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
framework.sessions.each { |k|
next unless session_ids.nil? || session_ids.include?(k[0])
session = k[1]
row = create_msf_session_row(session, show_extended)
tbl << row
}
framework.sessions.each do |id , session|
next unless session_ids.nil? || session_ids.include?(id)
row = create_msf_session_row(session, show_extended)
tbl << row
}

I know you're just using the existing code here but we can tidy this up a little since we're making changes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adfoster-r7 this conflicts with going back to each_sorted as that only iterates on the ids - thoughts on where to go?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't realise that each_sorted only gave session ids rather than the sessions themselves
Taking a look at the each function though shows that it is sorted anyway

def each(&block)
list = []
self.keys.sort.each do |sidx|
list << [sidx, self[sidx]]
end
list.each(&block)
end

so IMO I think we're good to stick with each since you need the session objects anyway and it keeps the same sorting

session = framework.sessions[k]

framework.sessions.each { |k|
next unless session_ids.nil? || session_ids.include?(k[0])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If sessions_ids is nil then it will be nil for every iteration of the loop, no need to check it over and over

you can also probably be a little smarter about filtering the sessions you want to include here, instead of looping over all the frameworks sessions and checking the id it might make more sense to loop over your list of sessions_ids and then grab the session with framework.sessions.get(id)

@@ -987,6 +990,11 @@ def self.dump_sessions_verbose(framework, opts={})
framework.sessions.each_sorted do |k|
session = framework.sessions[k]

if opts[:session_ids]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to my previous comment, you should only need to check if opts[:session_ids] has a value once rather than every loop

@@ -1425,6 +1431,7 @@ def cmd_sessions(*args)
begin
method = nil
quiet = false
search = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
search = false

I don't think this is being used

if search_term
matching_sessions = get_matching_sessions(search_term)
if matching_sessions.empty?
print_error("No matching sessions.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
print_error("No matching sessions.")
print_status("No matching sessions.")

Not having sessions match your search term isn't an error, this also aligns with a later print_status for not matching any sessions

matching_sessions = {}

framework.sessions.each do |session_id, session|
case search_term.split(":")[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make sure to check if the search term is valid and feed that back to the user

case search_term.split(":")[0]
when "last_checkin"
if session.respond_to?(:last_checkin) && session.last_checkin && evaluate_search_criteria(session, search_term)
matching_sessions.store(session_id, session)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
matching_sessions.store(session_id, session)
matching_sessions[session_id] = session

This might just be a personal preference thing but I think this reads better and is much more common in the code base

Comment on lines 1860 to 1971
when "session_type"
matching_sessions.store(session_id, session) if evaluate_search_criteria(session, search_term)
when "session_id"
matching_sessions.store(session_id, session) if evaluate_search_criteria(session, search_term)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all these cases in the switch statement don't seem to do anything different?

Comment on lines 1844 to 1905
matches.each do |session_id, session|
matching_sessions.store(session_id, session)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://apidock.com/ruby/Hash/merge you can use merge or merge! instead of looping over your hashes

Comment on lines 1843 to 1844
matches = filter_sessions_by_search(term)
matches.each do |session_id, session|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

am I correct in thinking that this results in your filter being an or filter rather than and? so if you search for before a certain checkin time and for session to be a meterpreter type rather than getting only meterpreter sessions checking in before a certain time you get all sessions that have checked in and all meterpreter sessions regardless of checkin time?

I would have expected the search to be an and and I think that's what users would expect too, but let me know if you disagree

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking an or filter for the case of someone searching for multiple different ids or session types, and having it be consistent from there. Seems like a few options -

  1. keep it as an or, which might lead to some users on the search results if they try to query on multiple fields as you pointed out
  2. change it to an and, effectively disabling the ability to search for multiple different types of the same field
  3. add the logic to make the or/and context sensitive, which might be more convoluted/inconsistent, but has the most functionality
    Happy to defer to you here, lmk what you think

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try and base our implementation off of how docker does it https://docs.docker.com/engine/reference/commandline/images/#filter since thye seem to have a fairly good UX for this kinda thing already
sessions IDs would be an or then anything else would be an and
I really think the ability to provide a before time and an after time to select sessions from a certain range could be a pretty nice feature to have and I think it's more intuitive that way
Probably best to write up the test cases first for what we would expect to happen and maybe get a few more opinions on that when people can see what the workflow will be more easily

Comment on lines 480 to 750
Killing matching sessions...
Active sessions
===============

Id Name Type Information Connection
-- ---- ---- ----------- ----------
2 sesh2 meterpreter info tunnel (127.0.0.1)
3 sesh3 java info tunnel (127.0.0.1)
TABLE
Copy link
Contributor

@adfoster-r7 adfoster-r7 Oct 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's indent this for readability, similar to

expect(described_class.dump_datastore('Table name', Msf::DataStore.new, indent_length)).to match_table <<~TABLE
Table name
==========
No entries in data store.
TABLE
end

for context heredoc can be aligned/extra indented: https://www.rubyguides.com/2018/11/ruby-heredoc/


let(:sessions) do
{
1 => double('Session', last_checkin: Time.now, type: 'meterpreter', sid:1, sname: "sesh1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not tested: I believe you should be able to use instance_double(::Msf::Session, ...) here. instance_double is nicer as it will verify your mocked calls against a real class instance - so if you mock values that are deleted from the real object your tests will start failing. Whilst a mock would continue to work.

This should work the methods that are called aren't defined with metaprogramming/method_missing logic

In RSpec 3.0 and higher, instance_double is an implementation of verifying doubles. Verifying doubles are defined in RSpec as "a stricter alternative to normal doubles that provide guarantees about what is being verified. When using verifying doubles, RSpec will check that the methods being stubbed are actually present on the underlying object, if it is available."
https://www.simplybusiness.co.uk/about-us/tech/2020/05/rspec-instance-doubles/

{
1 => double('Session', last_checkin: Time.now, type: 'meterpreter', sid:1, sname: "sesh1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel"),
2 => double('Session', last_checkin: (Time.now - 90), type: 'meterpreter', sid:2, sname: "sesh2", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel"),
3 => double('Session', last_checkin: Time.now, type: 'java', sid:3, sname: "sesh3", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For anything timing related, it's probably best to mock time with TimeCop to ensure that your tests are deterministic on every test run. It also provides a nice API for time travel

before(:each) do
Timecop.freeze(Time.parse('Dec 18, 2022 12:33:40.000000000 GMT'))
create_tickets
end
after do
Timecop.return
end

@@ -834,6 +834,7 @@ def self.dump_datastore(name, ds, indent = DefaultIndent, col = DefaultColumnWra
def self.dump_sessions(framework, opts={})
output = ""
verbose = opts[:verbose] || false
session_ids = opts[:session_ids] || nil
Copy link
Contributor

@adfoster-r7 adfoster-r7 Oct 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be worried about race conditions in IDs changing over time or being deleted by threads due to race conditions in code etc.

What are your thoughts on passing in the sessions object here in the opts that you would like to have serialized? Of course if the user doesn't provide the sessions, it'll grab them from framework.sessions. I think this would be a nicer API - and we wouldn't need this logic that's been added below either 🤔

      framework.sessions.each { |k|
        next unless session_ids.nil? || session_ids.include?(k[0])

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that makes sense - I'll change it up!

@@ -987,6 +990,11 @@ def self.dump_sessions_verbose(framework, opts={})
framework.sessions.each_sorted do |k|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -115,6 +114,13 @@ class Core
["-g", "--global"] => [ false, "Operate on global datastore variables"]
)

VALID_PARAMS =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
VALID_PARAMS =
VALID_SESSION_SEARCH_PARAMS =

Copy link
Contributor

@adfoster-r7 adfoster-r7 Oct 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And can we use private_constant :VALID_SESSION_SEARCH_PARAMS too to ensure this constant isn't available externally, as currently this constant would now be available as part of this classes public API 👍

https://aaronlasseigne.com/2016/10/26/know-ruby-private_constant/

context "with no sessions" do
it "should show an empty table" do
core.cmd_sessions
expect(@output).to eq([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be great to be consistent with the use of match_table for these newly added tests 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into this a bit, this case doesn't return a table so using match table results in failure

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

match_table is a misnomer, it's mostly just a match_multiline_string matcher

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. There does seem to be some sort of formatting mismatch with the expecation though.

  1) Msf::Ui::Console::CommandDispatcher::Core#cmd_sessions with no sessions should show an empty table
     Failure/Error:
               expect(@output).to match_table <<~TABLE
                 Active sessions
                 ===============

                 No active sessions.
                 TABLE

       Expected:
       'Active sessions'
       '==============='
       ''
       'No active sessions.'
       Received:
       '["Active sessions", "===============", "", "No active sessions."]'
       Raw Result:
       ["Active sessions", "===============", "", "No active sessions."]

       Diff:
       @@ -1,5 +1,2 @@
       -Active sessions
       -===============
       -
       -No active sessions.
       +["Active sessions", "===============", "", "No active sessions."]

I'll dive deeper and see what I can uncover

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you're expecting the string to equal an array:

       Expected:
       'Active sessions'
       '==============='
       ''
       'No active sessions.'
       Received:
       '["Active sessions", "===============", "", "No active sessions."]' <----

i.e. you're missing the @output.join("\n") that's being used in the other test examples

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, thank ya!

framework.sessions.each_sorted { |k|
session = framework.sessions[k]

framework.sessions.each { |k|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine we'll still want each_sorted here, not sure if it was dropped intentionally or not 👀

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It didn't seem necessary and was causing issues with testing, but I'll fix it 💪

before(:each) do
allow(driver).to receive(:active_session=)
allow(framework).to receive(:sessions).and_return(sessions)
Timecop.freeze(Time.parse('Dec 18, 2022 12:33:40.000000000 GMT'))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don''t need to set up timecop before each test, we can do it once for this section of tests and also make sure to cleanup and tell timecop to return things to normal
https://github.com/travisjeffery/timecop#:~:text=You%20can%20mock%20the%20time%20for%20a%20set%20of%20tests%20easily%20via%20setup/teardown%20methods

end
end

context "searches with sessions with different ids" do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this context is good english but that might just be me

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...isn't? 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦 yup isn't, maybe I shouldn't comment on english if I can't do it myself 🙃

context "searches with sessions with different checkin values" do
let(:sessions) do
{
1 => instance_double(::Msf::Sessions::Meterpreter_x64_Win, last_checkin: Time.parse('Dec 18, 2022 12:33:40.000000000 GMT'), type: 'meterpreter', sid:1, sname: "session1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're using Timecop you don't need to be parsing dates anymore

context "with sessions" do
let(:sessions) do
{
1 => instance_double(::Msf::Sessions::Meterpreter_x64_Win, last_checkin: Time.now, type: 'meterpreter', sid:1, sname: "sesh1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just thinking out loud here but would it make more sense/be easier to read to just have a common set of session you could use across most of your tests here? rather than making a bespoke set for each test case you have

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it would be easier to read, I put it this way since we don't want them to exist for the "no active sessions" context which exists at the same level as all the other contexts. But if we're fine separating them, or there's another rspec trick I'm not privy to, happy to change that up

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, I suppose using the same sessions might be an issue when testing sessions that don't respond to last_checkin, if we want to see what a query returns when none of the sessions do

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my head I was thinking you could have two contexts, with and without sessions but it's entirely possible there's some technical issues you might or have already ran into that I'm not thinking of why you can't do that

end

it "returns matching session when searching for id" do
core.cmd_sessions("--search", "session_id:1")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any tests for what happens when you combine searching for a session id and something else like type or checkin?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 602!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not seeing it 😅 I can see where you're testing type with checking but not with session id
it might be somewhere and the line numbers have changed since you replied and I just can't see though

print_error("Cannot search for last_checkin with two #{operator1} arguments.")
return false
end
if (operator1 == "before" && operator2 == "after" && parse_duration(value2) < parse_duration(value1)) || (operator1 == "after" && operator2 == "before" && parse_duration(value1) < parse_duration(value2))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use single quotes '' over double quotes "" when not needed, I think rubocop should throw you a warning about that

Comment on lines 1846 to 1867
# Make sure user is properly querying checkin
unless checkin_searches.length() < 2
unless validate_checkin_searches(checkin_searches)
return
end
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should validate the checkin searches regardless of how many you have since if you only have a single checkin search you'll miss out on the rest of the validation you have (like that only before and after are valid)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that validation occurs later, but you're right it'd be good to consolidate it into that function to clean up the logic!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circling back - checkin_searches.length() < 2 is itself a validation, so the logic does need to be tweaked but we need to keep that line somewhere

Copy link
Contributor

@dwelch-r7 dwelch-r7 Oct 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't that validation redundant since if you have 2 or more searches you'll either have an invalid operator or a duplicate? but if you think it's necessary then can't it be moved inside the validate_checkin_searches function?

Comment on lines 1866 to 1895
# Retrieve checkin search results. AND filter with a max length of 2
unless checkin_searches.empty?
checkin_matches = {}
checkin_matches = filter_sessions_by_search(checkin_searches.first)
if checkin_searches[1]
matches = filter_sessions_by_search(checkin_searches[1])
checkin_matches = checkin_matches.select{ |session_id, session| matches[session_id] == session }
end
searches << checkin_matches
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkin_matches = {}
checkin_searches.each do | search_query |
 ...
end

you should just be able to loop over the contents and achieve the same thing you're trying to do here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused what a loop would add here since checkin_searches can't be more than two elements and all we're trying to do is get the intersection of the two if there are two, can you elaborate?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uhhh yea I was thinking it could look a bit tidier and maybe more future proof but don't worry about it sure

if matching_sessions
print_status("Killing matching sessions...")
print_line
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be a print_status?

Suggested change
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was just going with the existing functionality, but that's no reason not to give it a swap if we think it's better

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this question became automagically unresolved; It shouldn't be print_status here as the table would be printed with a [*] prefix which isn't needed

Comment on lines 1542 to 1568
unless matching_sessions
return
end
if matching_sessions.empty?
print_error("No matching sessions.")
return
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
unless matching_sessions
return
end
if matching_sessions.empty?
print_error("No matching sessions.")
return
end
if matching_sessions.empty?
print_error("No matching sessions.")
return
end

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assuming it can't return nil, but if it can you'll need to check that too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It returns nil if the user enters invalid input (basically anywhere we'd hit print_error() and the nil bubbles up as a flag to cut off the search, which is why the unless matching_sessions is there - should I go about this differently?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in that case you could change my suggestion here to
if matching_sessions.nil? || matching_sessions.empty?
I would also explicitly return nil if that's what you're expecting

@@ -56,7 +55,7 @@ class Core
["-k", "--kill"] => [ true, "Terminate the specified thread ID.", "<id>" ],
["-K", "--kill-all"] => [ false, "Terminate all non-critical threads." ],
["-i", "--info"] => [ true, "Lists detailed information about a thread.", "<id>" ],
["-l", "--list"] => [ false, "List all background threads." ],
["-l", "--list"] => [ false, "List all background threads." ],
["-v", "--verbose"] => [ false, "Print more detailed info. Use with -i and -l" ])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't comment on the line directly, but updating the --search options above:

    ["-S", "--search"]               => [ true,  "Row search filter.", "<filter>"                                                ],

To include an example of the filter would be useful for users to discover this functionality 👍

The thought being - if the feature isn't documented for users, it doesn't necessarily exist

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding in session_type would be useful too, just to make sure all the options are visible to the user

Comment on lines 679 to 689
expected_values = {
"1s" => 1,
"2s" => 2,
"1.5m" => 90,
"1.5d" => 129600,
"1d1h1m1s" => 90061,
"1.5d1.5h1.5m1.5s" => 135091,
"1.75m70s" => 175
}

it_behaves_like "parses time values correctly", expected_values
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't quite how you use it_behaves_like, which is more useful for testing the different behavior of instances/objects

Shared examples let you describe behaviour of classes or modules. When declared, a shared group’s content is stored. It is only realized in the context of another example group, which provides any context the shared group needs to run.
https://rspec.info/features/3-12/rspec-core/example-groups/shared-examples/

Paired with the above knowledge - and that it's generally best practice is to have 1 expectation for each it block - https://www.betterspecs.org/#single

This pattern might be cleaner:

Suggested change
expected_values = {
"1s" => 1,
"2s" => 2,
"1.5m" => 90,
"1.5d" => 129600,
"1d1h1m1s" => 90061,
"1.5d1.5h1.5m1.5s" => 135091,
"1.75m70s" => 175
}
it_behaves_like "parses time values correctly", expected_values
{
"1s" => 1,
"2s" => 2,
"1.5m" => 90,
"1.5d" => 129600,
"1d1h1m1s" => 90061,
"1.5d1.5h1.5m1.5s" => 135091,
"1.75m70s" => 175
}.each do |input, expected|
it "returns #{expected} seconds for the input #{input}" do
expect(core.parse_duration(input)).to eq(output)
end
end

As it allows you to run failed tests individually from the command line, whilst with your current pattern that's not possible

require 'optparse'

require 'pry-byebug'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
require 'pry-byebug'

This is what's causing the test failures

Comment on lines 1542 to 1568
unless matching_sessions
return
end
if matching_sessions.empty?
print_error("No matching sessions.")
return
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in that case you could change my suggestion here to
if matching_sessions.nil? || matching_sessions.empty?
I would also explicitly return nil if that's what you're expecting

def validate_checkin_searches(checkin_searches)
checkin_searches.each do |search_term|
unless search_term.split(":").length == 3
print_error("Please only specify last_checkin, before or after, and a time. Ex: last_checkin:before:1m3os")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
print_error("Please only specify last_checkin, before or after, and a time. Ex: last_checkin:before:1m3os")
print_error("Please only specify last_checkin, before or after, and a time. Ex: last_checkin:before:1m30s")

return false
end
operator = checkin_searches[0].split(":")[1]
unless operator == "before" || operator == "after"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still have these "before" and "after" strings, you should be able to check if VALID_OPERATORS includes your operator here

return false
end
end
return true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return true
true

pretty sure this is what rubocop would want

end
when "session_id"
return session.sid.to_s == operator
when "session_type"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dropping this comment here but it applies in a few places
when you're using a string like 'session_type' here multiple times you should consider making it a constant

end
end

context "searches with sessions with different ids" do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦 yup isn't, maybe I shouldn't comment on english if I can't do it myself 🙃

end

it "returns matching session when searching for id" do
core.cmd_sessions("--search", "session_id:1")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not seeing it 😅 I can see where you're testing type with checking but not with session id
it might be somewhere and the line numbers have changed since you replied and I just can't see though

context "with sessions" do
let(:sessions) do
{
1 => instance_double(::Msf::Sessions::Meterpreter_x64_Win, last_checkin: Time.now, type: 'meterpreter', sid:1, sname: "sesh1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my head I was thinking you could have two contexts, with and without sessions but it's entirely possible there's some technical issues you might or have already ran into that I'm not thinking of why you can't do that


describe "#parse_duration" do
expected_values = {
"1s" => 1,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a warning is a good shout, I wouldn't expect it to combine them, I reckon it's more likely someone typo'd a second d instead on an h and would appreciate knowing they made a mistake

end
end

context "searches for checkin with sessions that do not respond to checkin" do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you think about adding a test to verify the session doesn't respond to checkin? that way if commandshell gets that feature or some future traveller updates this test unknowingly to a session that does respond to checkin it'll warn them

Comment on lines 117 to 119
SESSION_TYPE = "session_type"
SESSION_ID = "session_id"
LAST_CHECKIN = "last_checkin"
Copy link
Contributor

@dwelch-r7 dwelch-r7 Oct 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
SESSION_TYPE = "session_type"
SESSION_ID = "session_id"
LAST_CHECKIN = "last_checkin"
SESSION_TYPE = 'session_type'
SESSION_ID = 'session_id'
LAST_CHECKIN = 'last_checkin'

should these also be private constants?

end
time_value = search_term.split(":")[2]
time_unit_string = time_value.gsub(/[^a-zA-Z]/, '')
unless time_unit_string == time_unit_string.squeeze
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

squeeze only removes duplicate letters in a row right? so dds becomes ds but it doesn't work with say for example dmd it would remain dmd right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but at that point it'll get caught by the parse_time function and print print_error("Unrecognized time format: #{value}")


def parse_duration(duration)
total_time = 0
time_tokens = duration.scan(/(?:\d+\.?\d*|\.\d+)/).zip(duration.scan(/[a-zA-Z]+/))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens here if the user puts in capital letters? I don't see them being downcase'd anywhere and the switch statement only deals with the lower case characters

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a downcase to the switch 👍

@@ -1794,6 +1843,177 @@ def cmd_sessions(*args)
true
end

def get_matching_sessions(search_term)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zgoldman-r7 I think @dwelch-r7 has called it out a few times now separately - but could we run Rubocop on all new code that's being added? i.e. these chunks of code, and the code in your test. You can run it from the command line with bundle exec rubocop -A file_1.rb file_2.rb

The rest of the file hasn't been rubocop'ed, so you'll just need to make sure the new code is rubocop'ed

@@ -1755,7 +1804,7 @@ def cmd_sessions(*args)
end
when 'list', 'list_inactive', nil
print_line
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, search_term: search_term))
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry dude I know I asked you to change this but it needs to go back, I thought it was a mistake but the tables shouldn't be print_status since it appends a little extra to the output

Suggested change
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))

if matching_sessions
print_status('Killing matching sessions...')
print_line
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here as below, just regular print for tables, my bad

Suggested change
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions))

'1.5d1.5h1.5m1.5s' => 135091,
'1.75m70s' => 175
}.each do |input, expected|
it 'returns #{expected} seconds for the input #{input}' do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String interpolation only works with double quotes, and the indentation is a bit off above; I think Rubocop might've caught both of these

Original comment:
#18364 (comment)

Comment on lines 1987 to 1990
when GREATER_THAN
return checkin_time < threshold_time
when LESS_THAN
return checkin_time > threshold_time
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just thinking out loud here, what do we think about swapping the order of the comparisons here so the switch case lines up with the > and < operators and renaming checkin_time to last_checkin_time to make it clear which one is the user input and which is the sessions actual last checkin time

when GREATER_THAN
  return threshold_time > last_checkin_time
when LESS_THAN
  return threshold_time < last_checkin_time

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh I like it

Comment on lines 123 to 133
VALID_SESSION_SEARCH_PARAMS =
%w[
last_checkin
session_id
session_type
]
VALID_OPERATORS =
%w[
less_than
greater_than
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use the constants defined above in here aswell

@zgoldman-r7 zgoldman-r7 force-pushed the ms9056-list-stale-sessions branch 4 times, most recently from 21f18a1 to c134908 Compare October 19, 2023 14:40
remove single flag

pivot to search flag

added support for search session type

adds search session id support

remove stale references

reshuffle code

fix time parsing, add command support

fix search list, reduce duplicated code

testing added

killall with search lists table of killed sessions

sessions are no longer represented by ids

addresses feedback on code structure and search behavior

some test reshuffling, switch raised errors to printed ones

add checkin validation, rest of cmd_sessions tests

add time parsing test

refactoring

test reformatting and adjusted error validation

make error handling more explicit, add test context

fixes

sub quotes, make constant

rubocopping

switch before and after to greater than and less than

mbetter incorporate constants

update example
@dwelch-r7 dwelch-r7 merged commit 7baabd0 into rapid7:master Oct 19, 2023
34 checks passed
@dwelch-r7
Copy link
Contributor

Release Notes

Add support for filtering sessions based on last checkin time, session type and id

@bwatters-r7 bwatters-r7 added the rn-enhancement release notes enhancement label Oct 19, 2023
@adfoster-r7
Copy link
Contributor

Looks like the verification steps on this PR are wrong 👀

@adfoster-r7
Copy link
Contributor

Looks like the existing issue is still open, I'm not sure if that's intentional or not - or if there's more work required here? 👀

If it was meant to be closed, a trick that we use is following the convention of adding closes #ticket_number in your PR description and it will close the associated issues automatically 🎉

@adfoster-r7
Copy link
Contributor

I think we're missing an associated wiki entry for this functionality so folk can search how to kill stale sessions from the docs site 📚

@adfoster-r7
Copy link
Contributor

adfoster-r7 commented Oct 19, 2023

A small UX niggle I ran into when trying to use this functionality for the weekly wraup;

It looks like there's no user affordance given when the user provides an incorrect search term:

msf6 payload(osx/x64/meterpreter_reverse_tcp) > sessions -S 'id:1 id:2'
[-] Please provide valid search term. Given: id
                                      ^ Error generated, but it's not clear how the user can resolve the issue on their own

For MVP we could just add the list of available valid terms so that the user can self service:

msf6 payload(osx/x64/meterpreter_reverse_tcp) > sessions -S 'id:1 id:2'
[-] Please provide valid search term. Given: id. Supported keywords are: session_id, session_type, last_checkin
                                                  ^ List of available keywords provided to the user

Out of scope -

If the filter functionality ever became complex enough, we could follow the pattern of how the search command works - and output a pretty intuitive help menu:

msf6 payload(osx/x64/meterpreter_reverse_tcp) > search session_id:10
[-] Invalid argument(s)

Usage: search [<options>] [<keywords>:<value>]

Prepending a value with '-' will exclude any matching results.
If no options or keywords are provided, cached results are displayed.


OPTIONS:

    -h, --help                      Help banner
    -I, --ignore                    Ignore the command if the only match has the same name as the search
    -o, --output <filename>         Send output to a file in csv format
    -r, --sort-descending <column>  Reverse the order of search results to descending order
    -S, --filter <filter>           Regex pattern used to filter search results
    -s, --sort-ascending <column>   Sort search results by the specified column in ascending order
    -u, --use                       Use module if there is one result

Keywords:
  adapter          :  Modules with a matching adater reference name
  aka              :  Modules with a matching AKA (also-known-as) name
  author           :  Modules written by this author
  arch             :  Modules affecting this architecture
  bid              :  Modules with a matching Bugtraq ID
  cve              :  Modules with a matching CVE ID
  edb              :  Modules with a matching Exploit-DB ID
  check            :  Modules that support the 'check' method
  date             :  Modules with a matching disclosure date
  description      :  Modules with a matching description
  fullname         :  Modules with a matching full name
  mod_time         :  Modules with a matching modification date
  name             :  Modules with a matching descriptive name
  path             :  Modules with a matching path
  platform         :  Modules affecting this platform
  port             :  Modules with a matching port
  rank             :  Modules with a matching rank (Can be descriptive (ex: 'good') or numeric with comparison operators (ex: 'gte400'))
  ref              :  Modules with a matching ref
  reference        :  Modules with a matching reference
  stage            :  Modules with a matching stage reference name
  stager           :  Modules with a matching stager reference name
  target           :  Modules affecting this target
  type             :  Modules of a specific type (exploit, payload, auxiliary, encoder, evasion, post, or nop)

Supported search columns:
  rank             :  Sort modules by their exploitabilty rank
  date             :  Sort modules by their disclosure date. Alias for disclosure_date
  disclosure_date  :  Sort modules by their disclosure date
  name             :  Sort modules by their name
  type             :  Sort modules by their type
  check            :  Sort modules by whether or not they have a check method

Examples:
  search cve:2009 type:exploit
  search cve:2009 type:exploit platform:-linux
  search cve:2009 -s name
  search type:exploit -s type -r

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
rn-enhancement release notes enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature request: add ability to list "stale" sessions
4 participants